From 7e665057f916845b03883e83deec5a4f155393e6 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 17:50:49 -0400
Subject: [PATCH 063/206] Style Instructor exercise console shell
---
styles.css | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
diff --git a/styles.css b/styles.css
index b95897c..87918d7 100644
--- a/styles.css
+++ b/styles.css
@@ -3224,6 +3224,24 @@ body[data-experience-role="instructor"].instructor-class-connected .wrap{max-wid
}
+/* Instructor staged exercise console */
+.instructor-exercise-console{margin:16px 0 0;padding:12px;border:1px solid var(--line);border-radius:14px;background:var(--panel);}
+.instructor-exercise-console__heading{display:flex;align-items:center;justify-content:space-between;gap:10px;}
+.instructor-exercise-console__heading>div{display:flex;align-items:baseline;gap:8px;min-width:0;flex-wrap:wrap;}
+.instructor-exercise-console__heading strong{color:var(--ink-strong);font-size:13px;}
+.instructor-exercise-console__heading span{color:var(--muted);font-size:10px;}
+.instructor-exercise-console__heading .btn-secondary{padding:6px 9px;font-size:10px;}
+.instructor-exercise-console__summary{margin-top:10px;padding:9px 10px;border-radius:11px;background:var(--panel-muted);}
+.instructor-exercise-console__summary>strong{display:block;color:var(--ink-strong);font-size:12px;}
+.instructor-exercise-console__summary p{margin:4px 0 0;color:var(--muted);font-size:10px;line-height:1.45;}
+.instructor-exercise-console__available{margin-top:9px;}
+.instructor-exercise-console__available>span{display:block;color:var(--muted);font-size:9px;font-weight:800;letter-spacing:.05em;text-transform:uppercase;}
+.instructor-exercise-console__available ul{display:flex;flex-direction:column;gap:5px;max-height:92px;overflow:auto;margin:6px 0 0;padding:0;list-style:none;}
+.instructor-exercise-console__available li{padding:7px 8px;border:1px solid var(--line);border-radius:10px;background:var(--panel-muted);}
+.instructor-exercise-console__available li strong{display:block;color:var(--ink-strong);font-size:11px;}
+.instructor-exercise-console__available li span{display:block;margin-top:2px;color:var(--muted);font-size:9px;line-height:1.35;}
+.instructor-exercise-console__available .instructor-exercise-console__empty{color:var(--muted);font-size:10px;line-height:1.4;}
+
/* Classroom coaching feedback */
.classroom-coaching{margin-top:10px;padding:10px 12px;border:1px solid var(--line);border-radius:12px;background:var(--panel-muted);font-size:12px;line-height:1.45;}
.classroom-coaching__heading{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:7px;}
From 80734d28fe3b1763c3f47031c6fec141e57c1e0a Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 17:51:06 -0400
Subject: [PATCH 064/206] Test Instructor exercise console foundation
---
...assroomExerciseInstructor.feature.test.mjs | 117 ++++++++++++++++++
1 file changed, 117 insertions(+)
create mode 100644 tests/classroomExerciseInstructor.feature.test.mjs
diff --git a/tests/classroomExerciseInstructor.feature.test.mjs b/tests/classroomExerciseInstructor.feature.test.mjs
new file mode 100644
index 0000000..88dee08
--- /dev/null
+++ b/tests/classroomExerciseInstructor.feature.test.mjs
@@ -0,0 +1,117 @@
+/**
+ * Feature coverage for the Tranche 6A Instructor staged-exercise console foundation.
+ */
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { afterEach, test } from 'node:test';
+import { JSDOM } from 'jsdom';
+
+import {
+ INSTRUCTOR_EXERCISE_ENDPOINT,
+ createInstructorExerciseConsoleController
+} from '../src/classroomExerciseInstructor.js';
+
+const INDEX_HTML = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
+const TOKEN = 'i'.repeat(43);
+let dom = null;
+
+function response(status, body) {
+ return { ok: status >= 200 && status < 300, status, json: async () => body };
+}
+
+afterEach(() => {
+ dom?.window.close();
+ dom = null;
+});
+
+test('Instructor exercise console discovers staged summaries without persisting its capability', async () => {
+ dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' });
+ const requests = [];
+ const controller = createInstructorExerciseConsoleController({
+ documentRef: dom.window.document,
+ fetchImpl: async (url, options) => {
+ requests.push([url, options]);
+ return response(200, {
+ class: { id: 'class-1', title: 'PSDM Class' },
+ exercise: null,
+ availableCaseStudies: [
+ { id: 'staged-a', name: 'Staged Case A', description: 'First staged simulation.', supportedModes: ['full'] },
+ { id: 'staged-b', name: 'Staged Case B', description: 'Second staged simulation.', supportedModes: ['intake'] }
+ ]
+ });
+ }
+ });
+
+ assert.equal(await controller.connectInstructor(TOKEN), true);
+ assert.equal(requests.length, 1);
+ assert.equal(requests[0][0], INSTRUCTOR_EXERCISE_ENDPOINT);
+ assert.equal(requests[0][1].method, 'GET');
+ assert.equal(requests[0][1].headers.Authorization, 'Bearer ' + TOKEN);
+ assert.equal(dom.window.document.getElementById('instructorExerciseConsole').hidden, false);
+ assert.equal(dom.window.document.getElementById('instructorExerciseStatus').textContent, '2 staged Case Studies available');
+ assert.equal(dom.window.document.querySelectorAll('#instructorExerciseAvailableList li').length, 2);
+ assert.equal(dom.window.document.getElementById('instructorExerciseAvailableList').textContent.includes('Staged Case A'), true);
+ assert.equal(dom.window.localStorage.length, 0);
+ assert.equal(dom.window.document.body.textContent.includes(TOKEN), false);
+ assert.equal('capability' in controller.getState(), false);
+
+ controller.disconnect();
+ assert.equal(dom.window.document.getElementById('instructorExerciseConsole').hidden, true);
+ assert.deepEqual(controller.getState().availableCaseStudies, []);
+ controller.destroy();
+});
+
+test('Instructor exercise console renders an existing draft without exposing the full simulation through public state', async () => {
+ dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' });
+ const controller = createInstructorExerciseConsoleController({
+ documentRef: dom.window.document,
+ fetchImpl: async () => response(200, {
+ class: { id: 'class-1', title: 'PSDM Class' },
+ exercise: { id: 'exercise-1', status: 'draft', stagePhase: 'work', exerciseRevision: 1, currentStageId: null },
+ caseStudy: {
+ id: 'staged-a',
+ name: 'Staged Case A',
+ description: 'Instructor simulation.',
+ supportedModes: ['full'],
+ simulation: {
+ stages: [{ id: 'stage-1', title: 'Hidden until started', studentObjective: 'Objective' }],
+ instructorContent: [{ body: 'Instructor-only content remains in memory.' }]
+ }
+ },
+ availableCaseStudies: [{ id: 'staged-a', name: 'Staged Case A', description: 'Instructor simulation.', supportedModes: ['full'] }]
+ })
+ });
+
+ assert.equal(await controller.connectInstructor(TOKEN), true);
+ assert.equal(dom.window.document.getElementById('instructorExerciseStatus').textContent, 'Draft');
+ assert.equal(dom.window.document.getElementById('instructorExerciseCaseName').textContent, 'Staged Case A');
+ assert.equal(dom.window.document.getElementById('instructorExerciseDetail').textContent, 'Draft exercise — no stage has started.');
+ assert.deepEqual(controller.getState().caseStudy, {
+ id: 'staged-a',
+ name: 'Staged Case A',
+ description: 'Instructor simulation.',
+ supportedModes: ['full']
+ });
+ assert.equal(JSON.stringify(controller.getState()).includes('Instructor-only content'), false);
+ controller.destroy();
+});
+
+test('Instructor exercise console keeps a connected shell on transient read failure and can refresh', async () => {
+ dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' });
+ let attempt = 0;
+ const controller = createInstructorExerciseConsoleController({
+ documentRef: dom.window.document,
+ fetchImpl: async () => {
+ attempt += 1;
+ if (attempt === 1) return response(500, { error: 'Nope' });
+ return response(200, { class: { id: 'class-1', title: 'PSDM Class' }, exercise: null, availableCaseStudies: [] });
+ }
+ });
+
+ assert.equal(await controller.connectInstructor(TOKEN), false);
+ assert.equal(dom.window.document.getElementById('instructorExerciseStatus').textContent, 'Could not load the class exercise.');
+ assert.equal(dom.window.document.getElementById('instructorExerciseConsole').hidden, false);
+ assert.equal(await controller.refresh(), true);
+ assert.equal(dom.window.document.getElementById('instructorExerciseStatus').textContent, '0 staged Case Studies available');
+ controller.destroy();
+});
From 15bc1528df08a6047a4e774b13049633ee11054d Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 17:51:09 -0400
Subject: [PATCH 065/206] Document Instructor exercise client boundary
---
src/AGENTS.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/src/AGENTS.md b/src/AGENTS.md
index 2505e94..969fd1e 100644
--- a/src/AGENTS.md
+++ b/src/AGENTS.md
@@ -17,6 +17,8 @@ Keep feature modules independently initialisable, own only their feature-specifi
## Protected Classroom resources
+`classroomExerciseInstructor.js` owns the Instructor staged-exercise console client. It receives the Instructor capability only through the class lifecycle, keeps that credential and exercise payload in memory, and reads `/api/classes/exercise`. Exercise state, staged definitions, and capabilities must never enter Intake persistence, summaries, exports, URLs, or public manifests. Tranche 6 lifecycle mutations must remain optimistic-revision protected.
+
`classroomCaseStudies.js` owns the authorized in-memory protected Case Study catalog/payload client. It receives active capabilities only through Student/Instructor lifecycle callbacks, keeps no localStorage/sessionStorage record, and clears catalog/context when the class role disconnects. Its public state must never expose a bearer capability.
`templatesDrawer.js` may combine public Standard Template metadata with the current authorized in-memory Case Study catalog, but must fetch a protected payload only on demand and project it through `projectTemplateState()`. Standalone must work with no protected provider or classroom backend.
From d20a303a54ada7388f49ed9342cd07b6c3f86049 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 17:51:11 -0400
Subject: [PATCH 066/206] Catalog Instructor exercise console anchor
---
docs/commenting-guide.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/docs/commenting-guide.md b/docs/commenting-guide.md
index 0b11968..233b170 100644
--- a/docs/commenting-guide.md
+++ b/docs/commenting-guide.md
@@ -49,6 +49,7 @@ Anchors keep HTML and documentation in sync. Preserve existing tokens and regist
| `[feature:student-class-entry]` | `index.html`, between role chooser and Instructor shell | Student class join/reconnect gate shown before the assigned Intake is available. | `src/classroomStudent.js`, `src/experienceRoleController.js` | `studentClassEntryShell`, `studentClassJoinForm`, class/assignment/name inputs, retry controls; all local-only/summary-excluded. |
| `[feature:student-experience-notice]` | `index.html`, top of the connected Student Intake | Class/workspace/participant context plus explicit Leave class. | `src/classroomStudent.js`, `src/experienceRoleController.js` | `studentExperienceNotice`, `studentClassContextTitle`, `studentClassWorkspace`, `studentClassIdentity`, `studentClassLeaveBtn`; no persisted Intake fields. |
| `[feature:instructor-shell]` | `index.html`, between header and Intake wrap | Instructor class entry/resume and connected class workspace rail. | `src/classroomInstructor.js`, `src/experienceRoleController.js` | `instructorExperienceShell`, `instructorClassForm`, `instructorClassDashboard`, roster search/filter/list, Leave class; all class/session controls are local-only and summary-excluded. |
+| `[feature:instructor-exercise-console]` | `index.html`, inside the connected Instructor dashboard | Read-only Tranche 6 foundation for current staged exercise state and server-filtered staged Case Study availability. | `src/classroomExerciseInstructor.js` | `instructorExerciseConsole`, status/current case detail, staged availability list, Refresh; all exercise context is memory-only and summary-excluded. |
| `[feature:instructor-observer]` | `index.html`, top of the temporarily revealed Intake wrap | Live read-only context for the selected Student/team workspace, including revision and recent activity. Dynamic coaching panels are appended beside registered targets inside this same observed Intake; they do not introduce a new structural HTML anchor. | `src/classroomInstructor.js`, `src/classroomCoaching.js`, `src/coachableFields.js` | `instructorObservationNotice`, observer metadata, plus `.classroom-coaching--instructor` / `.classroom-coaching--student` dynamic local-only panels. Observed Student state and coaching UI are excluded from Intake persistence/summary capture. |
| `[feature:intake-mode-selector]` | `index.html` header title | Wraps the accessible intake mode selector used to choose between General, IT, Pharma, and Major Incident Management lenses before capture. | `src/intakeModes.js`, `src/intakeModeController.js` | `intakeModeSelect`; options use stable mode values and persist under `meta.intakeMode` in `kt-intake-full-v2`. |
| `[feature:major-incident-role-legend]` | `index.html`, below the header | Provides the Major Incident-only four-phase legend mount; phase/primary-role badges decorate mapped workflow headings (including Decision and Risk gates) and every KT row, then hide without unmounting data when the mode changes. Icons and colour swatches are decorative because visible and accessible role text carries their meaning. | `src/majorIncidentRoles.js`, `src/intakeModes.js` | `majorIncidentRoleLegend`, `majorIncidentRoleLegendItems`; role presentation is not persisted, while the decorated controls retain their normal field contracts. |
From 60154826f824939bbc1740b679d374f98472fb5d Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 17:51:47 -0400
Subject: [PATCH 067/206] Document Tranche 6A Instructor console checkpoint
---
docs/classroom-workstream.md | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/docs/classroom-workstream.md b/docs/classroom-workstream.md
index 10aea45..cbbf2c9 100644
--- a/docs/classroom-workstream.md
+++ b/docs/classroom-workstream.md
@@ -447,7 +447,15 @@ Continue #295 on draft PR #307 from the current branch head.
- `d66eeeace6f9e41dade765b6b0331a69a40ebbb0` / `261b28d488538964ccd73132ed568d73628f6411` prove allowed work writes, 423 frozen-write rejection with zero snapshot/revision mutation, frozen GET availability, Standalone isolation, freeze persistence through Pause/Resume, and explicit unfreeze restoring writes.
- stable temporary-lock contract: HTTP **423 Locked**, `code: classroom-editing-locked`.
- validation on `261b28d...`: repository quality **274 tests / 273 pass / 0 fail / 1 intentional skip**; required Browser E2E **26 passed / 8 intentional skips / 0 failed**; CI, CodeQL, Dependency Review, and Template Manifest Guard green.
-- Exact next action: implement **Tranche 6 only — Instructor exercise console** inside the existing live Instructor dashboard. Add staged Case Study selection/draft creation, stage/facilitation display, accessible lifecycle/release/debrief/freeze/advance/complete controls, readiness/progress, conflict refresh, and existing observer/coaching navigation. Keep Student staged-case panel and production official-case staging deferred.
+- Tranche 6A — Instructor exercise console foundation:
+ - implementation spans `2624f1e...` through `d20a303...`;
+ - `GET /api/classes/exercise` now returns `availableCaseStudies` containing **only** staged Case Study summaries; full `simulation` definitions and protected source `state` are not used for discovery;
+ - new `src/classroomExerciseInstructor.js` owns an in-memory Instructor exercise client and read-only console foundation;
+ - the existing Instructor dashboard now includes a `[feature:instructor-exercise-console]` section for current exercise status, staged availability, and explicit refresh;
+ - Instructor capability and exercise context remain outside Intake persistence, summaries, exports, URLs, and public manifests;
+ - no exercise mutations were added in 6A; selection/create and Start/Pause/Debrief controls remain intentionally deferred to the next slice;
+ - focused API/client tests cover staged-only discovery, absence of protected simulation/source material in the discovery list, capability non-persistence, draft rendering, disconnect cleanup, and transient refresh recovery.
+- Exact next action: continue **Tranche 6B — staged Case Study selection and draft creation** inside the existing console. Add an accessible staged-case selector/create action, preserve one-open-exercise and optimistic revision semantics, render the resulting draft, and add focused DOM/browser coverage. Do not add Start/Pause/Resume/debrief lifecycle controls until the following slice; keep Student staged-case UI and production official-case staging deferred.
## Approved Classroom follow-on direction
From a144a646c98fefc99286de3fd900dcd0ff86dd69 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 17:51:52 -0400
Subject: [PATCH 068/206] Document Tranche 6A staged simulation checkpoint
---
docs/classroom-staged-simulation.md | 40 ++++++++++++++++++-----------
1 file changed, 25 insertions(+), 15 deletions(-)
diff --git a/docs/classroom-staged-simulation.md b/docs/classroom-staged-simulation.md
index 672db31..7a7251f 100644
--- a/docs/classroom-staged-simulation.md
+++ b/docs/classroom-staged-simulation.md
@@ -740,20 +740,30 @@ Validation:
- required Browser E2E: **26 passed / 8 intentional project-scoped skips / 0 failed**;
- CI, CodeQL, Dependency Review, and Template Manifest Guard: green.
+## Implementation checkpoint — Tranche 6A Instructor console foundation
+
+The first Instructor-console slice is implemented without adding lifecycle mutations yet.
+
+Foundation contract:
+- `GET /api/classes/exercise` returns an Instructor-authorized `availableCaseStudies` list containing only protected Case Studies with explicit staged `simulation` definitions;
+- discovery items are summaries only: id/name/description/supported modes, with no `simulation`, protected source `state`, future Student content, or Instructor facilitation content;
+- `src/classroomExerciseInstructor.js` owns the in-memory Instructor exercise client and reads the current exercise/discovery state on class connect;
+- the controller never exposes the raw Instructor capability through public state and persists no exercise/capability data;
+- the existing Instructor dashboard owns the new `[feature:instructor-exercise-console]` shell; Tranche 6A exposes status/current case/staged availability plus Refresh only;
+- disconnect clears the console immediately; transient read errors keep the shell recoverable through explicit refresh.
+
+Implementation checkpoint spans `2624f1e...` through `d20a303...`.
+
## Exact next implementation action
-Implement **Tranche 6 only — Instructor exercise console** on the now-enforced server contract.
-
-Required product slice:
-- integrate staged exercise controls into the existing live Instructor dashboard rather than creating a second classroom shell;
-- list only protected Case Studies that carry an explicit staged `simulation` definition as startable simulations;
-- allow Instructor selection/create of a draft and show stage title/objective plus Instructor-only facilitation content;
-- provide accessible Start, Pause, Resume, optional-content Release, Begin debrief, Freeze/Unfreeze, Advance, and Complete controls with optimistic revision refresh/conflict handling;
-- show workspace/team readiness and current stage progress beside the existing roster/workspace navigation;
-- keep existing read-only observer/coaching one click away;
-- clearly distinguish exercise pause from Student editing freeze;
-- do not expose future Student content in Instructor-to-Student DOM/state accidentally; Instructor-only material may be shown only in Instructor experience;
-- retain same-device class resume: exercise state is server state and must be re-fetched, never persisted into Intake;
-- extend deterministic Instructor feature/browser fixtures only enough to prove the console lifecycle before cutting over Student staged-case UI.
-
-Stop before the Student stage-aware case reference panel and before production official Case Study staging.
+Continue **Tranche 6B — staged Case Study selection and draft creation**.
+
+Required next slice:
+- add an accessible staged-case selector using the server-filtered discovery list;
+- create/reuse the selected draft through the existing Instructor POST contract;
+- render the created draft and its safe summary inside the existing console;
+- preserve one-open-exercise behavior and fail closed on conflicting existing exercise state;
+- keep exercise state server-owned and memory-only;
+- add focused DOM/API/browser coverage for selection, create, replay, and conflict refresh.
+
+Do **not** add Start/Pause/Resume/release/debrief/freeze/advance/complete controls in 6B; those follow after the draft-creation slice. Keep Student staged-case UI and production official Case Study staging deferred.
From 6a6942653a6b81d5947a92733d3931ee733177bb Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 17:57:24 -0400
Subject: [PATCH 069/206] Add Instructor exercise draft creation
---
src/classroomExerciseInstructor.js | 195 +++++++++++++++++++++++++----
1 file changed, 172 insertions(+), 23 deletions(-)
diff --git a/src/classroomExerciseInstructor.js b/src/classroomExerciseInstructor.js
index c3909e7..6e10863 100644
--- a/src/classroomExerciseInstructor.js
+++ b/src/classroomExerciseInstructor.js
@@ -1,11 +1,11 @@
/**
* @module classroomExerciseInstructor
- * @summary Owns Instructor staged-exercise discovery and the read-only console foundation.
+ * @summary Owns Instructor staged-exercise discovery, draft setup, and console state.
* @description
* Receives the active Instructor capability from the class lifecycle, keeps it
- * in memory only, and reads /api/classes/exercise. Tranche 6A deliberately
- * exposes no exercise mutation actions; later Tranche 6 slices extend this
- * controller with revision-safe lifecycle controls.
+ * in memory only, and reads/writes the Instructor-authorized exercise endpoint.
+ * Tranche 6B adds staged Case Study selection plus draft create/reuse only;
+ * lifecycle actions remain deferred to later Tranche 6 slices.
*/
export const INSTRUCTOR_EXERCISE_ENDPOINT = '/api/classes/exercise';
@@ -46,13 +46,24 @@ export function createInstructorExerciseConsoleController({
let destroyed = false;
let loading = false;
let lastError = '';
+ let lastNotice = '';
let classroom = null;
let exercise = null;
let caseStudy = null;
let availableCaseStudies = [];
+ let selectedCaseStudyId = '';
const element = id => documentRef?.getElementById?.(id) || null;
+ const currentPhaseLabel = () => {
+ if (!exercise) return '';
+ if (exercise.status === 'draft') return 'Draft';
+ if (exercise.status === 'paused') return 'Paused';
+ if (exercise.stagePhase === 'debrief') return 'Debrief';
+ if (exercise.status === 'completed') return 'Completed';
+ return 'In progress';
+ };
+
const renderAvailable = () => {
const list = element('instructorExerciseAvailableList');
if (!list) return;
@@ -78,6 +89,45 @@ export function createInstructorExerciseConsoleController({
});
};
+ const renderSetup = () => {
+ const select = element('instructorExerciseCaseSelect');
+ const createButton = element('instructorExerciseCreateBtn');
+ const help = element('instructorExerciseSetupHelp');
+ if (!select) return;
+
+ const requestedSelection = exercise?.caseStudyId || selectedCaseStudyId;
+ select.replaceChildren();
+
+ const placeholder = documentRef.createElement('option');
+ placeholder.value = '';
+ placeholder.textContent = availableCaseStudies.length
+ ? 'Select a staged Case Study'
+ : 'No staged Case Studies available';
+ select.append(placeholder);
+
+ availableCaseStudies.forEach(item => {
+ const option = documentRef.createElement('option');
+ option.value = item.id;
+ option.textContent = item.name;
+ select.append(option);
+ });
+
+ const selectionExists = availableCaseStudies.some(item => item.id === requestedSelection);
+ selectedCaseStudyId = selectionExists ? requestedSelection : '';
+ select.value = selectedCaseStudyId;
+ select.disabled = loading || Boolean(exercise) || availableCaseStudies.length === 0;
+
+ if (createButton) {
+ createButton.disabled = loading || Boolean(exercise) || !selectedCaseStudyId;
+ createButton.textContent = loading ? 'Working…' : 'Create draft';
+ }
+ if (help) {
+ help.textContent = exercise
+ ? 'This class already has an open exercise. Lifecycle controls will be added in the next console slices.'
+ : 'Choose an explicitly staged Case Study. Creating a draft does not release anything to Students.';
+ }
+ };
+
const render = () => {
const panel = element('instructorExerciseConsole');
const status = element('instructorExerciseStatus');
@@ -95,21 +145,14 @@ export function createInstructorExerciseConsoleController({
} else if (lastError) {
if (status) status.textContent = lastError;
} else if (exercise) {
- const phase = exercise.status === 'draft'
- ? 'Draft'
- : exercise.status === 'paused'
- ? 'Paused'
- : exercise.stagePhase === 'debrief'
- ? 'Debrief'
- : exercise.status === 'completed'
- ? 'Completed'
- : 'In progress';
- if (status) status.textContent = phase;
+ const phase = currentPhaseLabel();
+ if (status) status.textContent = lastNotice ? `${phase} · ${lastNotice}` : phase;
} else {
const count = availableCaseStudies.length;
- if (status) status.textContent = count === 1
+ const availability = count === 1
? '1 staged Case Study available'
: `${count} staged Case Studies available`;
+ if (status) status.textContent = lastNotice ? `${availability} · ${lastNotice}` : availability;
}
if (exercise && caseStudy) {
@@ -119,23 +162,41 @@ export function createInstructorExerciseConsoleController({
: null;
if (detail) detail.textContent = stage
? `${stage.title} · ${stage.studentObjective}`
- : 'Draft exercise — no stage has started.';
+ : 'Draft exercise — no stage has started and nothing has been released to Students.';
} else {
if (caseName) caseName.textContent = 'No staged exercise selected';
if (detail) detail.textContent = availableCaseStudies.length
- ? 'Staged Case Studies are available. Exercise setup will be enabled in the next console slice.'
+ ? 'Choose a staged Case Study below to create the class exercise draft.'
: 'The console is connected; no staged Case Study definition is currently available.';
}
+ renderSetup();
renderAvailable();
};
const resetState = () => {
loading = false;
lastError = '';
+ lastNotice = '';
classroom = null;
exercise = null;
caseStudy = null;
availableCaseStudies = [];
+ selectedCaseStudyId = '';
+ };
+
+ const applyPayload = body => {
+ classroom = body?.class && typeof body.class === 'object' ? body.class : null;
+ exercise = body?.exercise && typeof body.exercise === 'object' ? body.exercise : null;
+ caseStudy = body?.caseStudy && typeof body.caseStudy === 'object' ? body.caseStudy : null;
+ if (Array.isArray(body?.availableCaseStudies)) {
+ availableCaseStudies = body.availableCaseStudies
+ .map(sanitizeCaseStudySummary)
+ .filter(Boolean);
+ }
+ if (exercise?.caseStudyId) selectedCaseStudyId = exercise.caseStudyId;
+ if (!exercise && selectedCaseStudyId && !availableCaseStudies.some(item => item.id === selectedCaseStudyId)) {
+ selectedCaseStudyId = '';
+ }
};
const refresh = async () => {
@@ -144,6 +205,7 @@ export function createInstructorExerciseConsoleController({
const localEpoch = epoch;
loading = true;
lastError = '';
+ lastNotice = '';
render();
try {
const response = await fetchImpl(INSTRUCTOR_EXERCISE_ENDPOINT, {
@@ -158,12 +220,7 @@ export function createInstructorExerciseConsoleController({
: 'Could not load the class exercise.';
return false;
}
- classroom = body.class && typeof body.class === 'object' ? body.class : null;
- exercise = body.exercise && typeof body.exercise === 'object' ? body.exercise : null;
- caseStudy = body.caseStudy && typeof body.caseStudy === 'object' ? body.caseStudy : null;
- availableCaseStudies = Array.isArray(body.availableCaseStudies)
- ? body.availableCaseStudies.map(sanitizeCaseStudySummary).filter(Boolean)
- : [];
+ applyPayload(body);
return true;
} catch {
if (destroyed || localEpoch !== epoch) return false;
@@ -177,6 +234,80 @@ export function createInstructorExerciseConsoleController({
}
};
+ const createDraft = async (caseStudyId = selectedCaseStudyId) => {
+ if (
+ destroyed
+ || !capability
+ || exercise
+ || loading
+ || typeof fetchImpl !== 'function'
+ ) return false;
+
+ const requestedId = typeof caseStudyId === 'string' ? caseStudyId.trim() : '';
+ if (!availableCaseStudies.some(item => item.id === requestedId)) {
+ lastError = 'Choose a staged Case Study.';
+ lastNotice = '';
+ render();
+ return false;
+ }
+
+ const localCapability = capability;
+ const localEpoch = epoch;
+ selectedCaseStudyId = requestedId;
+ loading = true;
+ lastError = '';
+ lastNotice = '';
+ render();
+
+ try {
+ const response = await fetchImpl(INSTRUCTOR_EXERCISE_ENDPOINT, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${localCapability}`,
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ caseStudyId: requestedId })
+ });
+ const body = await response.json().catch(() => ({}));
+ if (destroyed || localEpoch !== epoch || capability !== localCapability) return false;
+
+ if (response.status === 409) {
+ loading = false;
+ const reloaded = await refresh();
+ if (reloaded && !destroyed && localEpoch === epoch) {
+ lastNotice = 'current state reloaded';
+ render();
+ } else if (!lastError) {
+ lastError = typeof body.error === 'string' ? body.error : 'Exercise changed. Refresh and retry.';
+ render();
+ }
+ return false;
+ }
+
+ if (!response.ok) {
+ lastError = response.status === 401 || response.status === 404
+ ? 'Exercise access is no longer available.'
+ : typeof body.error === 'string' && body.error
+ ? body.error
+ : 'Could not create the exercise draft.';
+ return false;
+ }
+
+ applyPayload(body);
+ lastNotice = body.created === false ? 'existing draft reused' : 'draft created';
+ return true;
+ } catch {
+ if (destroyed || localEpoch !== epoch) return false;
+ lastError = 'Could not create the exercise draft.';
+ return false;
+ } finally {
+ if (!destroyed && localEpoch === epoch) {
+ loading = false;
+ render();
+ }
+ }
+ };
+
const connectInstructor = async token => {
if (!validCapability(token) || destroyed) return false;
epoch += 1;
@@ -194,25 +325,43 @@ export function createInstructorExerciseConsoleController({
};
const handleRefresh = () => { void refresh(); };
+ const handleSelect = event => {
+ selectedCaseStudyId = typeof event?.target?.value === 'string' ? event.target.value : '';
+ lastError = '';
+ lastNotice = '';
+ render();
+ };
+ const handleSetupSubmit = event => {
+ event.preventDefault();
+ void createDraft();
+ };
+
element('instructorExerciseRefreshBtn')?.addEventListener('click', handleRefresh);
+ element('instructorExerciseCaseSelect')?.addEventListener('change', handleSelect);
+ element('instructorExerciseSetupForm')?.addEventListener('submit', handleSetupSubmit);
render();
return {
connectInstructor,
disconnect,
refresh,
+ createDraft,
getState: () => ({
connected: Boolean(capability),
loading,
lastError,
+ lastNotice,
classroom,
exercise,
+ selectedCaseStudyId,
caseStudy: caseStudy ? sanitizeCaseStudySummary(caseStudy) : null,
availableCaseStudies: availableCaseStudies.map(item => ({ ...item }))
}),
destroy: () => {
if (destroyed) return;
element('instructorExerciseRefreshBtn')?.removeEventListener('click', handleRefresh);
+ element('instructorExerciseCaseSelect')?.removeEventListener('change', handleSelect);
+ element('instructorExerciseSetupForm')?.removeEventListener('submit', handleSetupSubmit);
disconnect();
destroyed = true;
}
From 1324ff99f53d91b845c267a22913dc2e854abbfc Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 17:57:27 -0400
Subject: [PATCH 070/206] Add staged exercise draft selector
---
index.html | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/index.html b/index.html
index 392780b..0acbcdc 100644
--- a/index.html
+++ b/index.html
@@ -423,6 +423,16 @@
Class
No staged exercise selected
Staged Case Studies available to this class will appear here.
+
Available staged Case Studies
From a457864ea50e867d752fe00b597dd062a2f4c1a0 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 17:57:30 -0400
Subject: [PATCH 071/206] Style staged exercise draft setup
---
styles.css | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/styles.css b/styles.css
index 87918d7..8207485 100644
--- a/styles.css
+++ b/styles.css
@@ -3234,6 +3234,12 @@ body[data-experience-role="instructor"].instructor-class-connected .wrap{max-wid
.instructor-exercise-console__summary{margin-top:10px;padding:9px 10px;border-radius:11px;background:var(--panel-muted);}
.instructor-exercise-console__summary>strong{display:block;color:var(--ink-strong);font-size:12px;}
.instructor-exercise-console__summary p{margin:4px 0 0;color:var(--muted);font-size:10px;line-height:1.45;}
+.instructor-exercise-console__setup{margin-top:10px;}
+.instructor-exercise-console__setup>label{display:block;margin-bottom:5px;color:var(--ink-strong);font-size:10px;font-weight:750;}
+.instructor-exercise-console__setup-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px;}
+.instructor-exercise-console__setup-row select{min-width:0;font-size:10px;}
+.instructor-exercise-console__setup-row .btn-secondary{padding:7px 9px;font-size:10px;white-space:nowrap;}
+.instructor-exercise-console__setup small{display:block;margin-top:5px;color:var(--muted);font-size:9px;line-height:1.4;}
.instructor-exercise-console__available{margin-top:9px;}
.instructor-exercise-console__available>span{display:block;color:var(--muted);font-size:9px;font-weight:800;letter-spacing:.05em;text-transform:uppercase;}
.instructor-exercise-console__available ul{display:flex;flex-direction:column;gap:5px;max-height:92px;overflow:auto;margin:6px 0 0;padding:0;list-style:none;}
From e4deeaf3f9e0322d8770eb30709d50e618037b19 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 17:58:00 -0400
Subject: [PATCH 072/206] Test Instructor exercise draft creation
---
...assroomExerciseInstructor.feature.test.mjs | 170 ++++++++++++++----
1 file changed, 135 insertions(+), 35 deletions(-)
diff --git a/tests/classroomExerciseInstructor.feature.test.mjs b/tests/classroomExerciseInstructor.feature.test.mjs
index 88dee08..4a0ae1f 100644
--- a/tests/classroomExerciseInstructor.feature.test.mjs
+++ b/tests/classroomExerciseInstructor.feature.test.mjs
@@ -1,5 +1,5 @@
/**
- * Feature coverage for the Tranche 6A Instructor staged-exercise console foundation.
+ * Feature coverage for the Tranche 6 Instructor staged-exercise console.
*/
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
@@ -19,26 +19,55 @@ function response(status, body) {
return { ok: status >= 200 && status < 300, status, json: async () => body };
}
+function discoveryBody() {
+ return {
+ class: { id: 'class-1', title: 'PSDM Class' },
+ exercise: null,
+ availableCaseStudies: [
+ { id: 'staged-a', name: 'Staged Case A', description: 'First staged simulation.', supportedModes: ['full'] },
+ { id: 'staged-b', name: 'Staged Case B', description: 'Second staged simulation.', supportedModes: ['intake'] }
+ ]
+ };
+}
+
+function draftBody({ created = true, caseStudyId = 'staged-a', name = 'Staged Case A' } = {}) {
+ return {
+ class: { id: 'class-1', title: 'PSDM Class' },
+ exercise: {
+ id: 'exercise-1',
+ caseStudyId,
+ status: 'draft',
+ stagePhase: 'work',
+ exerciseRevision: 1,
+ currentStageId: null
+ },
+ caseStudy: {
+ id: caseStudyId,
+ name,
+ description: 'Instructor simulation.',
+ supportedModes: ['full'],
+ simulation: {
+ stages: [{ id: 'stage-1', title: 'Hidden until started', studentObjective: 'Objective' }],
+ instructorContent: [{ body: 'Instructor-only content remains in memory.' }]
+ }
+ },
+ created
+ };
+}
+
afterEach(() => {
dom?.window.close();
dom = null;
});
-test('Instructor exercise console discovers staged summaries without persisting its capability', async () => {
+test('Instructor exercise console discovers staged summaries and enables explicit selection without persisting capability', async () => {
dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' });
const requests = [];
const controller = createInstructorExerciseConsoleController({
documentRef: dom.window.document,
fetchImpl: async (url, options) => {
requests.push([url, options]);
- return response(200, {
- class: { id: 'class-1', title: 'PSDM Class' },
- exercise: null,
- availableCaseStudies: [
- { id: 'staged-a', name: 'Staged Case A', description: 'First staged simulation.', supportedModes: ['full'] },
- { id: 'staged-b', name: 'Staged Case B', description: 'Second staged simulation.', supportedModes: ['intake'] }
- ]
- });
+ return response(200, discoveryBody());
}
});
@@ -50,7 +79,17 @@ test('Instructor exercise console discovers staged summaries without persisting
assert.equal(dom.window.document.getElementById('instructorExerciseConsole').hidden, false);
assert.equal(dom.window.document.getElementById('instructorExerciseStatus').textContent, '2 staged Case Studies available');
assert.equal(dom.window.document.querySelectorAll('#instructorExerciseAvailableList li').length, 2);
- assert.equal(dom.window.document.getElementById('instructorExerciseAvailableList').textContent.includes('Staged Case A'), true);
+
+ const select = dom.window.document.getElementById('instructorExerciseCaseSelect');
+ const createButton = dom.window.document.getElementById('instructorExerciseCreateBtn');
+ assert.equal(select.options.length, 3);
+ assert.equal(createButton.disabled, true);
+
+ select.value = 'staged-a';
+ select.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
+ assert.equal(controller.getState().selectedCaseStudyId, 'staged-a');
+ assert.equal(createButton.disabled, false);
+
assert.equal(dom.window.localStorage.length, 0);
assert.equal(dom.window.document.body.textContent.includes(TOKEN), false);
assert.equal('capability' in controller.getState(), false);
@@ -61,38 +100,99 @@ test('Instructor exercise console discovers staged summaries without persisting
controller.destroy();
});
-test('Instructor exercise console renders an existing draft without exposing the full simulation through public state', async () => {
+test('Instructor creates a selected staged draft without releasing Student content or exposing full simulation through public state', async () => {
dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' });
+ const requests = [];
const controller = createInstructorExerciseConsoleController({
documentRef: dom.window.document,
- fetchImpl: async () => response(200, {
- class: { id: 'class-1', title: 'PSDM Class' },
- exercise: { id: 'exercise-1', status: 'draft', stagePhase: 'work', exerciseRevision: 1, currentStageId: null },
- caseStudy: {
- id: 'staged-a',
- name: 'Staged Case A',
- description: 'Instructor simulation.',
- supportedModes: ['full'],
- simulation: {
- stages: [{ id: 'stage-1', title: 'Hidden until started', studentObjective: 'Objective' }],
- instructorContent: [{ body: 'Instructor-only content remains in memory.' }]
- }
- },
- availableCaseStudies: [{ id: 'staged-a', name: 'Staged Case A', description: 'Instructor simulation.', supportedModes: ['full'] }]
- })
+ fetchImpl: async (url, options) => {
+ requests.push([url, options]);
+ if (options.method === 'GET') return response(200, discoveryBody());
+ return response(201, draftBody({ created: true }));
+ }
});
assert.equal(await controller.connectInstructor(TOKEN), true);
- assert.equal(dom.window.document.getElementById('instructorExerciseStatus').textContent, 'Draft');
+ const select = dom.window.document.getElementById('instructorExerciseCaseSelect');
+ select.value = 'staged-a';
+ select.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
+
+ assert.equal(await controller.createDraft(), true);
+ assert.equal(requests.length, 2);
+ assert.equal(requests[1][1].method, 'POST');
+ assert.equal(requests[1][1].headers.Authorization, 'Bearer ' + TOKEN);
+ assert.equal(requests[1][1].headers['Content-Type'], 'application/json');
+ assert.deepEqual(JSON.parse(requests[1][1].body), { caseStudyId: 'staged-a' });
+
+ assert.equal(dom.window.document.getElementById('instructorExerciseStatus').textContent, 'Draft · draft created');
assert.equal(dom.window.document.getElementById('instructorExerciseCaseName').textContent, 'Staged Case A');
- assert.equal(dom.window.document.getElementById('instructorExerciseDetail').textContent, 'Draft exercise — no stage has started.');
- assert.deepEqual(controller.getState().caseStudy, {
- id: 'staged-a',
- name: 'Staged Case A',
- description: 'Instructor simulation.',
- supportedModes: ['full']
- });
+ assert.equal(
+ dom.window.document.getElementById('instructorExerciseDetail').textContent,
+ 'Draft exercise — no stage has started and nothing has been released to Students.'
+ );
+ assert.equal(dom.window.document.getElementById('instructorExerciseCaseSelect').disabled, true);
+ assert.equal(dom.window.document.getElementById('instructorExerciseCreateBtn').disabled, true);
+ assert.equal(controller.getState().exercise.exerciseRevision, 1);
assert.equal(JSON.stringify(controller.getState()).includes('Instructor-only content'), false);
+ assert.equal(dom.window.localStorage.length, 0);
+ controller.destroy();
+});
+
+test('Instructor accepts an idempotent existing-draft response as the represented current exercise', async () => {
+ dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' });
+ const controller = createInstructorExerciseConsoleController({
+ documentRef: dom.window.document,
+ fetchImpl: async (_url, options) => options.method === 'GET'
+ ? response(200, discoveryBody())
+ : response(200, draftBody({ created: false }))
+ });
+
+ await controller.connectInstructor(TOKEN);
+ const select = dom.window.document.getElementById('instructorExerciseCaseSelect');
+ select.value = 'staged-a';
+ select.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
+
+ assert.equal(await controller.createDraft(), true);
+ assert.equal(dom.window.document.getElementById('instructorExerciseStatus').textContent, 'Draft · existing draft reused');
+ assert.equal(controller.getState().exercise.caseStudyId, 'staged-a');
+ controller.destroy();
+});
+
+test('Instructor draft conflict reloads authoritative current exercise instead of retrying a stale create', async () => {
+ dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' });
+ const requests = [];
+ let getCount = 0;
+ const controller = createInstructorExerciseConsoleController({
+ documentRef: dom.window.document,
+ fetchImpl: async (_url, options) => {
+ requests.push(options.method);
+ if (options.method === 'POST') {
+ return response(409, {
+ error: 'Another exercise is already open for this class.',
+ exercise: { id: 'exercise-race', caseStudyId: 'staged-b', exerciseRevision: 2 }
+ });
+ }
+ getCount += 1;
+ if (getCount === 1) return response(200, discoveryBody());
+ return response(200, {
+ ...discoveryBody(),
+ ...draftBody({ created: false, caseStudyId: 'staged-b', name: 'Staged Case B' }),
+ availableCaseStudies: discoveryBody().availableCaseStudies
+ });
+ }
+ });
+
+ await controller.connectInstructor(TOKEN);
+ const select = dom.window.document.getElementById('instructorExerciseCaseSelect');
+ select.value = 'staged-a';
+ select.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
+
+ assert.equal(await controller.createDraft(), false);
+ assert.deepEqual(requests, ['GET', 'POST', 'GET']);
+ assert.equal(controller.getState().exercise.caseStudyId, 'staged-b');
+ assert.equal(controller.getState().selectedCaseStudyId, 'staged-b');
+ assert.equal(dom.window.document.getElementById('instructorExerciseCaseName').textContent, 'Staged Case B');
+ assert.equal(dom.window.document.getElementById('instructorExerciseStatus').textContent, 'Draft · current state reloaded');
controller.destroy();
});
From 57ecf55fb3c829abaaaf48f03a45c41bcb6d3695 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 17:58:04 -0400
Subject: [PATCH 073/206] Harden integrated classroom browser timeout
---
tests/e2e/classroom-live-integration.spec.mjs | 1 +
1 file changed, 1 insertion(+)
diff --git a/tests/e2e/classroom-live-integration.spec.mjs b/tests/e2e/classroom-live-integration.spec.mjs
index 34896c9..29f22e3 100644
--- a/tests/e2e/classroom-live-integration.spec.mjs
+++ b/tests/e2e/classroom-live-integration.spec.mjs
@@ -46,6 +46,7 @@ async function createTeam(page, label) {
}
test('live class integrates Instructor roster, team sync, isolation, coaching, reassignment, and unassign', async ({ browser }, testInfo) => {
+ test.setTimeout(45_000);
test.skip(testInfo.project.name === 'chromium-mobile', 'Integrated four-browser classroom acceptance is covered once on desktop; mobile Student and Instructor paths have dedicated coverage.');
const contextOptions = { baseURL: testInfo.project.use.baseURL };
From c3440eb7c032c6feee98e79d534f139f4ae9bc83 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 17:58:31 -0400
Subject: [PATCH 074/206] Add staged exercise browser fixture
---
scripts/serve-browser-tests.mjs | 137 ++++++++++++++++++++++++++++++++
1 file changed, 137 insertions(+)
diff --git a/scripts/serve-browser-tests.mjs b/scripts/serve-browser-tests.mjs
index 80121c5..7dbfb5e 100644
--- a/scripts/serve-browser-tests.mjs
+++ b/scripts/serve-browser-tests.mjs
@@ -48,6 +48,44 @@ const STUDENT_LIVE_CLASS = Object.freeze({
title: 'Browser Student Live Classroom',
expiresAt: CLASSROOM_EXPIRY
});
+const BROWSER_STAGED_CASE = Object.freeze({
+ id: 'browser-staged-simulation',
+ name: 'Browser Staged Simulation',
+ description: 'Synthetic staged Case Study used only by deterministic browser acceptance.',
+ supportedModes: ['full'],
+ simulation: {
+ version: 1,
+ studentContent: [
+ {
+ id: 'browser-brief-1',
+ kind: 'narrative',
+ title: 'Initial browser briefing',
+ body: 'Synthetic Student-safe browser briefing.'
+ }
+ ],
+ instructorContent: [
+ {
+ id: 'browser-teach-1',
+ kind: 'facilitation',
+ title: 'Browser facilitation note',
+ body: 'Synthetic Instructor-only browser facilitation.'
+ }
+ ],
+ stages: [
+ {
+ id: 'browser-stage-1',
+ title: 'Clarify the browser case',
+ studentObjective: 'Capture the initial situation in Intake.',
+ initialReleaseIds: ['browser-brief-1'],
+ optionalReleaseIds: [],
+ intakeTargetIds: ['problem.one-line'],
+ suggestedMinutes: 5,
+ instructorContentIds: ['browser-teach-1'],
+ defaultDebriefEditPolicy: 'frozen'
+ }
+ ]
+ }
+});
let liveClassState = null;
let liveInstructorWorkspaces = [];
let liveInstructorParticipants = [];
@@ -61,6 +99,7 @@ const studentLiveSessions = new Map();
const studentLiveAccessContexts = new Map();
const classroomWorkspaces = new Map();
const classroomCoachingFeedback = new Map();
+const classroomExercises = new Map();
const CONTENT_TYPES = Object.freeze({
'.css': 'text/css; charset=utf-8',
@@ -606,6 +645,7 @@ async function handleClassroomApi(request, response, url) {
integratedInstructorWorkspaces = [];
integratedInstructorWorkspaceStates.clear();
integratedInstructorParticipants = [];
+ classroomExercises.delete(INTEGRATED_INSTRUCTOR_TOKEN);
sendJson(response, 201, {
class: integratedInstructorClass(),
instructorToken: INTEGRATED_INSTRUCTOR_TOKEN,
@@ -623,6 +663,7 @@ async function handleClassroomApi(request, response, url) {
};
liveInstructorWorkspaces = [];
liveInstructorWorkspaceStates.clear();
+ classroomExercises.delete(LIVE_INSTRUCTOR_TOKEN);
liveInstructorParticipants = [{
id: LIVE_PARTICIPANT_ID,
displayName: 'Waiting Student',
@@ -640,6 +681,102 @@ async function handleClassroomApi(request, response, url) {
return true;
}
+ if (url.pathname === '/api/classes/exercise') {
+ const instructorToken = bearerToken(request);
+ if (!activeInstructorCapability(instructorToken)) {
+ sendJson(response, 401, { error: 'Missing or invalid instructor authorization.' });
+ return true;
+ }
+
+ const managedFixture = managedInstructorFixture(instructorToken);
+ const classContext = managedFixture?.classContext || instructorClass();
+ const availableCaseStudies = [{
+ id: BROWSER_STAGED_CASE.id,
+ name: BROWSER_STAGED_CASE.name,
+ description: BROWSER_STAGED_CASE.description,
+ supportedModes: [...BROWSER_STAGED_CASE.supportedModes]
+ }];
+
+ if (request.method === 'GET') {
+ const exercise = classroomExercises.get(instructorToken) || null;
+ sendJson(response, 200, exercise
+ ? {
+ class: classContext,
+ exercise: structuredClone(exercise),
+ caseStudy: structuredClone(BROWSER_STAGED_CASE),
+ releases: [],
+ workspaceState: [],
+ checkpoints: [],
+ editFreezeEnforced: true,
+ availableCaseStudies
+ }
+ : {
+ class: classContext,
+ exercise: null,
+ availableCaseStudies
+ });
+ return true;
+ }
+
+ if (request.method === 'POST') {
+ const body = await readJson(request).catch(() => null);
+ const caseStudyId = typeof body?.caseStudyId === 'string' ? body.caseStudyId.trim() : '';
+ if (caseStudyId !== BROWSER_STAGED_CASE.id) {
+ sendJson(response, 404, { error: 'Staged Case Study not found.' });
+ return true;
+ }
+
+ const existing = classroomExercises.get(instructorToken);
+ if (existing) {
+ if (existing.caseStudyId !== caseStudyId) {
+ sendJson(response, 409, {
+ error: 'Another exercise is already open for this class.',
+ exercise: structuredClone(existing)
+ });
+ return true;
+ }
+ sendJson(response, 200, {
+ class: classContext,
+ exercise: structuredClone(existing),
+ caseStudy: structuredClone(BROWSER_STAGED_CASE),
+ releases: [],
+ workspaceState: [],
+ checkpoints: [],
+ editFreezeEnforced: true,
+ created: false
+ });
+ return true;
+ }
+
+ const exercise = {
+ id: `browser-exercise-${instructorToken.slice(-1)}`,
+ caseStudyId,
+ status: 'draft',
+ currentStageId: null,
+ stagePhase: 'work',
+ studentEditingEnabled: true,
+ exerciseRevision: 1,
+ simulationVersion: BROWSER_STAGED_CASE.simulation.version,
+ simulationFingerprint: 'b'.repeat(64)
+ };
+ classroomExercises.set(instructorToken, exercise);
+ sendJson(response, 201, {
+ class: classContext,
+ exercise: structuredClone(exercise),
+ caseStudy: structuredClone(BROWSER_STAGED_CASE),
+ releases: [],
+ workspaceState: [],
+ checkpoints: [],
+ editFreezeEnforced: true,
+ created: true
+ });
+ return true;
+ }
+
+ sendJson(response, 405, { error: 'Method not allowed.' });
+ return true;
+ }
+
if (url.pathname === '/api/classes/workspaces') {
const instructorToken = bearerToken(request);
if (!activeInstructorCapability(instructorToken)) {
From 1035ed70d195583393053d9817c27e1678f8484d Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 17:58:55 -0400
Subject: [PATCH 075/206] Cover Instructor exercise draft creation in browser
---
tests/e2e/instructor.spec.mjs | 13 +++++++++++++
1 file changed, 13 insertions(+)
diff --git a/tests/e2e/instructor.spec.mjs b/tests/e2e/instructor.spec.mjs
index 0245209..530f91f 100644
--- a/tests/e2e/instructor.spec.mjs
+++ b/tests/e2e/instructor.spec.mjs
@@ -68,6 +68,14 @@ test('Instructor starts a live class, creates a team, assigns a waiting Student,
await expect(page.locator('[data-participant-id="99999999-9999-4999-8999-999999999999"]')).toContainText('Waiting Student');
await expect(page.getByLabel('Assignment for Waiting Student')).toHaveValue('');
+ await expect(page.locator('#instructorExerciseConsole')).toBeVisible();
+ await expect(page.locator('#instructorExerciseStatus')).toHaveText('1 staged Case Study available');
+ await page.getByLabel('Staged Case Study').selectOption('browser-staged-simulation');
+ await page.getByRole('button', { name: 'Create draft' }).click();
+ await expect(page.locator('#instructorExerciseStatus')).toHaveText('Draft · draft created');
+ await expect(page.locator('#instructorExerciseCaseName')).toHaveText('Browser Staged Simulation');
+ await expect(page.locator('#instructorExerciseCreateBtn')).toBeDisabled();
+
const storedAfterStart = await page.evaluate(key => window.localStorage.getItem(key), INSTRUCTOR_SESSION_STORAGE_KEY);
expect(storedAfterStart).toContain('K7FM-P4Q2');
expect(storedAfterStart).not.toContain('studentJoinToken');
@@ -90,6 +98,7 @@ test('Instructor starts a live class, creates a team, assigns a waiting Student,
expect(classroomRequests.some(request => request.method === 'POST' && request.pathname === '/api/classes')).toBe(true);
expect(classroomRequests.some(request => request.method === 'POST' && request.pathname === '/api/classes/workspaces')).toBe(true);
+ expect(classroomRequests.some(request => request.method === 'POST' && request.pathname === '/api/classes/exercise')).toBe(true);
expect(classroomRequests.some(request => request.method === 'PATCH' && request.pathname === '/api/classes/participants')).toBe(true);
expect(classroomRequests.some(request => request.pathname === '/api/classes/join')).toBe(false);
expect(classroomRequests.some(request => request.body.includes('assignmentToken'))).toBe(false);
@@ -103,6 +112,10 @@ test('Instructor starts a live class, creates a team, assigns a waiting Student,
await expect(page.locator('#instructorJoinCode')).toHaveText('K7FM-P4Q2');
await expect(page.locator('#instructorParticipantSummary')).toHaveText('1 student · 0 waiting');
await expect(page.getByLabel('Assignment for Waiting Student')).not.toHaveValue('');
+ await expect(page.locator('#instructorExerciseStatus')).toHaveText('Draft');
+ await expect(page.locator('#instructorExerciseCaseName')).toHaveText('Browser Staged Simulation');
+ await expect(page.getByLabel('Staged Case Study')).toHaveValue('browser-staged-simulation');
+ await expect(page.locator('#instructorExerciseCreateBtn')).toBeDisabled();
await expectNoBlockingA11yViolations(page);
expect(pageErrors).toEqual([]);
From 40a113ba21dbb10a99fe319bde97806492b501ed Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 17:59:40 -0400
Subject: [PATCH 076/206] Document Instructor draft creation boundary
---
src/AGENTS.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/AGENTS.md b/src/AGENTS.md
index 969fd1e..537b05c 100644
--- a/src/AGENTS.md
+++ b/src/AGENTS.md
@@ -17,7 +17,7 @@ Keep feature modules independently initialisable, own only their feature-specifi
## Protected Classroom resources
-`classroomExerciseInstructor.js` owns the Instructor staged-exercise console client. It receives the Instructor capability only through the class lifecycle, keeps that credential and exercise payload in memory, and reads `/api/classes/exercise`. Exercise state, staged definitions, and capabilities must never enter Intake persistence, summaries, exports, URLs, or public manifests. Tranche 6 lifecycle mutations must remain optimistic-revision protected.
+`classroomExerciseInstructor.js` owns the Instructor staged-exercise console client. It receives the Instructor capability only through the class lifecycle, keeps that credential and exercise payload in memory, and reads `/api/classes/exercise`; draft selection/create may POST only an explicitly discovered staged `caseStudyId`. Exercise state, staged definitions, and capabilities must never enter Intake persistence, summaries, exports, URLs, or public manifests. Draft conflicts must reload authoritative server state rather than retry stale intent; later Tranche 6 lifecycle mutations must remain optimistic-revision protected.
`classroomCaseStudies.js` owns the authorized in-memory protected Case Study catalog/payload client. It receives active capabilities only through Student/Instructor lifecycle callbacks, keeps no localStorage/sessionStorage record, and clears catalog/context when the class role disconnects. Its public state must never expose a bearer capability.
From a503be8adff69bd0ca5a6dd79d6e7d07538bd241 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 17:59:43 -0400
Subject: [PATCH 077/206] Update Instructor exercise console anchor
---
docs/commenting-guide.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/commenting-guide.md b/docs/commenting-guide.md
index 233b170..53740f8 100644
--- a/docs/commenting-guide.md
+++ b/docs/commenting-guide.md
@@ -49,7 +49,7 @@ Anchors keep HTML and documentation in sync. Preserve existing tokens and regist
| `[feature:student-class-entry]` | `index.html`, between role chooser and Instructor shell | Student class join/reconnect gate shown before the assigned Intake is available. | `src/classroomStudent.js`, `src/experienceRoleController.js` | `studentClassEntryShell`, `studentClassJoinForm`, class/assignment/name inputs, retry controls; all local-only/summary-excluded. |
| `[feature:student-experience-notice]` | `index.html`, top of the connected Student Intake | Class/workspace/participant context plus explicit Leave class. | `src/classroomStudent.js`, `src/experienceRoleController.js` | `studentExperienceNotice`, `studentClassContextTitle`, `studentClassWorkspace`, `studentClassIdentity`, `studentClassLeaveBtn`; no persisted Intake fields. |
| `[feature:instructor-shell]` | `index.html`, between header and Intake wrap | Instructor class entry/resume and connected class workspace rail. | `src/classroomInstructor.js`, `src/experienceRoleController.js` | `instructorExperienceShell`, `instructorClassForm`, `instructorClassDashboard`, roster search/filter/list, Leave class; all class/session controls are local-only and summary-excluded. |
-| `[feature:instructor-exercise-console]` | `index.html`, inside the connected Instructor dashboard | Read-only Tranche 6 foundation for current staged exercise state and server-filtered staged Case Study availability. | `src/classroomExerciseInstructor.js` | `instructorExerciseConsole`, status/current case detail, staged availability list, Refresh; all exercise context is memory-only and summary-excluded. |
+| `[feature:instructor-exercise-console]` | `index.html`, inside the connected Instructor dashboard | Instructor staged-exercise discovery and draft setup within the existing class rail. | `src/classroomExerciseInstructor.js` | `instructorExerciseConsole`, staged Case Study selector, Create draft, current case/status, staged availability list, Refresh; all exercise context is memory-only and summary-excluded. |
| `[feature:instructor-observer]` | `index.html`, top of the temporarily revealed Intake wrap | Live read-only context for the selected Student/team workspace, including revision and recent activity. Dynamic coaching panels are appended beside registered targets inside this same observed Intake; they do not introduce a new structural HTML anchor. | `src/classroomInstructor.js`, `src/classroomCoaching.js`, `src/coachableFields.js` | `instructorObservationNotice`, observer metadata, plus `.classroom-coaching--instructor` / `.classroom-coaching--student` dynamic local-only panels. Observed Student state and coaching UI are excluded from Intake persistence/summary capture. |
| `[feature:intake-mode-selector]` | `index.html` header title | Wraps the accessible intake mode selector used to choose between General, IT, Pharma, and Major Incident Management lenses before capture. | `src/intakeModes.js`, `src/intakeModeController.js` | `intakeModeSelect`; options use stable mode values and persist under `meta.intakeMode` in `kt-intake-full-v2`. |
| `[feature:major-incident-role-legend]` | `index.html`, below the header | Provides the Major Incident-only four-phase legend mount; phase/primary-role badges decorate mapped workflow headings (including Decision and Risk gates) and every KT row, then hide without unmounting data when the mode changes. Icons and colour swatches are decorative because visible and accessible role text carries their meaning. | `src/majorIncidentRoles.js`, `src/intakeModes.js` | `majorIncidentRoleLegend`, `majorIncidentRoleLegendItems`; role presentation is not persisted, while the decorated controls retain their normal field contracts. |
From 0a9d4b452f5cd8c1c4fc599b8ecf820412e49b77 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 17:59:45 -0400
Subject: [PATCH 078/206] Document Tranche 6B draft creation checkpoint
---
docs/classroom-workstream.md | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)
diff --git a/docs/classroom-workstream.md b/docs/classroom-workstream.md
index cbbf2c9..7f62e45 100644
--- a/docs/classroom-workstream.md
+++ b/docs/classroom-workstream.md
@@ -455,7 +455,17 @@ Continue #295 on draft PR #307 from the current branch head.
- Instructor capability and exercise context remain outside Intake persistence, summaries, exports, URLs, and public manifests;
- no exercise mutations were added in 6A; selection/create and Start/Pause/Debrief controls remain intentionally deferred to the next slice;
- focused API/client tests cover staged-only discovery, absence of protected simulation/source material in the discovery list, capability non-persistence, draft rendering, disconnect cleanup, and transient refresh recovery.
-- Exact next action: continue **Tranche 6B — staged Case Study selection and draft creation** inside the existing console. Add an accessible staged-case selector/create action, preserve one-open-exercise and optimistic revision semantics, render the resulting draft, and add focused DOM/browser coverage. Do not add Start/Pause/Resume/debrief lifecycle controls until the following slice; keep Student staged-case UI and production official-case staging deferred.
+- Tranche 6B — staged Case Study selection and draft creation:
+ - implementation spans `6a69426...` through `1035ed7...`, with deterministic browser fixture support at `c3440eb...`;
+ - the console now exposes an accessible staged Case Study selector and **Create draft** action only when no exercise is open;
+ - POST sends only the selected server-discovered `caseStudyId`; successful create and idempotent existing-draft reuse both hydrate the represented server exercise without persisting it locally;
+ - once an exercise exists, selector/create are disabled so the UI cannot imply a second simultaneous exercise;
+ - a 409 create conflict is never blindly retried: the controller performs an authoritative GET and renders the current exercise, with a visible "current state reloaded" status;
+ - public controller state continues to sanitize the protected Case Study to summary fields and never exposes the Instructor capability or Instructor-only simulation content;
+ - deterministic browser acceptance now uses a synthetic staged case owned only by `scripts/serve-browser-tests.mjs`; no official production case content was staged or guessed;
+ - the live Instructor browser journey proves draft create, server-backed reload/resume, disabled duplicate-create control, and no local exercise persistence;
+ - 6A final-head repository quality was green, but the existing four-browser integration acceptance exceeded its 30s global timeout (historical green runtime was already 26.1s). 6B raises only that test budget to 45s; no failing product assertion was suppressed.
+- Exact next action: implement **Tranche 6C — Start/Pause/Resume and current-stage Instructor context**. Add revision-safe Start/Pause/Resume controls to the existing console, render current stage title/objective plus Instructor-only facilitation material after Start, handle 409 mutation conflicts by authoritative refresh, and add focused DOM/browser coverage. Do not add optional evidence release, Begin Debrief, Freeze/Unfreeze, Advance, or Complete until subsequent slices; keep Student staged-case UI and production official-case staging deferred.
## Approved Classroom follow-on direction
From 46747e50bf8ad05072239508475dded8c9909f94 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 17:59:48 -0400
Subject: [PATCH 079/206] Document Tranche 6B staged simulation checkpoint
---
docs/classroom-staged-simulation.md | 37 ++++++++++++++++++++++-------
1 file changed, 28 insertions(+), 9 deletions(-)
diff --git a/docs/classroom-staged-simulation.md b/docs/classroom-staged-simulation.md
index 7a7251f..8608d11 100644
--- a/docs/classroom-staged-simulation.md
+++ b/docs/classroom-staged-simulation.md
@@ -754,16 +754,35 @@ Foundation contract:
Implementation checkpoint spans `2624f1e...` through `d20a303...`.
+## Implementation checkpoint — Tranche 6B staged Case Study selection and draft creation
+
+The console now creates the server-owned draft without adding lifecycle actions.
+
+Draft-setup contract:
+- the selector is populated only from the Instructor-authorized `availableCaseStudies` discovery list;
+- create sends only the selected `caseStudyId` in an authenticated POST body;
+- a successful create hydrates the returned draft and protected Instructor definition in memory, while public controller state exposes only the sanitized Case Study summary;
+- an idempotent `created: false` response is treated as the represented existing draft rather than an error;
+- once an exercise exists, selector/create controls are disabled;
+- a 409 never retries the stale create request; the client performs an authoritative GET and renders the current exercise;
+- draft setup releases no Student content and does not modify Intake/collaboration state;
+- exercise state remains server-owned and is re-fetched on Instructor class resume/reload;
+- deterministic browser coverage uses only a synthetic staged case in the browser fixture.
+
+Implementation spans `6a69426...` through `1035ed7...`, with synthetic fixture support at `c3440eb...`.
+
+Validation note carried from 6A: its repository quality gate passed, but the four-browser integration journey hit the old 30-second Playwright test timeout. Its immediately prior green runtime was 26.1 seconds, so the acceptance-specific budget is now 45 seconds rather than removing assertions or weakening behavior.
+
## Exact next implementation action
-Continue **Tranche 6B — staged Case Study selection and draft creation**.
+Implement **Tranche 6C — Start/Pause/Resume and current-stage Instructor context**.
Required next slice:
-- add an accessible staged-case selector using the server-filtered discovery list;
-- create/reuse the selected draft through the existing Instructor POST contract;
-- render the created draft and its safe summary inside the existing console;
-- preserve one-open-exercise behavior and fail closed on conflicting existing exercise state;
-- keep exercise state server-owned and memory-only;
-- add focused DOM/API/browser coverage for selection, create, replay, and conflict refresh.
-
-Do **not** add Start/Pause/Resume/release/debrief/freeze/advance/complete controls in 6B; those follow after the draft-creation slice. Keep Student staged-case UI and production official Case Study staging deferred.
+- expose Start only for a draft and send the current `exerciseRevision`;
+- expose Pause only for an active exercise and Resume only for a paused exercise;
+- render current stage title, Student objective, suggested timing, and Instructor-only current-stage facilitation content after Start;
+- refresh authoritative exercise state on optimistic 409 conflicts rather than replaying lifecycle intent;
+- clearly distinguish pause state from editing freeze (freeze remains a later debrief slice);
+- add focused controller and real-browser coverage for Start -> Pause -> Resume plus reload/resume.
+
+Do **not** add optional evidence release, Begin Debrief, Freeze/Unfreeze, Advance, or Complete in 6C. Keep Student staged-case UI and production official Case Study staging deferred.
From 9f6d811c45620f128de0002177e2862dd1c5cfae Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 18:00:56 -0400
Subject: [PATCH 080/206] Mark exercise selector option summary-excluded
---
index.html | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/index.html b/index.html
index 0acbcdc..76fafcd 100644
--- a/index.html
+++ b/index.html
@@ -427,7 +427,7 @@
Class
From bd7658a99724501d94db9e28f74d5ef50e649ff9 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 18:01:30 -0400
Subject: [PATCH 081/206] Mark exercise selector label local-only
---
index.html | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/index.html b/index.html
index 76fafcd..d009c2b 100644
--- a/index.html
+++ b/index.html
@@ -424,7 +424,7 @@
Class
Staged Case Studies available to this class will appear here.
+
+
+
+
+ Pause controls class pacing only. Student editing freeze is a separate debrief control.
+
+
+
+ Current stage
+ Stage
+
+
+
+
+
+
+
Instructor facilitation
+
+
+
Available staged Case Studies
From 2fd8c972be43e931c9d0c24dbfd7577c3d175806 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 18:26:07 -0400
Subject: [PATCH 090/206] Add Instructor exercise lifecycle controls
---
src/classroomExerciseInstructor.js | 178 +++++++++++++++++++++++++++++
1 file changed, 178 insertions(+)
diff --git a/src/classroomExerciseInstructor.js b/src/classroomExerciseInstructor.js
index 8cd1024..e57a62d 100644
--- a/src/classroomExerciseInstructor.js
+++ b/src/classroomExerciseInstructor.js
@@ -128,6 +128,86 @@ export function createInstructorExerciseConsoleController({
}
};
+ const currentStageContext = () => {
+ if (!exercise?.currentStageId || !caseStudy?.simulation) return null;
+ const stages = Array.isArray(caseStudy.simulation.stages) ? caseStudy.simulation.stages : [];
+ const stage = stages.find(item => item.id === exercise.currentStageId) || null;
+ if (!stage) return null;
+ const instructorBlocks = new Map(
+ (Array.isArray(caseStudy.simulation.instructorContent) ? caseStudy.simulation.instructorContent : [])
+ .map(item => [item.id, item])
+ );
+ return {
+ stage,
+ instructorContent: (Array.isArray(stage.instructorContentIds) ? stage.instructorContentIds : [])
+ .map(id => instructorBlocks.get(id))
+ .filter(Boolean)
+ };
+ };
+
+ const renderLifecycle = () => {
+ const lifecycle = element('instructorExerciseLifecycle');
+ const start = element('instructorExerciseStartBtn');
+ const pause = element('instructorExercisePauseBtn');
+ const resume = element('instructorExerciseResumeBtn');
+ if (!lifecycle) return;
+
+ lifecycle.hidden = !exercise;
+ const canStart = exercise?.status === 'draft' && !exercise.currentStageId;
+ const canPause = exercise?.status === 'active';
+ const canResume = exercise?.status === 'paused';
+
+ if (start) {
+ start.hidden = !canStart;
+ start.disabled = loading || !canStart;
+ }
+ if (pause) {
+ pause.hidden = !canPause;
+ pause.disabled = loading || !canPause;
+ }
+ if (resume) {
+ resume.hidden = !canResume;
+ resume.disabled = loading || !canResume;
+ }
+ };
+
+ const renderStage = () => {
+ const panel = element('instructorExerciseStagePanel');
+ if (!panel) return;
+ const context = currentStageContext();
+ panel.hidden = !context;
+ if (!context) return;
+
+ const { stage, instructorContent } = context;
+ if (element('instructorExerciseStageTitle')) {
+ element('instructorExerciseStageTitle').textContent = stage.title || 'Current stage';
+ }
+ if (element('instructorExerciseStageObjective')) {
+ element('instructorExerciseStageObjective').textContent = stage.studentObjective || '';
+ }
+ if (element('instructorExerciseStageTiming')) {
+ element('instructorExerciseStageTiming').textContent = Number.isFinite(stage.suggestedMinutes)
+ ? `Suggested time: ${stage.suggestedMinutes} min`
+ : '';
+ }
+
+ const facilitation = element('instructorExerciseFacilitation');
+ const list = element('instructorExerciseFacilitationList');
+ if (facilitation) facilitation.hidden = instructorContent.length === 0;
+ if (list) {
+ list.replaceChildren();
+ instructorContent.forEach(item => {
+ const block = documentRef.createElement('article');
+ const title = documentRef.createElement('strong');
+ title.textContent = item.title || 'Instructor note';
+ const body = documentRef.createElement('p');
+ body.textContent = item.body || '';
+ block.append(title, body);
+ list.append(block);
+ });
+ }
+ };
+
const render = () => {
const panel = element('instructorExerciseConsole');
const status = element('instructorExerciseStatus');
@@ -170,6 +250,8 @@ export function createInstructorExerciseConsoleController({
: 'The console is connected; no staged Case Study definition is currently available.';
}
renderSetup();
+ renderLifecycle();
+ renderStage();
renderAvailable();
};
@@ -308,6 +390,90 @@ export function createInstructorExerciseConsoleController({
}
};
+ const mutateLifecycle = async action => {
+ if (
+ destroyed
+ || !capability
+ || !exercise
+ || loading
+ || typeof fetchImpl !== 'function'
+ ) return false;
+
+ const expectedRevision = Number(exercise.exerciseRevision);
+ if (!Number.isInteger(expectedRevision) || expectedRevision < 1) {
+ lastError = 'Exercise revision is unavailable. Refresh and retry.';
+ lastNotice = '';
+ render();
+ return false;
+ }
+
+ const allowed = (
+ (action === 'start' && exercise.status === 'draft' && !exercise.currentStageId)
+ || (action === 'pause' && exercise.status === 'active')
+ || (action === 'resume' && exercise.status === 'paused')
+ );
+ if (!allowed) return false;
+
+ const localCapability = capability;
+ const localEpoch = epoch;
+ loading = true;
+ lastError = '';
+ lastNotice = '';
+ render();
+
+ try {
+ const response = await fetchImpl(INSTRUCTOR_EXERCISE_ENDPOINT, {
+ method: 'PATCH',
+ headers: {
+ Authorization: `Bearer ${localCapability}`,
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ action, expectedRevision })
+ });
+ const body = await response.json().catch(() => ({}));
+ if (destroyed || localEpoch !== epoch || capability !== localCapability) return false;
+
+ if (response.status === 409) {
+ loading = false;
+ const reloaded = await refresh();
+ if (reloaded && !destroyed && localEpoch === epoch) {
+ lastNotice = 'current state reloaded';
+ render();
+ } else if (!lastError) {
+ lastError = typeof body.error === 'string' ? body.error : 'Exercise changed. Refresh and retry.';
+ render();
+ }
+ return false;
+ }
+
+ if (!response.ok) {
+ lastError = response.status === 401 || response.status === 404
+ ? 'Exercise access is no longer available.'
+ : typeof body.error === 'string' && body.error
+ ? body.error
+ : 'Could not update the exercise.';
+ return false;
+ }
+
+ applyPayload(body);
+ lastNotice = action === 'start'
+ ? 'exercise started'
+ : action === 'pause'
+ ? 'exercise paused'
+ : 'exercise resumed';
+ return true;
+ } catch {
+ if (destroyed || localEpoch !== epoch) return false;
+ lastError = 'Could not update the exercise.';
+ return false;
+ } finally {
+ if (!destroyed && localEpoch === epoch) {
+ loading = false;
+ render();
+ }
+ }
+ };
+
const connectInstructor = async token => {
if (!validCapability(token) || destroyed) return false;
epoch += 1;
@@ -335,10 +501,16 @@ export function createInstructorExerciseConsoleController({
event.preventDefault();
void createDraft();
};
+ const handleStart = () => { void mutateLifecycle('start'); };
+ const handlePause = () => { void mutateLifecycle('pause'); };
+ const handleResume = () => { void mutateLifecycle('resume'); };
element('instructorExerciseRefreshBtn')?.addEventListener('click', handleRefresh);
element('instructorExerciseCaseSelect')?.addEventListener('change', handleSelect);
element('instructorExerciseSetupForm')?.addEventListener('submit', handleSetupSubmit);
+ element('instructorExerciseStartBtn')?.addEventListener('click', handleStart);
+ element('instructorExercisePauseBtn')?.addEventListener('click', handlePause);
+ element('instructorExerciseResumeBtn')?.addEventListener('click', handleResume);
render();
return {
@@ -346,6 +518,9 @@ export function createInstructorExerciseConsoleController({
disconnect,
refresh,
createDraft,
+ start: () => mutateLifecycle('start'),
+ pause: () => mutateLifecycle('pause'),
+ resume: () => mutateLifecycle('resume'),
getState: () => ({
connected: Boolean(capability),
loading,
@@ -362,6 +537,9 @@ export function createInstructorExerciseConsoleController({
element('instructorExerciseRefreshBtn')?.removeEventListener('click', handleRefresh);
element('instructorExerciseCaseSelect')?.removeEventListener('change', handleSelect);
element('instructorExerciseSetupForm')?.removeEventListener('submit', handleSetupSubmit);
+ element('instructorExerciseStartBtn')?.removeEventListener('click', handleStart);
+ element('instructorExercisePauseBtn')?.removeEventListener('click', handlePause);
+ element('instructorExerciseResumeBtn')?.removeEventListener('click', handleResume);
disconnect();
destroyed = true;
}
From d0f35b50c5f9f043846a2fa43ff3e93e4a94edc8 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 18:26:16 -0400
Subject: [PATCH 091/206] Style Instructor exercise lifecycle context
---
styles.css | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/styles.css b/styles.css
index f4eac8e..be97d64 100644
--- a/styles.css
+++ b/styles.css
@@ -3240,6 +3240,18 @@ body[data-experience-role="instructor"].instructor-class-connected .wrap{max-wid
.instructor-exercise-console__setup-row select{min-width:0;font-size:10px;}
.instructor-exercise-console__setup-row .btn-secondary{padding:7px 9px;font-size:10px;white-space:nowrap;}
.instructor-exercise-console__setup small{display:block;margin-top:5px;color:var(--muted);font-size:9px;line-height:1.4;}
+.instructor-exercise-console__lifecycle{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:10px;}
+.instructor-exercise-console__lifecycle small{flex-basis:100%;color:var(--muted);font-size:9px;line-height:1.4;}
+.instructor-exercise-console__stage{margin-top:10px;padding:10px;border:1px solid var(--line);border-radius:11px;background:var(--panel-muted);}
+.instructor-exercise-console__stage-heading{display:flex;flex-direction:column;gap:2px;}
+.instructor-exercise-console__stage-heading span,.instructor-exercise-console__facilitation>span{color:var(--muted);font-size:9px;font-weight:800;letter-spacing:.05em;text-transform:uppercase;}
+.instructor-exercise-console__stage-heading strong{color:var(--ink-strong);font-size:12px;}
+.instructor-exercise-console__stage>p{margin:6px 0 0;color:var(--ink);font-size:10px;line-height:1.45;}
+.instructor-exercise-console__stage-meta{margin-top:6px;color:var(--muted);font-size:9px;}
+.instructor-exercise-console__facilitation{margin-top:9px;padding-top:9px;border-top:1px solid var(--line);}
+.instructor-exercise-console__facilitation article{margin-top:6px;}
+.instructor-exercise-console__facilitation article strong{display:block;color:var(--ink-strong);font-size:10px;}
+.instructor-exercise-console__facilitation article p{margin:3px 0 0;color:var(--ink);font-size:9px;line-height:1.4;}
.instructor-exercise-console__available{margin-top:9px;}
.instructor-exercise-console__available>span{display:block;color:var(--muted);font-size:9px;font-weight:800;letter-spacing:.05em;text-transform:uppercase;}
.instructor-exercise-console__available ul{display:flex;flex-direction:column;gap:5px;max-height:92px;overflow:auto;margin:6px 0 0;padding:0;list-style:none;}
From 4b28845daeeb57ebe3eb4d648c788163e1bc5aa1 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 18:26:27 -0400
Subject: [PATCH 092/206] Support exercise lifecycle in browser fixture
---
scripts/serve-browser-tests.mjs | 65 +++++++++++++++++++++++++++++++++
1 file changed, 65 insertions(+)
diff --git a/scripts/serve-browser-tests.mjs b/scripts/serve-browser-tests.mjs
index 7dbfb5e..a225390 100644
--- a/scripts/serve-browser-tests.mjs
+++ b/scripts/serve-browser-tests.mjs
@@ -773,6 +773,71 @@ async function handleClassroomApi(request, response, url) {
return true;
}
+ if (request.method === 'PATCH') {
+ const body = await readJson(request).catch(() => null);
+ const exercise = classroomExercises.get(instructorToken) || null;
+ if (!exercise) {
+ sendJson(response, 404, { error: 'Exercise not found.' });
+ return true;
+ }
+ if (!Number.isInteger(body?.expectedRevision) || body.expectedRevision !== exercise.exerciseRevision) {
+ sendJson(response, 409, {
+ error: 'Exercise changed. Refresh and retry.',
+ exercise: structuredClone(exercise)
+ });
+ return true;
+ }
+
+ if (body.action === 'start') {
+ if (exercise.status !== 'draft' || exercise.currentStageId !== null) {
+ sendJson(response, 409, {
+ error: 'Exercise cannot be started from its current state.',
+ exercise: structuredClone(exercise)
+ });
+ return true;
+ }
+ exercise.status = 'active';
+ exercise.currentStageId = BROWSER_STAGED_CASE.simulation.stages[0].id;
+ exercise.stagePhase = 'work';
+ exercise.studentEditingEnabled = true;
+ } else if (body.action === 'pause') {
+ if (exercise.status !== 'active') {
+ sendJson(response, 409, {
+ error: 'Exercise is not active.',
+ exercise: structuredClone(exercise)
+ });
+ return true;
+ }
+ exercise.status = 'paused';
+ } else if (body.action === 'resume') {
+ if (exercise.status !== 'paused') {
+ sendJson(response, 409, {
+ error: 'Exercise is not paused.',
+ exercise: structuredClone(exercise)
+ });
+ return true;
+ }
+ exercise.status = 'active';
+ } else {
+ sendJson(response, 400, { error: 'Invalid exercise action.' });
+ return true;
+ }
+
+ exercise.exerciseRevision += 1;
+ classroomExercises.set(instructorToken, exercise);
+ sendJson(response, 200, {
+ class: classContext,
+ exercise: structuredClone(exercise),
+ caseStudy: structuredClone(BROWSER_STAGED_CASE),
+ releases: [],
+ workspaceState: [],
+ checkpoints: [],
+ editFreezeEnforced: true,
+ changed: true
+ });
+ return true;
+ }
+
sendJson(response, 405, { error: 'Method not allowed.' });
return true;
}
From e350d5940656e2b82f0fdba98d0e91f94e5a7a46 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 18:26:44 -0400
Subject: [PATCH 093/206] Cover Instructor exercise start pause resume
---
tests/e2e/instructor.spec.mjs | 24 +++++++++++++++++++++++-
1 file changed, 23 insertions(+), 1 deletion(-)
diff --git a/tests/e2e/instructor.spec.mjs b/tests/e2e/instructor.spec.mjs
index 530f91f..0a80811 100644
--- a/tests/e2e/instructor.spec.mjs
+++ b/tests/e2e/instructor.spec.mjs
@@ -75,6 +75,25 @@ test('Instructor starts a live class, creates a team, assigns a waiting Student,
await expect(page.locator('#instructorExerciseStatus')).toHaveText('Draft · draft created');
await expect(page.locator('#instructorExerciseCaseName')).toHaveText('Browser Staged Simulation');
await expect(page.locator('#instructorExerciseCreateBtn')).toBeDisabled();
+ await expect(page.locator('#instructorExerciseStartBtn')).toBeVisible();
+
+ await page.getByRole('button', { name: 'Start exercise' }).click();
+ await expect(page.locator('#instructorExerciseStatus')).toHaveText('In progress · exercise started');
+ await expect(page.locator('#instructorExerciseStageTitle')).toHaveText('Clarify the browser case');
+ await expect(page.locator('#instructorExerciseStageObjective')).toHaveText('Capture the initial situation in Intake.');
+ await expect(page.locator('#instructorExerciseStageTiming')).toHaveText('Suggested time: 5 min');
+ await expect(page.locator('#instructorExerciseFacilitation')).toContainText('Browser facilitation note');
+ await expect(page.locator('#instructorExerciseFacilitation')).toContainText('Synthetic Instructor-only browser facilitation.');
+ await expect(page.locator('#instructorExercisePauseBtn')).toBeVisible();
+
+ await page.getByRole('button', { name: 'Pause exercise' }).click();
+ await expect(page.locator('#instructorExerciseStatus')).toHaveText('Paused · exercise paused');
+ await expect(page.locator('#instructorExerciseResumeBtn')).toBeVisible();
+ await expect(page.locator('#instructorExerciseLifecycleHelp')).toContainText('Pause controls class pacing only');
+
+ await page.getByRole('button', { name: 'Resume exercise' }).click();
+ await expect(page.locator('#instructorExerciseStatus')).toHaveText('In progress · exercise resumed');
+ await expect(page.locator('#instructorExercisePauseBtn')).toBeVisible();
const storedAfterStart = await page.evaluate(key => window.localStorage.getItem(key), INSTRUCTOR_SESSION_STORAGE_KEY);
expect(storedAfterStart).toContain('K7FM-P4Q2');
@@ -99,6 +118,7 @@ test('Instructor starts a live class, creates a team, assigns a waiting Student,
expect(classroomRequests.some(request => request.method === 'POST' && request.pathname === '/api/classes')).toBe(true);
expect(classroomRequests.some(request => request.method === 'POST' && request.pathname === '/api/classes/workspaces')).toBe(true);
expect(classroomRequests.some(request => request.method === 'POST' && request.pathname === '/api/classes/exercise')).toBe(true);
+ expect(classroomRequests.filter(request => request.method === 'PATCH' && request.pathname === '/api/classes/exercise')).toHaveLength(3);
expect(classroomRequests.some(request => request.method === 'PATCH' && request.pathname === '/api/classes/participants')).toBe(true);
expect(classroomRequests.some(request => request.pathname === '/api/classes/join')).toBe(false);
expect(classroomRequests.some(request => request.body.includes('assignmentToken'))).toBe(false);
@@ -112,10 +132,12 @@ test('Instructor starts a live class, creates a team, assigns a waiting Student,
await expect(page.locator('#instructorJoinCode')).toHaveText('K7FM-P4Q2');
await expect(page.locator('#instructorParticipantSummary')).toHaveText('1 student · 0 waiting');
await expect(page.getByLabel('Assignment for Waiting Student')).not.toHaveValue('');
- await expect(page.locator('#instructorExerciseStatus')).toHaveText('Draft');
+ await expect(page.locator('#instructorExerciseStatus')).toHaveText('In progress');
await expect(page.locator('#instructorExerciseCaseName')).toHaveText('Browser Staged Simulation');
await expect(page.getByLabel('Staged Case Study')).toHaveValue('browser-staged-simulation');
await expect(page.locator('#instructorExerciseCreateBtn')).toBeDisabled();
+ await expect(page.locator('#instructorExerciseStageTitle')).toHaveText('Clarify the browser case');
+ await expect(page.locator('#instructorExercisePauseBtn')).toBeVisible();
await expectNoBlockingA11yViolations(page);
expect(pageErrors).toEqual([]);
From ba1aa4527b9b7534174ffaa84b4afab9b6ef7dc9 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 18:27:05 -0400
Subject: [PATCH 094/206] Test Instructor exercise lifecycle controls
---
...assroomExerciseInstructor.feature.test.mjs | 162 ++++++++++++++++++
1 file changed, 162 insertions(+)
diff --git a/tests/classroomExerciseInstructor.feature.test.mjs b/tests/classroomExerciseInstructor.feature.test.mjs
index 4a0ae1f..5435eb6 100644
--- a/tests/classroomExerciseInstructor.feature.test.mjs
+++ b/tests/classroomExerciseInstructor.feature.test.mjs
@@ -196,6 +196,168 @@ test('Instructor draft conflict reloads authoritative current exercise instead o
controller.destroy();
});
+test('Instructor lifecycle sends optimistic revisions and renders current-stage facilitation context', async () => {
+ dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' });
+ const requests = [];
+ let exerciseState = {
+ id: 'exercise-1',
+ caseStudyId: 'staged-a',
+ status: 'draft',
+ stagePhase: 'work',
+ exerciseRevision: 1,
+ currentStageId: null
+ };
+ const caseStudy = {
+ id: 'staged-a',
+ name: 'Staged Case A',
+ description: 'Instructor simulation.',
+ supportedModes: ['full'],
+ simulation: {
+ stages: [{
+ id: 'stage-1',
+ title: 'Clarify the situation',
+ studentObjective: 'Frame the initial problem.',
+ suggestedMinutes: 7,
+ instructorContentIds: ['teach-1']
+ }],
+ instructorContent: [{
+ id: 'teach-1',
+ kind: 'facilitation',
+ title: 'Facilitation cue',
+ body: 'Keep teams focused on the deviation.'
+ }]
+ }
+ };
+
+ const controller = createInstructorExerciseConsoleController({
+ documentRef: dom.window.document,
+ fetchImpl: async (_url, options) => {
+ requests.push({
+ method: options.method,
+ body: options.body ? JSON.parse(options.body) : null
+ });
+ if (options.method === 'GET') {
+ return response(200, {
+ class: { id: 'class-1', title: 'PSDM Class' },
+ exercise: { ...exerciseState },
+ caseStudy,
+ availableCaseStudies: discoveryBody().availableCaseStudies
+ });
+ }
+ if (options.method === 'PATCH') {
+ const body = JSON.parse(options.body);
+ assert.equal(body.expectedRevision, exerciseState.exerciseRevision);
+ if (body.action === 'start') {
+ exerciseState = { ...exerciseState, status: 'active', currentStageId: 'stage-1', exerciseRevision: 2 };
+ } else if (body.action === 'pause') {
+ exerciseState = { ...exerciseState, status: 'paused', exerciseRevision: 3 };
+ } else if (body.action === 'resume') {
+ exerciseState = { ...exerciseState, status: 'active', exerciseRevision: 4 };
+ }
+ return response(200, {
+ class: { id: 'class-1', title: 'PSDM Class' },
+ exercise: { ...exerciseState },
+ caseStudy,
+ releases: [],
+ workspaceState: [],
+ checkpoints: [],
+ editFreezeEnforced: true,
+ changed: true
+ });
+ }
+ return response(405, {});
+ }
+ });
+
+ assert.equal(await controller.connectInstructor(TOKEN), true);
+ assert.equal(dom.window.document.getElementById('instructorExerciseStartBtn').hidden, false);
+
+ assert.equal(await controller.start(), true);
+ assert.deepEqual(requests.at(-1), {
+ method: 'PATCH',
+ body: { action: 'start', expectedRevision: 1 }
+ });
+ assert.equal(controller.getState().exercise.exerciseRevision, 2);
+ assert.equal(dom.window.document.getElementById('instructorExerciseStatus').textContent, 'In progress · exercise started');
+ assert.equal(dom.window.document.getElementById('instructorExerciseStageTitle').textContent, 'Clarify the situation');
+ assert.equal(dom.window.document.getElementById('instructorExerciseStageObjective').textContent, 'Frame the initial problem.');
+ assert.equal(dom.window.document.getElementById('instructorExerciseStageTiming').textContent, 'Suggested time: 7 min');
+ assert.equal(dom.window.document.getElementById('instructorExerciseFacilitationList').textContent.includes('Facilitation cue'), true);
+ assert.equal(dom.window.document.getElementById('instructorExerciseFacilitationList').textContent.includes('Keep teams focused on the deviation.'), true);
+ assert.equal(dom.window.document.getElementById('instructorExercisePauseBtn').hidden, false);
+
+ assert.equal(await controller.pause(), true);
+ assert.deepEqual(requests.at(-1), {
+ method: 'PATCH',
+ body: { action: 'pause', expectedRevision: 2 }
+ });
+ assert.equal(controller.getState().exercise.status, 'paused');
+ assert.equal(dom.window.document.getElementById('instructorExerciseResumeBtn').hidden, false);
+
+ assert.equal(await controller.resume(), true);
+ assert.deepEqual(requests.at(-1), {
+ method: 'PATCH',
+ body: { action: 'resume', expectedRevision: 3 }
+ });
+ assert.equal(controller.getState().exercise.status, 'active');
+ assert.equal(controller.getState().exercise.exerciseRevision, 4);
+ controller.destroy();
+});
+
+test('Instructor lifecycle conflict refreshes authoritative state without replaying stale intent', async () => {
+ dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' });
+ const requests = [];
+ let getCount = 0;
+ const draft = draftBody({ created: false });
+
+ const controller = createInstructorExerciseConsoleController({
+ documentRef: dom.window.document,
+ fetchImpl: async (_url, options) => {
+ requests.push(options.method);
+ if (options.method === 'GET') {
+ getCount += 1;
+ if (getCount === 1) {
+ return response(200, {
+ ...draft,
+ availableCaseStudies: discoveryBody().availableCaseStudies
+ });
+ }
+ return response(200, {
+ ...draft,
+ exercise: {
+ ...draft.exercise,
+ status: 'active',
+ currentStageId: 'stage-1',
+ exerciseRevision: 2
+ },
+ availableCaseStudies: discoveryBody().availableCaseStudies
+ });
+ }
+ if (options.method === 'PATCH') {
+ return response(409, {
+ error: 'Exercise changed. Refresh and retry.',
+ exercise: {
+ ...draft.exercise,
+ status: 'active',
+ currentStageId: 'stage-1',
+ exerciseRevision: 2
+ }
+ });
+ }
+ return response(405, {});
+ }
+ });
+
+ await controller.connectInstructor(TOKEN);
+ assert.equal(await controller.start(), false);
+ assert.deepEqual(requests, ['GET', 'PATCH', 'GET']);
+ assert.equal(controller.getState().exercise.status, 'active');
+ assert.equal(controller.getState().exercise.exerciseRevision, 2);
+ assert.equal(dom.window.document.getElementById('instructorExerciseStatus').textContent, 'In progress · current state reloaded');
+ assert.equal(dom.window.document.getElementById('instructorExercisePauseBtn').hidden, false);
+ controller.destroy();
+});
+
test('Instructor exercise console keeps a connected shell on transient read failure and can refresh', async () => {
dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' });
let attempt = 0;
From 2888bebfd5d5c0da2bb83ec8aab501e2dd949bb8 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 18:27:36 -0400
Subject: [PATCH 095/206] Document Instructor lifecycle console scope
---
src/classroomExerciseInstructor.js | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/classroomExerciseInstructor.js b/src/classroomExerciseInstructor.js
index e57a62d..7d4833c 100644
--- a/src/classroomExerciseInstructor.js
+++ b/src/classroomExerciseInstructor.js
@@ -1,11 +1,11 @@
/**
* @module classroomExerciseInstructor
- * @summary Owns Instructor staged-exercise discovery, draft setup, and console state.
+ * @summary Owns Instructor staged-exercise discovery, draft setup, lifecycle, and console state.
* @description
* Receives the active Instructor capability from the class lifecycle, keeps it
* in memory only, and reads/writes the Instructor-authorized exercise endpoint.
- * Tranche 6B adds staged Case Study selection plus draft create/reuse only;
- * lifecycle actions remain deferred to later Tranche 6 slices.
+ * Tranche 6C adds revision-safe Start/Pause/Resume plus current-stage
+ * Instructor facilitation context; later debrief/release actions remain deferred.
*/
export const INSTRUCTOR_EXERCISE_ENDPOINT = '/api/classes/exercise';
From a2e6380e2407cab50f1d9e7077c84ce22963aecd Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 18:28:13 -0400
Subject: [PATCH 096/206] Document Instructor exercise lifecycle anchor
---
docs/commenting-guide.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/commenting-guide.md b/docs/commenting-guide.md
index 53740f8..98730b7 100644
--- a/docs/commenting-guide.md
+++ b/docs/commenting-guide.md
@@ -49,7 +49,7 @@ Anchors keep HTML and documentation in sync. Preserve existing tokens and regist
| `[feature:student-class-entry]` | `index.html`, between role chooser and Instructor shell | Student class join/reconnect gate shown before the assigned Intake is available. | `src/classroomStudent.js`, `src/experienceRoleController.js` | `studentClassEntryShell`, `studentClassJoinForm`, class/assignment/name inputs, retry controls; all local-only/summary-excluded. |
| `[feature:student-experience-notice]` | `index.html`, top of the connected Student Intake | Class/workspace/participant context plus explicit Leave class. | `src/classroomStudent.js`, `src/experienceRoleController.js` | `studentExperienceNotice`, `studentClassContextTitle`, `studentClassWorkspace`, `studentClassIdentity`, `studentClassLeaveBtn`; no persisted Intake fields. |
| `[feature:instructor-shell]` | `index.html`, between header and Intake wrap | Instructor class entry/resume and connected class workspace rail. | `src/classroomInstructor.js`, `src/experienceRoleController.js` | `instructorExperienceShell`, `instructorClassForm`, `instructorClassDashboard`, roster search/filter/list, Leave class; all class/session controls are local-only and summary-excluded. |
-| `[feature:instructor-exercise-console]` | `index.html`, inside the connected Instructor dashboard | Instructor staged-exercise discovery and draft setup within the existing class rail. | `src/classroomExerciseInstructor.js` | `instructorExerciseConsole`, staged Case Study selector, Create draft, current case/status, staged availability list, Refresh; all exercise context is memory-only and summary-excluded. |
+| `[feature:instructor-exercise-console]` | `index.html`, inside the connected Instructor dashboard | Instructor staged-exercise discovery, draft setup, lifecycle pacing, and current-stage facilitation within the existing class rail. | `src/classroomExerciseInstructor.js` | `instructorExerciseConsole`, staged selector/Create draft, Start/Pause/Resume, current stage/objective/timing/facilitation, availability list, Refresh; all exercise context is memory-only and summary-excluded. |
| `[feature:instructor-observer]` | `index.html`, top of the temporarily revealed Intake wrap | Live read-only context for the selected Student/team workspace, including revision and recent activity. Dynamic coaching panels are appended beside registered targets inside this same observed Intake; they do not introduce a new structural HTML anchor. | `src/classroomInstructor.js`, `src/classroomCoaching.js`, `src/coachableFields.js` | `instructorObservationNotice`, observer metadata, plus `.classroom-coaching--instructor` / `.classroom-coaching--student` dynamic local-only panels. Observed Student state and coaching UI are excluded from Intake persistence/summary capture. |
| `[feature:intake-mode-selector]` | `index.html` header title | Wraps the accessible intake mode selector used to choose between General, IT, Pharma, and Major Incident Management lenses before capture. | `src/intakeModes.js`, `src/intakeModeController.js` | `intakeModeSelect`; options use stable mode values and persist under `meta.intakeMode` in `kt-intake-full-v2`. |
| `[feature:major-incident-role-legend]` | `index.html`, below the header | Provides the Major Incident-only four-phase legend mount; phase/primary-role badges decorate mapped workflow headings (including Decision and Risk gates) and every KT row, then hide without unmounting data when the mode changes. Icons and colour swatches are decorative because visible and accessible role text carries their meaning. | `src/majorIncidentRoles.js`, `src/intakeModes.js` | `majorIncidentRoleLegend`, `majorIncidentRoleLegendItems`; role presentation is not persisted, while the decorated controls retain their normal field contracts. |
From 69051a619fc5ef91caede44774f7ffd18800cf27 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 18:28:16 -0400
Subject: [PATCH 097/206] Document Instructor lifecycle authorization rules
---
src/AGENTS.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/AGENTS.md b/src/AGENTS.md
index 537b05c..af81a94 100644
--- a/src/AGENTS.md
+++ b/src/AGENTS.md
@@ -17,7 +17,7 @@ Keep feature modules independently initialisable, own only their feature-specifi
## Protected Classroom resources
-`classroomExerciseInstructor.js` owns the Instructor staged-exercise console client. It receives the Instructor capability only through the class lifecycle, keeps that credential and exercise payload in memory, and reads `/api/classes/exercise`; draft selection/create may POST only an explicitly discovered staged `caseStudyId`. Exercise state, staged definitions, and capabilities must never enter Intake persistence, summaries, exports, URLs, or public manifests. Draft conflicts must reload authoritative server state rather than retry stale intent; later Tranche 6 lifecycle mutations must remain optimistic-revision protected.
+`classroomExerciseInstructor.js` owns the Instructor staged-exercise console client. It receives the Instructor capability only through the class lifecycle, keeps that credential and exercise payload in memory, and reads/writes `/api/classes/exercise`; draft selection/create may POST only an explicitly discovered staged `caseStudyId`. Start/Pause/Resume PATCH actions must send the currently observed `exerciseRevision` and a 409 must reload authoritative server state rather than replay stale intent. Current-stage Instructor facilitation may render only in Instructor experience. Exercise state, staged definitions, and capabilities must never enter Intake persistence, summaries, exports, URLs, or public manifests.
`classroomCaseStudies.js` owns the authorized in-memory protected Case Study catalog/payload client. It receives active capabilities only through Student/Instructor lifecycle callbacks, keeps no localStorage/sessionStorage record, and clears catalog/context when the class role disconnects. Its public state must never expose a bearer capability.
From 4a5c90e336e60b5f6e1f26917ba04d057a70dfe1 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 18:28:18 -0400
Subject: [PATCH 098/206] Document Tranche 6C checkpoint
---
docs/classroom-workstream.md | 11 ++++++++++-
1 file changed, 10 insertions(+), 1 deletion(-)
diff --git a/docs/classroom-workstream.md b/docs/classroom-workstream.md
index 41d19a9..c165d09 100644
--- a/docs/classroom-workstream.md
+++ b/docs/classroom-workstream.md
@@ -466,7 +466,16 @@ Continue #295 on draft PR #307 from the current branch head.
- the live Instructor browser journey proves draft create, server-backed reload/resume, disabled duplicate-create control, and no local exercise persistence;
- summary/persistence guards explicitly mark the staged selector, label, placeholder, and dynamic options as local-only / summary-excluded rather than incorrectly wiring Classroom exercise state into Intake serialization;
- 6A final-head repository quality was green, but the existing four-browser integration acceptance exceeded its 30s global timeout (historical green runtime was already 26.1s). 6B raises only that test budget to 45s; no failing product assertion was suppressed.
-- Exact next action: implement **Tranche 6C — Start/Pause/Resume and current-stage Instructor context**. Add revision-safe Start/Pause/Resume controls to the existing console, render current stage title/objective plus Instructor-only facilitation material after Start, handle 409 mutation conflicts by authoritative refresh, and add focused DOM/browser coverage. Do not add optional evidence release, Begin Debrief, Freeze/Unfreeze, Advance, or Complete until subsequent slices; keep Student staged-case UI and production official-case staging deferred.
+- Tranche 6C — Start/Pause/Resume and current-stage Instructor context:
+ - implementation spans `53ce2b7...` through `2888beb...`;
+ - draft-only **Start exercise**, active-only **Pause exercise**, and paused-only **Resume exercise** controls use the current server `exerciseRevision`;
+ - lifecycle PATCH conflict behavior is fail-refresh: a 409 triggers authoritative GET and never replays stale Instructor intent;
+ - after Start, the console renders current stage title, Student objective, suggested timing, and only the current stage's Instructor-authorized facilitation blocks;
+ - Pause explicitly describes class pacing as distinct from Student editing freeze, preserving the later debrief-policy boundary;
+ - deterministic browser fixture now implements matching Start/Pause/Resume revision semantics against the synthetic staged case;
+ - focused controller coverage proves revision progression, stage/facilitation rendering, state-specific controls, and 409 refresh; real-browser Instructor acceptance proves Start -> Pause -> Resume -> reload;
+ - the unrelated four-browser acceptance timeout is raised narrowly from 30s to 45s after the previous run completed product assertions but timed out during teardown; no behavioral assertion was removed.
+- Exact next action: implement **Tranche 6D — optional evidence release and team readiness/progress signals**. Add current-work-stage optional release controls using revision-safe `release-content`, show already-released state, render per-workspace Ready/working signals from `workspaceState`, and connect team rows to existing observer navigation where practical. Do not add Begin Debrief, Freeze/Unfreeze, Advance, or Complete yet; keep Student staged-case UI and production official-case staging deferred.
## Approved Classroom follow-on direction
From 1f0c9f0f64bd3695827e700366c2ceb0f30bec12 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 18:28:21 -0400
Subject: [PATCH 099/206] Document Tranche 6C simulation checkpoint
---
docs/classroom-staged-simulation.md | 33 +++++++++++++++++++++--------
1 file changed, 24 insertions(+), 9 deletions(-)
diff --git a/docs/classroom-staged-simulation.md b/docs/classroom-staged-simulation.md
index 1cffb6f..48da8b6 100644
--- a/docs/classroom-staged-simulation.md
+++ b/docs/classroom-staged-simulation.md
@@ -774,16 +774,31 @@ Implementation spans `6a69426...` through `5f3fb56...`, with synthetic fixture s
Validation note carried from 6A: its repository quality gate passed, but the four-browser integration journey hit the old 30-second Playwright test timeout. Its immediately prior green runtime was 26.1 seconds, so the acceptance-specific budget is now 45 seconds rather than removing assertions or weakening behavior.
+## Implementation checkpoint — Tranche 6C Start/Pause/Resume and current-stage Instructor context
+
+Lifecycle/pacing contract:
+- Start is exposed only for a draft with no current stage and PATCHes `{ action: "start", expectedRevision }`;
+- Pause is exposed only for an active exercise and Resume only for a paused exercise;
+- every lifecycle mutation uses the currently observed positive `exerciseRevision`;
+- a 409 mutation conflict performs one authoritative GET and renders that state; stale lifecycle intent is never automatically replayed;
+- after Start, the console renders only the current stage's title, Student objective, suggested minutes, and Instructor content referenced by that stage's `instructorContentIds`;
+- current-stage facilitation remains Instructor-only and memory-only;
+- Pause is described as class pacing, explicitly distinct from the Student editing freeze policy that belongs to debrief;
+- exercise state remains server-owned and is restored by GET on same-device Instructor resume/reload;
+- deterministic browser acceptance uses the synthetic staged case only and proves Start -> Pause -> Resume -> reload.
+
+Implementation spans `53ce2b7...` through `2888beb...`.
+
## Exact next implementation action
-Implement **Tranche 6C — Start/Pause/Resume and current-stage Instructor context**.
+Implement **Tranche 6D — optional evidence release and team readiness/progress signals**.
Required next slice:
-- expose Start only for a draft and send the current `exerciseRevision`;
-- expose Pause only for an active exercise and Resume only for a paused exercise;
-- render current stage title, Student objective, suggested timing, and Instructor-only current-stage facilitation content after Start;
-- refresh authoritative exercise state on optimistic 409 conflicts rather than replaying lifecycle intent;
-- clearly distinguish pause state from editing freeze (freeze remains a later debrief slice);
-- add focused controller and real-browser coverage for Start -> Pause -> Resume plus reload/resume.
-
-Do **not** add optional evidence release, Begin Debrief, Freeze/Unfreeze, Advance, or Complete in 6C. Keep Student staged-case UI and production official Case Study staging deferred.
+- render current-stage optional Student content to the Instructor as unreleased/released controls without exposing future-stage content;
+- PATCH `release-content` with the current exercise revision and selected current-stage content ID;
+- preserve release idempotency and 409 authoritative-refresh behavior;
+- show current-stage per-workspace readiness/working state from the existing Instructor exercise payload;
+- make progress rows useful for existing observer/coaching navigation without creating a second observer;
+- extend the synthetic staged browser case with deterministic optional evidence/readiness only as needed for acceptance.
+
+Do **not** add Begin Debrief, Freeze/Unfreeze, Advance, or Complete in 6D. Keep Student staged-case UI and production official Case Study staging deferred.
From 1de00a1d9076a6728009ca53ccf2b9ae542ea8bb Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 18:33:42 -0400
Subject: [PATCH 100/206] Avoid nested Instructor roster scrolling
---
styles.css | 1 +
1 file changed, 1 insertion(+)
diff --git a/styles.css b/styles.css
index be97d64..7e8917d 100644
--- a/styles.css
+++ b/styles.css
@@ -3206,6 +3206,7 @@ body.dialog-open{overflow:hidden;}
body[data-experience-role="instructor"].instructor-class-connected .instructor-shell{position:fixed;left:16px;top:132px;bottom:16px;width:330px;max-width:none;margin:0;padding:0;z-index:35;}
body[data-experience-role="instructor"].instructor-class-connected .instructor-shell .experience-shell__card{height:100%;padding:18px;border-radius:18px;overflow:hidden;}
body[data-experience-role="instructor"].instructor-class-connected .instructor-shell .instructor-dashboard{overflow-x:hidden;overflow-y:auto;overscroll-behavior:contain;}
+body[data-experience-role="instructor"].instructor-class-connected .instructor-workspace-list{flex-shrink:0;overflow:visible;padding-bottom:4px;}
body[data-experience-role="instructor"].instructor-class-connected .wrap{max-width:none;margin-left:370px;margin-right:24px;}
@media(max-width:980px){
body[data-experience-role="instructor"].instructor-class-connected .instructor-shell{position:static;width:auto;max-width:1100px;margin:20px auto;padding:0 12px;}
From 58c6964cc619b2489c20cd61a756d8c0f4ccc617 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 18:35:19 -0400
Subject: [PATCH 101/206] Add exercise release and progress panels
---
index.html | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/index.html b/index.html
index 107e944..1098ae0 100644
--- a/index.html
+++ b/index.html
@@ -452,6 +452,17 @@
Class
Instructor facilitation
+
+
Optional Student evidence
+
+
+
+
Available staged Case Studies
From e28d4aac982443aeb9582a8b5a0a6777966ee7f5 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 18:35:30 -0400
Subject: [PATCH 102/206] Style exercise release and progress panels
---
styles.css | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
diff --git a/styles.css b/styles.css
index 7e8917d..714b9cf 100644
--- a/styles.css
+++ b/styles.css
@@ -3253,6 +3253,24 @@ body[data-experience-role="instructor"].instructor-class-connected .wrap{max-wid
.instructor-exercise-console__facilitation article{margin-top:6px;}
.instructor-exercise-console__facilitation article strong{display:block;color:var(--ink-strong);font-size:10px;}
.instructor-exercise-console__facilitation article p{margin:3px 0 0;color:var(--ink);font-size:9px;line-height:1.4;}
+.instructor-exercise-console__release{margin-top:9px;padding-top:9px;border-top:1px solid var(--line);}
+.instructor-exercise-console__release>span{color:var(--muted);font-size:9px;font-weight:800;letter-spacing:.05em;text-transform:uppercase;}
+.instructor-exercise-console__release article{margin-top:6px;padding:7px 8px;border:1px solid var(--line);border-radius:10px;background:var(--panel);}
+.instructor-exercise-console__release article strong{display:block;color:var(--ink-strong);font-size:10px;}
+.instructor-exercise-console__release article p{margin:3px 0 6px;color:var(--ink);font-size:9px;line-height:1.4;}
+.instructor-exercise-console__release-state{color:var(--muted);font-size:9px;font-weight:750;}
+.instructor-exercise-console__progress{margin-top:10px;padding:10px;border:1px solid var(--line);border-radius:11px;background:var(--panel-muted);}
+.instructor-exercise-console__progress-heading{display:flex;align-items:baseline;justify-content:space-between;gap:8px;}
+.instructor-exercise-console__progress-heading strong{color:var(--ink-strong);font-size:11px;}
+.instructor-exercise-console__progress-heading span{color:var(--muted);font-size:9px;}
+.instructor-exercise-console__progress-list{display:flex;flex-direction:column;gap:5px;margin-top:7px;}
+.instructor-exercise-progress__row{appearance:none;width:100%;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:7px 8px;border:1px solid var(--line);border-radius:10px;background:var(--panel);color:var(--ink);text-align:left;cursor:pointer;}
+.instructor-exercise-progress__row:hover{border-color:color-mix(in srgb,var(--accent) 45%,var(--line));}
+.instructor-exercise-progress__row:focus-visible{outline:none;box-shadow:0 0 0 3px var(--focus-ring);}
+.instructor-exercise-progress__row>span:first-child{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:10px;}
+.instructor-exercise-progress__state{flex:0 0 auto;font-size:9px;font-weight:800;}
+.instructor-exercise-progress__state--ready{color:var(--accent);}
+.instructor-exercise-progress__empty{margin:7px 0 0;color:var(--muted);font-size:9px;line-height:1.4;}
.instructor-exercise-console__available{margin-top:9px;}
.instructor-exercise-console__available>span{display:block;color:var(--muted);font-size:9px;font-weight:800;letter-spacing:.05em;text-transform:uppercase;}
.instructor-exercise-console__available ul{display:flex;flex-direction:column;gap:5px;max-height:92px;overflow:auto;margin:6px 0 0;padding:0;list-style:none;}
From e0445595da3c4b98559ebd9e1fd3a85b1fff3a5f Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 18:36:09 -0400
Subject: [PATCH 103/206] Add evidence release and readiness progress
---
src/classroomExerciseInstructor.js | 252 ++++++++++++++++++++++++++++-
1 file changed, 250 insertions(+), 2 deletions(-)
diff --git a/src/classroomExerciseInstructor.js b/src/classroomExerciseInstructor.js
index 7d4833c..de8d068 100644
--- a/src/classroomExerciseInstructor.js
+++ b/src/classroomExerciseInstructor.js
@@ -31,6 +31,37 @@ function sanitizeCaseStudySummary(value) {
};
}
+function sanitizeRelease(value) {
+ if (!value || typeof value !== 'object') return null;
+ const stageId = typeof value.stageId === 'string' ? value.stageId.trim() : '';
+ const contentId = typeof value.contentId === 'string' ? value.contentId.trim() : '';
+ if (!stageId || !contentId) return null;
+ return {
+ stageId,
+ contentId,
+ releasedAt: typeof value.releasedAt === 'string' ? value.releasedAt : null
+ };
+}
+
+function sanitizeWorkspaceState(value) {
+ if (!value || typeof value !== 'object') return null;
+ const workspaceId = typeof value.workspaceId === 'string' ? value.workspaceId.trim() : '';
+ const workspaceLabel = typeof value.workspaceLabel === 'string' ? value.workspaceLabel.trim() : '';
+ const stageId = typeof value.stageId === 'string' ? value.stageId.trim() : '';
+ if (!workspaceId || !workspaceLabel || !stageId) return null;
+ return {
+ workspaceId,
+ workspaceKind: typeof value.workspaceKind === 'string' ? value.workspaceKind : '',
+ workspaceLabel,
+ stageId,
+ readyForDebrief: value.readyForDebrief === true,
+ readyAt: typeof value.readyAt === 'string' ? value.readyAt : null,
+ readyWorkspaceRevision: Number.isInteger(value.readyWorkspaceRevision)
+ ? value.readyWorkspaceRevision
+ : null
+ };
+}
+
/**
* Create the Instructor exercise console controller.
*
@@ -39,7 +70,8 @@ function sanitizeCaseStudySummary(value) {
*/
export function createInstructorExerciseConsoleController({
fetchImpl = globalThis.fetch?.bind(globalThis),
- documentRef = globalThis.document
+ documentRef = globalThis.document,
+ onSelectWorkspace = () => {}
} = {}) {
let capability = '';
let epoch = 0;
@@ -52,6 +84,8 @@ export function createInstructorExerciseConsoleController({
let caseStudy = null;
let availableCaseStudies = [];
let selectedCaseStudyId = '';
+ let releases = [];
+ let workspaceState = [];
const element = id => documentRef?.getElementById?.(id) || null;
@@ -137,10 +171,25 @@ export function createInstructorExerciseConsoleController({
(Array.isArray(caseStudy.simulation.instructorContent) ? caseStudy.simulation.instructorContent : [])
.map(item => [item.id, item])
);
+ const studentBlocks = new Map(
+ (Array.isArray(caseStudy.simulation.studentContent) ? caseStudy.simulation.studentContent : [])
+ .map(item => [item.id, item])
+ );
+ const stageReleases = new Map(
+ releases
+ .filter(item => item.stageId === stage.id)
+ .map(item => [item.contentId, item])
+ );
return {
stage,
instructorContent: (Array.isArray(stage.instructorContentIds) ? stage.instructorContentIds : [])
.map(id => instructorBlocks.get(id))
+ .filter(Boolean),
+ optionalContent: (Array.isArray(stage.optionalReleaseIds) ? stage.optionalReleaseIds : [])
+ .map(id => {
+ const content = studentBlocks.get(id);
+ return content ? { content, release: stageReleases.get(id) || null } : null;
+ })
.filter(Boolean)
};
};
@@ -208,6 +257,103 @@ export function createInstructorExerciseConsoleController({
}
};
+ const renderReleases = () => {
+ const panel = element('instructorExerciseReleasePanel');
+ const list = element('instructorExerciseReleaseList');
+ if (!panel || !list) return;
+ const context = currentStageContext();
+ const optionalContent = context?.optionalContent || [];
+ panel.hidden = optionalContent.length === 0;
+ list.replaceChildren();
+ if (!optionalContent.length) return;
+
+ const canRelease = exercise?.status === 'active' && exercise?.stagePhase === 'work' && !loading;
+ optionalContent.forEach(item => {
+ const row = documentRef.createElement('article');
+ row.setAttribute('data-persistence', 'local-only');
+ row.setAttribute('data-summary', 'exclude');
+ const title = documentRef.createElement('strong');
+ title.textContent = item.content.title || 'Optional evidence';
+ const body = documentRef.createElement('p');
+ body.textContent = item.content.body || '';
+ row.append(title, body);
+
+ if (item.release) {
+ const state = documentRef.createElement('span');
+ state.className = 'instructor-exercise-console__release-state';
+ state.textContent = 'Released';
+ row.append(state);
+ } else {
+ const button = documentRef.createElement('button');
+ button.type = 'button';
+ button.className = 'btn-secondary';
+ button.setAttribute('data-persistence', 'local-only');
+ button.setAttribute('data-summary', 'exclude');
+ button.textContent = canRelease ? 'Release to Students' : 'Resume to release';
+ button.disabled = !canRelease;
+ button.setAttribute('aria-label', `Release ${item.content.title || 'optional evidence'} to Students`);
+ button.addEventListener('click', () => { void releaseContent(item.content.id); });
+ row.append(button);
+ }
+ list.append(row);
+ });
+ };
+
+ const renderProgress = () => {
+ const panel = element('instructorExerciseProgressPanel');
+ const list = element('instructorExerciseProgressList');
+ const summary = element('instructorExerciseProgressSummary');
+ if (!panel || !list) return;
+ const stageId = exercise?.currentStageId || '';
+ panel.hidden = !stageId;
+ list.replaceChildren();
+ if (!stageId) {
+ if (summary) summary.textContent = '';
+ return;
+ }
+
+ const rows = workspaceState.filter(item => item.stageId === stageId);
+ const readyCount = rows.filter(item => item.readyForDebrief).length;
+ const workingCount = rows.length - readyCount;
+ if (summary) summary.textContent = rows.length
+ ? `${readyCount} ready · ${workingCount} working`
+ : 'No readiness signals yet';
+
+ if (!rows.length) {
+ const empty = documentRef.createElement('p');
+ empty.className = 'instructor-exercise-progress__empty';
+ empty.textContent = 'Waiting for teams to report readiness.';
+ list.append(empty);
+ return;
+ }
+
+ const wrapper = documentRef.createElement('div');
+ wrapper.className = 'instructor-exercise-console__progress-list';
+ rows.forEach(item => {
+ const button = documentRef.createElement('button');
+ button.type = 'button';
+ button.className = 'instructor-exercise-progress__row';
+ button.setAttribute('data-persistence', 'local-only');
+ button.setAttribute('data-summary', 'exclude');
+ button.setAttribute(
+ 'aria-label',
+ `Observe ${item.workspaceLabel}, ${item.readyForDebrief ? 'Ready' : 'Working'}`
+ );
+ const label = documentRef.createElement('span');
+ label.textContent = item.workspaceLabel;
+ const state = documentRef.createElement('span');
+ state.className = 'instructor-exercise-progress__state'
+ + (item.readyForDebrief ? ' instructor-exercise-progress__state--ready' : '');
+ state.textContent = item.readyForDebrief ? 'Ready' : 'Working';
+ button.append(label, state);
+ button.addEventListener('click', () => {
+ Promise.resolve(onSelectWorkspace(item.workspaceId)).catch(() => {});
+ });
+ wrapper.append(button);
+ });
+ list.append(wrapper);
+ };
+
const render = () => {
const panel = element('instructorExerciseConsole');
const status = element('instructorExerciseStatus');
@@ -252,6 +398,8 @@ export function createInstructorExerciseConsoleController({
renderSetup();
renderLifecycle();
renderStage();
+ renderReleases();
+ renderProgress();
renderAvailable();
};
@@ -264,12 +412,20 @@ export function createInstructorExerciseConsoleController({
caseStudy = null;
availableCaseStudies = [];
selectedCaseStudyId = '';
+ releases = [];
+ workspaceState = [];
};
const applyPayload = body => {
classroom = body?.class && typeof body.class === 'object' ? body.class : null;
exercise = body?.exercise && typeof body.exercise === 'object' ? body.exercise : null;
caseStudy = body?.caseStudy && typeof body.caseStudy === 'object' ? body.caseStudy : null;
+ releases = Array.isArray(body?.releases)
+ ? body.releases.map(sanitizeRelease).filter(Boolean)
+ : exercise ? releases : [];
+ workspaceState = Array.isArray(body?.workspaceState)
+ ? body.workspaceState.map(sanitizeWorkspaceState).filter(Boolean)
+ : exercise ? workspaceState : [];
if (Array.isArray(body?.availableCaseStudies)) {
availableCaseStudies = body.availableCaseStudies
.map(sanitizeCaseStudySummary)
@@ -474,6 +630,95 @@ export function createInstructorExerciseConsoleController({
}
};
+ const releaseContent = async contentId => {
+ if (
+ destroyed
+ || !capability
+ || !exercise
+ || loading
+ || typeof fetchImpl !== 'function'
+ ) return false;
+
+ const context = currentStageContext();
+ const requestedId = typeof contentId === 'string' ? contentId.trim() : '';
+ const optional = context?.optionalContent?.find(item => item.content.id === requestedId) || null;
+ if (!optional) return false;
+ if (optional.release) {
+ lastError = '';
+ lastNotice = 'content already released';
+ render();
+ return true;
+ }
+ if (exercise.status !== 'active' || exercise.stagePhase !== 'work') return false;
+
+ const expectedRevision = Number(exercise.exerciseRevision);
+ if (!Number.isInteger(expectedRevision) || expectedRevision < 1) {
+ lastError = 'Exercise revision is unavailable. Refresh and retry.';
+ lastNotice = '';
+ render();
+ return false;
+ }
+
+ const localCapability = capability;
+ const localEpoch = epoch;
+ loading = true;
+ lastError = '';
+ lastNotice = '';
+ render();
+
+ try {
+ const response = await fetchImpl(INSTRUCTOR_EXERCISE_ENDPOINT, {
+ method: 'PATCH',
+ headers: {
+ Authorization: `Bearer ${localCapability}`,
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ action: 'release-content',
+ expectedRevision,
+ contentId: requestedId
+ })
+ });
+ const body = await response.json().catch(() => ({}));
+ if (destroyed || localEpoch !== epoch || capability !== localCapability) return false;
+
+ if (response.status === 409) {
+ loading = false;
+ const reloaded = await refresh();
+ if (reloaded && !destroyed && localEpoch === epoch) {
+ lastNotice = 'current state reloaded';
+ render();
+ } else if (!lastError) {
+ lastError = typeof body.error === 'string' ? body.error : 'Exercise changed. Refresh and retry.';
+ render();
+ }
+ return false;
+ }
+
+ if (!response.ok) {
+ lastError = response.status === 401 || response.status === 404
+ ? 'Exercise access is no longer available.'
+ : typeof body.error === 'string' && body.error
+ ? body.error
+ : 'Could not release the Student evidence.';
+ return false;
+ }
+
+ applyPayload(body);
+ lastNotice = body.changed === false ? 'content already released' : 'content released';
+ return true;
+ } catch {
+ if (destroyed || localEpoch !== epoch) return false;
+ lastError = 'Could not release the Student evidence.';
+ return false;
+ } finally {
+ if (!destroyed && localEpoch === epoch) {
+ loading = false;
+ render();
+ }
+ }
+ };
+
const connectInstructor = async token => {
if (!validCapability(token) || destroyed) return false;
epoch += 1;
@@ -521,6 +766,7 @@ export function createInstructorExerciseConsoleController({
start: () => mutateLifecycle('start'),
pause: () => mutateLifecycle('pause'),
resume: () => mutateLifecycle('resume'),
+ releaseContent,
getState: () => ({
connected: Boolean(capability),
loading,
@@ -530,7 +776,9 @@ export function createInstructorExerciseConsoleController({
exercise,
selectedCaseStudyId,
caseStudy: caseStudy ? sanitizeCaseStudySummary(caseStudy) : null,
- availableCaseStudies: availableCaseStudies.map(item => ({ ...item }))
+ availableCaseStudies: availableCaseStudies.map(item => ({ ...item })),
+ releases: releases.map(item => ({ ...item })),
+ workspaceState: workspaceState.map(item => ({ ...item }))
}),
destroy: () => {
if (destroyed) return;
From 5db58b4eb97917bca192b75627839420a445c24a Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 18:36:17 -0400
Subject: [PATCH 104/206] Reuse Instructor observer from exercise progress
---
main.js | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/main.js b/main.js
index 31b2157..89ac901 100644
--- a/main.js
+++ b/main.js
@@ -204,7 +204,8 @@ function boot() {
initCommsDrawer();
classroomCaseStudiesController = initClassroomCaseStudies({ windowRef: window });
instructorExerciseConsoleController = initInstructorExerciseConsole({
- documentRef: document
+ documentRef: document,
+ onSelectWorkspace: workspaceId => instructorClassroomController?.selectWorkspace?.(workspaceId)
});
initTemplatesDrawer({ protectedCaseStudies: classroomCaseStudiesController });
initNotesWorkspace({ onSave: saveAppState, showToast });
From f7e9940cc4e87cd7ac6e9131972f2e3786ed05f6 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 18:36:41 -0400
Subject: [PATCH 105/206] Add synthetic release and progress fixture data
---
scripts/serve-browser-tests.mjs | 32 +++++++++++++++++++++++++++++++-
1 file changed, 31 insertions(+), 1 deletion(-)
diff --git a/scripts/serve-browser-tests.mjs b/scripts/serve-browser-tests.mjs
index a225390..95d0245 100644
--- a/scripts/serve-browser-tests.mjs
+++ b/scripts/serve-browser-tests.mjs
@@ -61,6 +61,12 @@ const BROWSER_STAGED_CASE = Object.freeze({
kind: 'narrative',
title: 'Initial browser briefing',
body: 'Synthetic Student-safe browser briefing.'
+ },
+ {
+ id: 'browser-hint-1',
+ kind: 'evidence',
+ title: 'Optional browser evidence',
+ body: 'Synthetic optional Student evidence released only by the Instructor.'
}
],
instructorContent: [
@@ -77,7 +83,7 @@ const BROWSER_STAGED_CASE = Object.freeze({
title: 'Clarify the browser case',
studentObjective: 'Capture the initial situation in Intake.',
initialReleaseIds: ['browser-brief-1'],
- optionalReleaseIds: [],
+ optionalReleaseIds: ['browser-hint-1'],
intakeTargetIds: ['problem.one-line'],
suggestedMinutes: 5,
instructorContentIds: ['browser-teach-1'],
@@ -100,6 +106,7 @@ const studentLiveAccessContexts = new Map();
const classroomWorkspaces = new Map();
const classroomCoachingFeedback = new Map();
const classroomExercises = new Map();
+const classroomExerciseReleases = new Map();
const CONTENT_TYPES = Object.freeze({
'.css': 'text/css; charset=utf-8',
@@ -356,6 +363,27 @@ function managedInstructorParticipantRoster(token) {
};
}
+function browserExerciseWorkspaceState(token, exercise) {
+ if (!exercise?.currentStageId) return [];
+ const fixture = managedInstructorFixture(token);
+ const workspaces = fixture?.workspaces || instructorRoster().workspaces;
+ return workspaces.map((workspace, index) => {
+ const observed = fixture?.workspaceStates?.get(workspace.id);
+ const ready = index === 0;
+ return {
+ workspaceId: workspace.id,
+ workspaceKind: workspace.kind,
+ workspaceLabel: workspace.label,
+ stageId: exercise.currentStageId,
+ readyForDebrief: ready,
+ readyAt: ready ? '2099-12-31T23:30:00.000Z' : null,
+ readyWorkspaceRevision: ready ? (observed?.revision || 1) : null,
+ createdAt: '2099-12-31T23:29:00.000Z',
+ updatedAt: '2099-12-31T23:30:00.000Z'
+ };
+ });
+}
+
function managedInstructorObservation(token, workspaceId) {
const fixture = managedInstructorFixture(token);
if (!fixture) return null;
@@ -646,6 +674,7 @@ async function handleClassroomApi(request, response, url) {
integratedInstructorWorkspaceStates.clear();
integratedInstructorParticipants = [];
classroomExercises.delete(INTEGRATED_INSTRUCTOR_TOKEN);
+ classroomExerciseReleases.delete(INTEGRATED_INSTRUCTOR_TOKEN);
sendJson(response, 201, {
class: integratedInstructorClass(),
instructorToken: INTEGRATED_INSTRUCTOR_TOKEN,
@@ -664,6 +693,7 @@ async function handleClassroomApi(request, response, url) {
liveInstructorWorkspaces = [];
liveInstructorWorkspaceStates.clear();
classroomExercises.delete(LIVE_INSTRUCTOR_TOKEN);
+ classroomExerciseReleases.delete(LIVE_INSTRUCTOR_TOKEN);
liveInstructorParticipants = [{
id: LIVE_PARTICIPANT_ID,
displayName: 'Waiting Student',
From d68f2dcabb7634cddadb9193f73130e69c10691f Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 18:37:08 -0400
Subject: [PATCH 106/206] Implement synthetic evidence release semantics
---
scripts/serve-browser-tests.mjs | 82 +++++++++++++++++++++++++++++----
1 file changed, 74 insertions(+), 8 deletions(-)
diff --git a/scripts/serve-browser-tests.mjs b/scripts/serve-browser-tests.mjs
index 95d0245..be153af 100644
--- a/scripts/serve-browser-tests.mjs
+++ b/scripts/serve-browser-tests.mjs
@@ -726,6 +726,10 @@ async function handleClassroomApi(request, response, url) {
description: BROWSER_STAGED_CASE.description,
supportedModes: [...BROWSER_STAGED_CASE.supportedModes]
}];
+ const exerciseReleases = () => structuredClone(classroomExerciseReleases.get(instructorToken) || []);
+ const exerciseProgress = exercise => structuredClone(
+ browserExerciseWorkspaceState(instructorToken, exercise)
+ );
if (request.method === 'GET') {
const exercise = classroomExercises.get(instructorToken) || null;
@@ -734,8 +738,8 @@ async function handleClassroomApi(request, response, url) {
class: classContext,
exercise: structuredClone(exercise),
caseStudy: structuredClone(BROWSER_STAGED_CASE),
- releases: [],
- workspaceState: [],
+ releases: exerciseReleases(),
+ workspaceState: exerciseProgress(exercise),
checkpoints: [],
editFreezeEnforced: true,
availableCaseStudies
@@ -769,8 +773,8 @@ async function handleClassroomApi(request, response, url) {
class: classContext,
exercise: structuredClone(existing),
caseStudy: structuredClone(BROWSER_STAGED_CASE),
- releases: [],
- workspaceState: [],
+ releases: exerciseReleases(),
+ workspaceState: exerciseProgress(existing),
checkpoints: [],
editFreezeEnforced: true,
created: false
@@ -790,12 +794,13 @@ async function handleClassroomApi(request, response, url) {
simulationFingerprint: 'b'.repeat(64)
};
classroomExercises.set(instructorToken, exercise);
+ classroomExerciseReleases.set(instructorToken, []);
sendJson(response, 201, {
class: classContext,
exercise: structuredClone(exercise),
caseStudy: structuredClone(BROWSER_STAGED_CASE),
- releases: [],
- workspaceState: [],
+ releases: exerciseReleases(),
+ workspaceState: exerciseProgress(exercise),
checkpoints: [],
editFreezeEnforced: true,
created: true
@@ -810,6 +815,67 @@ async function handleClassroomApi(request, response, url) {
sendJson(response, 404, { error: 'Exercise not found.' });
return true;
}
+
+ if (body?.action === 'release-content') {
+ const contentId = typeof body.contentId === 'string' ? body.contentId.trim() : '';
+ const stage = BROWSER_STAGED_CASE.simulation.stages.find(item => item.id === exercise.currentStageId);
+ if (
+ exercise.status !== 'active'
+ || exercise.stagePhase !== 'work'
+ || !stage
+ || !stage.optionalReleaseIds.includes(contentId)
+ ) {
+ sendJson(response, 400, { error: 'Invalid current-stage content release.' });
+ return true;
+ }
+
+ const releases = classroomExerciseReleases.get(instructorToken) || [];
+ const existingRelease = releases.find(item => (
+ item.stageId === stage.id && item.contentId === contentId
+ ));
+ if (existingRelease) {
+ sendJson(response, 200, {
+ class: classContext,
+ exercise: structuredClone(exercise),
+ caseStudy: structuredClone(BROWSER_STAGED_CASE),
+ releases: exerciseReleases(),
+ workspaceState: exerciseProgress(exercise),
+ checkpoints: [],
+ editFreezeEnforced: true,
+ changed: false
+ });
+ return true;
+ }
+
+ if (!Number.isInteger(body.expectedRevision) || body.expectedRevision !== exercise.exerciseRevision) {
+ sendJson(response, 409, {
+ error: 'Exercise changed. Refresh and retry.',
+ exercise: structuredClone(exercise)
+ });
+ return true;
+ }
+
+ releases.push({
+ stageId: stage.id,
+ contentId,
+ releasedAt: '2099-12-31T23:31:00.000Z'
+ });
+ classroomExerciseReleases.set(instructorToken, releases);
+ exercise.exerciseRevision += 1;
+ classroomExercises.set(instructorToken, exercise);
+ sendJson(response, 200, {
+ class: classContext,
+ exercise: structuredClone(exercise),
+ caseStudy: structuredClone(BROWSER_STAGED_CASE),
+ releases: exerciseReleases(),
+ workspaceState: exerciseProgress(exercise),
+ checkpoints: [],
+ editFreezeEnforced: true,
+ changed: true
+ });
+ return true;
+ }
+
if (!Number.isInteger(body?.expectedRevision) || body.expectedRevision !== exercise.exerciseRevision) {
sendJson(response, 409, {
error: 'Exercise changed. Refresh and retry.',
@@ -859,8 +925,8 @@ async function handleClassroomApi(request, response, url) {
class: classContext,
exercise: structuredClone(exercise),
caseStudy: structuredClone(BROWSER_STAGED_CASE),
- releases: [],
- workspaceState: [],
+ releases: exerciseReleases(),
+ workspaceState: exerciseProgress(exercise),
checkpoints: [],
editFreezeEnforced: true,
changed: true
From 811ef2bd81621161bf1fb362d96df6adebc364e4 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 18:37:38 -0400
Subject: [PATCH 107/206] Test evidence release and readiness progress
---
...assroomExerciseInstructor.feature.test.mjs | 206 ++++++++++++++++++
1 file changed, 206 insertions(+)
diff --git a/tests/classroomExerciseInstructor.feature.test.mjs b/tests/classroomExerciseInstructor.feature.test.mjs
index 5435eb6..d366138 100644
--- a/tests/classroomExerciseInstructor.feature.test.mjs
+++ b/tests/classroomExerciseInstructor.feature.test.mjs
@@ -358,6 +358,212 @@ test('Instructor lifecycle conflict refreshes authoritative state without replay
controller.destroy();
});
+test('Instructor releases only current-stage optional evidence and progress rows reuse observer navigation', async () => {
+ dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' });
+ const requests = [];
+ const observed = [];
+ let exerciseState = {
+ id: 'exercise-1',
+ caseStudyId: 'staged-a',
+ status: 'active',
+ stagePhase: 'work',
+ exerciseRevision: 2,
+ currentStageId: 'stage-1'
+ };
+ let releases = [];
+ const workspaceState = [
+ {
+ workspaceId: 'workspace-a',
+ workspaceKind: 'group',
+ workspaceLabel: 'Team Alpha',
+ stageId: 'stage-1',
+ readyForDebrief: true,
+ readyAt: 'ready',
+ readyWorkspaceRevision: 7
+ },
+ {
+ workspaceId: 'workspace-b',
+ workspaceKind: 'group',
+ workspaceLabel: 'Team Beta',
+ stageId: 'stage-1',
+ readyForDebrief: false,
+ readyAt: null,
+ readyWorkspaceRevision: null
+ }
+ ];
+ const caseStudy = {
+ id: 'staged-a',
+ name: 'Staged Case A',
+ description: 'Instructor simulation.',
+ supportedModes: ['full'],
+ simulation: {
+ studentContent: [
+ { id: 'hint-1', kind: 'evidence', title: 'Current optional evidence', body: 'Release this now.' },
+ { id: 'future-2', kind: 'evidence', title: 'Future optional evidence', body: 'Do not show this yet.' }
+ ],
+ instructorContent: [],
+ stages: [
+ {
+ id: 'stage-1',
+ title: 'Clarify',
+ studentObjective: 'Clarify the problem.',
+ suggestedMinutes: 5,
+ optionalReleaseIds: ['hint-1'],
+ instructorContentIds: []
+ },
+ {
+ id: 'stage-2',
+ title: 'Analyze',
+ studentObjective: 'Analyze later evidence.',
+ suggestedMinutes: 5,
+ optionalReleaseIds: ['future-2'],
+ instructorContentIds: []
+ }
+ ]
+ }
+ };
+
+ const body = () => ({
+ class: { id: 'class-1', title: 'PSDM Class' },
+ exercise: { ...exerciseState },
+ caseStudy,
+ releases: releases.map(item => ({ ...item })),
+ workspaceState: workspaceState.map(item => ({ ...item })),
+ checkpoints: [],
+ editFreezeEnforced: true,
+ availableCaseStudies: discoveryBody().availableCaseStudies
+ });
+
+ const controller = createInstructorExerciseConsoleController({
+ documentRef: dom.window.document,
+ onSelectWorkspace: workspaceId => observed.push(workspaceId),
+ fetchImpl: async (_url, options) => {
+ requests.push({
+ method: options.method,
+ body: options.body ? JSON.parse(options.body) : null
+ });
+ if (options.method === 'GET') return response(200, body());
+ if (options.method === 'PATCH') {
+ const payload = JSON.parse(options.body);
+ assert.deepEqual(payload, {
+ action: 'release-content',
+ expectedRevision: 2,
+ contentId: 'hint-1'
+ });
+ releases = [{ stageId: 'stage-1', contentId: 'hint-1', releasedAt: 'released' }];
+ exerciseState = { ...exerciseState, exerciseRevision: 3 };
+ return response(200, { ...body(), changed: true });
+ }
+ return response(405, {});
+ }
+ });
+
+ assert.equal(await controller.connectInstructor(TOKEN), true);
+ const releaseText = dom.window.document.getElementById('instructorExerciseReleaseList').textContent;
+ assert.equal(releaseText.includes('Current optional evidence'), true);
+ assert.equal(releaseText.includes('Future optional evidence'), false);
+ assert.equal(dom.window.document.getElementById('instructorExerciseProgressSummary').textContent, '1 ready · 1 working');
+
+ dom.window.document
+ .querySelector('[aria-label="Observe Team Beta, Working"]')
+ .dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
+ assert.deepEqual(observed, ['workspace-b']);
+
+ assert.equal(await controller.releaseContent('hint-1'), true);
+ assert.deepEqual(requests.at(-1), {
+ method: 'PATCH',
+ body: {
+ action: 'release-content',
+ expectedRevision: 2,
+ contentId: 'hint-1'
+ }
+ });
+ assert.equal(controller.getState().exercise.exerciseRevision, 3);
+ assert.equal(controller.getState().releases.length, 1);
+ assert.equal(controller.getState().workspaceState.length, 2);
+ assert.equal(dom.window.document.getElementById('instructorExerciseStatus').textContent, 'In progress · content released');
+ assert.equal(dom.window.document.getElementById('instructorExerciseReleaseList').textContent.includes('Released'), true);
+ assert.equal(
+ dom.window.document.querySelector('[aria-label="Release Current optional evidence to Students"]'),
+ null
+ );
+ controller.destroy();
+});
+
+test('Instructor evidence-release conflict refreshes authoritative released state without replay', async () => {
+ dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' });
+ const requests = [];
+ let getCount = 0;
+ const caseStudy = {
+ id: 'staged-a',
+ name: 'Staged Case A',
+ description: 'Instructor simulation.',
+ supportedModes: ['full'],
+ simulation: {
+ studentContent: [
+ { id: 'hint-1', kind: 'evidence', title: 'Current optional evidence', body: 'Release this now.' }
+ ],
+ instructorContent: [],
+ stages: [{
+ id: 'stage-1',
+ title: 'Clarify',
+ studentObjective: 'Clarify the problem.',
+ suggestedMinutes: 5,
+ optionalReleaseIds: ['hint-1'],
+ instructorContentIds: []
+ }]
+ }
+ };
+ const payload = ({ revision, released }) => ({
+ class: { id: 'class-1', title: 'PSDM Class' },
+ exercise: {
+ id: 'exercise-1',
+ caseStudyId: 'staged-a',
+ status: 'active',
+ stagePhase: 'work',
+ exerciseRevision: revision,
+ currentStageId: 'stage-1'
+ },
+ caseStudy,
+ releases: released
+ ? [{ stageId: 'stage-1', contentId: 'hint-1', releasedAt: 'released' }]
+ : [],
+ workspaceState: [],
+ checkpoints: [],
+ editFreezeEnforced: true,
+ availableCaseStudies: discoveryBody().availableCaseStudies
+ });
+
+ const controller = createInstructorExerciseConsoleController({
+ documentRef: dom.window.document,
+ fetchImpl: async (_url, options) => {
+ requests.push(options.method);
+ if (options.method === 'GET') {
+ getCount += 1;
+ return response(200, getCount === 1
+ ? payload({ revision: 2, released: false })
+ : payload({ revision: 3, released: true }));
+ }
+ if (options.method === 'PATCH') {
+ return response(409, {
+ error: 'Exercise changed. Refresh and retry.',
+ exercise: payload({ revision: 3, released: true }).exercise
+ });
+ }
+ return response(405, {});
+ }
+ });
+
+ await controller.connectInstructor(TOKEN);
+ assert.equal(await controller.releaseContent('hint-1'), false);
+ assert.deepEqual(requests, ['GET', 'PATCH', 'GET']);
+ assert.equal(controller.getState().exercise.exerciseRevision, 3);
+ assert.equal(controller.getState().releases[0].contentId, 'hint-1');
+ assert.equal(dom.window.document.getElementById('instructorExerciseStatus').textContent, 'In progress · current state reloaded');
+ assert.equal(dom.window.document.getElementById('instructorExerciseReleaseList').textContent.includes('Released'), true);
+ controller.destroy();
+});
+
test('Instructor exercise console keeps a connected shell on transient read failure and can refresh', async () => {
dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' });
let attempt = 0;
From 141f728d0248ed668fc395f24250cf78850d740d Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 18:37:56 -0400
Subject: [PATCH 108/206] Cover evidence release and team progress
---
tests/e2e/instructor.spec.mjs | 15 ++++++++++++++-
1 file changed, 14 insertions(+), 1 deletion(-)
diff --git a/tests/e2e/instructor.spec.mjs b/tests/e2e/instructor.spec.mjs
index 0a80811..9a3f8a9 100644
--- a/tests/e2e/instructor.spec.mjs
+++ b/tests/e2e/instructor.spec.mjs
@@ -84,8 +84,13 @@ test('Instructor starts a live class, creates a team, assigns a waiting Student,
await expect(page.locator('#instructorExerciseStageTiming')).toHaveText('Suggested time: 5 min');
await expect(page.locator('#instructorExerciseFacilitation')).toContainText('Browser facilitation note');
await expect(page.locator('#instructorExerciseFacilitation')).toContainText('Synthetic Instructor-only browser facilitation.');
+ await expect(page.locator('#instructorExerciseReleasePanel')).toContainText('Optional browser evidence');
await expect(page.locator('#instructorExercisePauseBtn')).toBeVisible();
+ await page.getByRole('button', { name: 'Release Optional browser evidence to Students' }).click();
+ await expect(page.locator('#instructorExerciseStatus')).toHaveText('In progress · content released');
+ await expect(page.locator('#instructorExerciseReleaseList')).toContainText('Released');
+
await page.getByRole('button', { name: 'Pause exercise' }).click();
await expect(page.locator('#instructorExerciseStatus')).toHaveText('Paused · exercise paused');
await expect(page.locator('#instructorExerciseResumeBtn')).toBeVisible();
@@ -115,10 +120,16 @@ test('Instructor starts a live class, creates a team, assigns a waiting Student,
await expect(assignment).not.toHaveValue('');
await expect(page.locator('[data-participant-id="99999999-9999-4999-8999-999999999999"]')).toContainText('Team · Team Alpha');
+ await page.locator('#instructorExerciseRefreshBtn').click();
+ await expect(page.locator('#instructorExerciseProgressSummary')).toHaveText('1 ready · 0 working');
+ await expect(page.getByRole('button', { name: 'Observe Team Alpha, Ready' })).toBeVisible();
+ await page.getByRole('button', { name: 'Observe Team Alpha, Ready' }).click();
+ await expect(page.locator('#instructorObservedWorkspace')).toHaveText('Team Alpha');
+
expect(classroomRequests.some(request => request.method === 'POST' && request.pathname === '/api/classes')).toBe(true);
expect(classroomRequests.some(request => request.method === 'POST' && request.pathname === '/api/classes/workspaces')).toBe(true);
expect(classroomRequests.some(request => request.method === 'POST' && request.pathname === '/api/classes/exercise')).toBe(true);
- expect(classroomRequests.filter(request => request.method === 'PATCH' && request.pathname === '/api/classes/exercise')).toHaveLength(3);
+ expect(classroomRequests.filter(request => request.method === 'PATCH' && request.pathname === '/api/classes/exercise')).toHaveLength(4);
expect(classroomRequests.some(request => request.method === 'PATCH' && request.pathname === '/api/classes/participants')).toBe(true);
expect(classroomRequests.some(request => request.pathname === '/api/classes/join')).toBe(false);
expect(classroomRequests.some(request => request.body.includes('assignmentToken'))).toBe(false);
@@ -137,6 +148,8 @@ test('Instructor starts a live class, creates a team, assigns a waiting Student,
await expect(page.getByLabel('Staged Case Study')).toHaveValue('browser-staged-simulation');
await expect(page.locator('#instructorExerciseCreateBtn')).toBeDisabled();
await expect(page.locator('#instructorExerciseStageTitle')).toHaveText('Clarify the browser case');
+ await expect(page.locator('#instructorExerciseReleaseList')).toContainText('Released');
+ await expect(page.locator('#instructorExerciseProgressSummary')).toHaveText('1 ready · 0 working');
await expect(page.locator('#instructorExercisePauseBtn')).toBeVisible();
await expectNoBlockingA11yViolations(page);
From 2c8145b31f96403f59ff3b41d9396fd2a3ac86ed Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 18:41:50 -0400
Subject: [PATCH 109/206] Annotate dynamic exercise controls as local only
---
src/classroomExerciseInstructor.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/classroomExerciseInstructor.js b/src/classroomExerciseInstructor.js
index de8d068..a6ae66e 100644
--- a/src/classroomExerciseInstructor.js
+++ b/src/classroomExerciseInstructor.js
@@ -284,7 +284,7 @@ export function createInstructorExerciseConsoleController({
state.textContent = 'Released';
row.append(state);
} else {
- const button = documentRef.createElement('button');
+ const button = documentRef.createElement('button'); // data-persistence="local-only" data-summary="exclude"
button.type = 'button';
button.className = 'btn-secondary';
button.setAttribute('data-persistence', 'local-only');
@@ -330,7 +330,7 @@ export function createInstructorExerciseConsoleController({
const wrapper = documentRef.createElement('div');
wrapper.className = 'instructor-exercise-console__progress-list';
rows.forEach(item => {
- const button = documentRef.createElement('button');
+ const button = documentRef.createElement('button'); // data-persistence="local-only" data-summary="exclude"
button.type = 'button';
button.className = 'instructor-exercise-progress__row';
button.setAttribute('data-persistence', 'local-only');
From 14036670c56616d0723051b0bf81ea0ab71cc627 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 18:46:06 -0400
Subject: [PATCH 110/206] Document exercise release and progress scope
---
src/classroomExerciseInstructor.js | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/classroomExerciseInstructor.js b/src/classroomExerciseInstructor.js
index a6ae66e..d5715b3 100644
--- a/src/classroomExerciseInstructor.js
+++ b/src/classroomExerciseInstructor.js
@@ -1,11 +1,11 @@
/**
* @module classroomExerciseInstructor
- * @summary Owns Instructor staged-exercise discovery, draft setup, lifecycle, and console state.
+ * @summary Owns Instructor staged-exercise setup, pacing, release, progress, and console state.
* @description
* Receives the active Instructor capability from the class lifecycle, keeps it
* in memory only, and reads/writes the Instructor-authorized exercise endpoint.
- * Tranche 6C adds revision-safe Start/Pause/Resume plus current-stage
- * Instructor facilitation context; later debrief/release actions remain deferred.
+ * Tranche 6D adds current-stage optional evidence release plus team readiness
+ * and observer navigation; debrief/freeze/advance/complete remain deferred.
*/
export const INSTRUCTOR_EXERCISE_ENDPOINT = '/api/classes/exercise';
From 58c7fd7febb6a12a194892f37d2b79a97b8b6833 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 18:46:09 -0400
Subject: [PATCH 111/206] Document exercise release and progress guardrails
---
src/AGENTS.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/AGENTS.md b/src/AGENTS.md
index af81a94..046d9c0 100644
--- a/src/AGENTS.md
+++ b/src/AGENTS.md
@@ -17,7 +17,7 @@ Keep feature modules independently initialisable, own only their feature-specifi
## Protected Classroom resources
-`classroomExerciseInstructor.js` owns the Instructor staged-exercise console client. It receives the Instructor capability only through the class lifecycle, keeps that credential and exercise payload in memory, and reads/writes `/api/classes/exercise`; draft selection/create may POST only an explicitly discovered staged `caseStudyId`. Start/Pause/Resume PATCH actions must send the currently observed `exerciseRevision` and a 409 must reload authoritative server state rather than replay stale intent. Current-stage Instructor facilitation may render only in Instructor experience. Exercise state, staged definitions, and capabilities must never enter Intake persistence, summaries, exports, URLs, or public manifests.
+`classroomExerciseInstructor.js` owns the Instructor staged-exercise console client. It receives the Instructor capability only through the class lifecycle, keeps that credential and exercise payload in memory, and reads/writes `/api/classes/exercise`; draft selection/create may POST only an explicitly discovered staged `caseStudyId`. Start/Pause/Resume and new current-stage `release-content` PATCH actions must send the currently observed `exerciseRevision`; a 409 must reload authoritative server state rather than replay stale intent. Release UI may render only the current stage's explicit `optionalReleaseIds`, never future-stage Student content. Team readiness/progress must come from the Instructor-authorized `workspaceState` payload and reuse the existing Instructor `selectWorkspace()` observer/coaching path. Current-stage Instructor facilitation may render only in Instructor experience. Exercise state, staged definitions, releases, readiness, and capabilities must never enter Intake persistence, summaries, exports, URLs, or public manifests.
`classroomCaseStudies.js` owns the authorized in-memory protected Case Study catalog/payload client. It receives active capabilities only through Student/Instructor lifecycle callbacks, keeps no localStorage/sessionStorage record, and clears catalog/context when the class role disconnects. Its public state must never expose a bearer capability.
From 7053801aaca8d54c0ce79b7baab03cac78525b11 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 18:46:13 -0400
Subject: [PATCH 112/206] Document exercise release and progress anchor
---
docs/commenting-guide.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/commenting-guide.md b/docs/commenting-guide.md
index 98730b7..d44e354 100644
--- a/docs/commenting-guide.md
+++ b/docs/commenting-guide.md
@@ -49,7 +49,7 @@ Anchors keep HTML and documentation in sync. Preserve existing tokens and regist
| `[feature:student-class-entry]` | `index.html`, between role chooser and Instructor shell | Student class join/reconnect gate shown before the assigned Intake is available. | `src/classroomStudent.js`, `src/experienceRoleController.js` | `studentClassEntryShell`, `studentClassJoinForm`, class/assignment/name inputs, retry controls; all local-only/summary-excluded. |
| `[feature:student-experience-notice]` | `index.html`, top of the connected Student Intake | Class/workspace/participant context plus explicit Leave class. | `src/classroomStudent.js`, `src/experienceRoleController.js` | `studentExperienceNotice`, `studentClassContextTitle`, `studentClassWorkspace`, `studentClassIdentity`, `studentClassLeaveBtn`; no persisted Intake fields. |
| `[feature:instructor-shell]` | `index.html`, between header and Intake wrap | Instructor class entry/resume and connected class workspace rail. | `src/classroomInstructor.js`, `src/experienceRoleController.js` | `instructorExperienceShell`, `instructorClassForm`, `instructorClassDashboard`, roster search/filter/list, Leave class; all class/session controls are local-only and summary-excluded. |
-| `[feature:instructor-exercise-console]` | `index.html`, inside the connected Instructor dashboard | Instructor staged-exercise discovery, draft setup, lifecycle pacing, and current-stage facilitation within the existing class rail. | `src/classroomExerciseInstructor.js` | `instructorExerciseConsole`, staged selector/Create draft, Start/Pause/Resume, current stage/objective/timing/facilitation, availability list, Refresh; all exercise context is memory-only and summary-excluded. |
+| `[feature:instructor-exercise-console]` | `index.html`, inside the connected Instructor dashboard | Instructor staged-exercise discovery, draft setup, lifecycle pacing, current-stage facilitation/evidence release, and team progress within the existing class rail. | `src/classroomExerciseInstructor.js` | `instructorExerciseConsole`, staged selector/Create draft, Start/Pause/Resume, current stage/objective/timing/facilitation, optional evidence release, Ready/Working progress rows, existing observer navigation, availability list, Refresh; all exercise context is memory-only and summary-excluded. |
| `[feature:instructor-observer]` | `index.html`, top of the temporarily revealed Intake wrap | Live read-only context for the selected Student/team workspace, including revision and recent activity. Dynamic coaching panels are appended beside registered targets inside this same observed Intake; they do not introduce a new structural HTML anchor. | `src/classroomInstructor.js`, `src/classroomCoaching.js`, `src/coachableFields.js` | `instructorObservationNotice`, observer metadata, plus `.classroom-coaching--instructor` / `.classroom-coaching--student` dynamic local-only panels. Observed Student state and coaching UI are excluded from Intake persistence/summary capture. |
| `[feature:intake-mode-selector]` | `index.html` header title | Wraps the accessible intake mode selector used to choose between General, IT, Pharma, and Major Incident Management lenses before capture. | `src/intakeModes.js`, `src/intakeModeController.js` | `intakeModeSelect`; options use stable mode values and persist under `meta.intakeMode` in `kt-intake-full-v2`. |
| `[feature:major-incident-role-legend]` | `index.html`, below the header | Provides the Major Incident-only four-phase legend mount; phase/primary-role badges decorate mapped workflow headings (including Decision and Risk gates) and every KT row, then hide without unmounting data when the mode changes. Icons and colour swatches are decorative because visible and accessible role text carries their meaning. | `src/majorIncidentRoles.js`, `src/intakeModes.js` | `majorIncidentRoleLegend`, `majorIncidentRoleLegendItems`; role presentation is not persisted, while the decorated controls retain their normal field contracts. |
From 648ee03c3ad6b95cc250e8fd2da8728f80aa26cd Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 18:46:38 -0400
Subject: [PATCH 113/206] Document Tranche 6D checkpoint
---
docs/classroom-workstream.md | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)
diff --git a/docs/classroom-workstream.md b/docs/classroom-workstream.md
index c165d09..4568221 100644
--- a/docs/classroom-workstream.md
+++ b/docs/classroom-workstream.md
@@ -475,7 +475,17 @@ Continue #295 on draft PR #307 from the current branch head.
- deterministic browser fixture now implements matching Start/Pause/Resume revision semantics against the synthetic staged case;
- focused controller coverage proves revision progression, stage/facilitation rendering, state-specific controls, and 409 refresh; real-browser Instructor acceptance proves Start -> Pause -> Resume -> reload;
- the unrelated four-browser acceptance timeout is raised narrowly from 30s to 45s after the previous run completed product assertions but timed out during teardown; no behavioral assertion was removed.
-- Exact next action: implement **Tranche 6D — optional evidence release and team readiness/progress signals**. Add current-work-stage optional release controls using revision-safe `release-content`, show already-released state, render per-workspace Ready/working signals from `workspaceState`, and connect team rows to existing observer navigation where practical. Do not add Begin Debrief, Freeze/Unfreeze, Advance, or Complete yet; keep Student staged-case UI and production official-case staging deferred.
+- Tranche 6D — optional evidence release and team readiness/progress:
+ - implementation spans `1de00a1...` through green implementation head `2c8145b...`;
+ - the console renders optional Student evidence only from the **current stage's** explicit `optionalReleaseIds`; future-stage optional content is not projected into the Instructor release UI;
+ - **Release to Students** PATCHes `release-content` with the currently observed `exerciseRevision`; a genuinely new stale release 409 refreshes authoritative state and never replays stale intent;
+ - an already-recorded release remains idempotent and renders **Released** without a second revision bump;
+ - current-stage `workspaceState` rows render Ready/Working progress and progress buttons reuse the existing Instructor `selectWorkspace()` observer/coaching path rather than creating a second observer;
+ - exercise releases/readiness/progress remain memory-only and local-only / summary-excluded;
+ - deterministic browser coverage uses only synthetic optional evidence/readiness and proves release, persisted release after reload, Ready progress, and one-click observer navigation;
+ - the prior four-browser failure was traced to nested scrolling in the expanded Instructor rail, not product authorization; the rail layout now lets the outer dashboard own scrolling so workspace buttons remain genuinely clickable without forced test clicks;
+ - validation on `2c8145b...`: repository quality **285 tests / 284 pass / 0 fail / 1 intentional skip**; required Browser E2E **26 passed / 8 intentional skips / 0 failed**; CI, CodeQL, Dependency Review, and Template Manifest Guard green.
+- Exact next action: implement **Tranche 6E — Begin Debrief, checkpoint review, and Freeze/Allow Editing**. Add revision-safe Begin Debrief, render captured immutable current-stage checkpoint status/revision per workspace, expose explicit Freeze / Allow Editing only during debrief with authoritative `studentEditingEnabled` state, and preserve 409 authoritative-refresh/no-replay behavior. Keep Advance/Complete deferred to 6F; keep Student staged-case UI and production official-case staging deferred.
## Approved Classroom follow-on direction
From 7ee2956d629cb313be221282a36ed5d888e896cf Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 18:46:42 -0400
Subject: [PATCH 114/206] Document Tranche 6D staged-simulation checkpoint
---
docs/classroom-staged-simulation.md | 39 ++++++++++++++++++++++-------
1 file changed, 30 insertions(+), 9 deletions(-)
diff --git a/docs/classroom-staged-simulation.md b/docs/classroom-staged-simulation.md
index 48da8b6..7cab03d 100644
--- a/docs/classroom-staged-simulation.md
+++ b/docs/classroom-staged-simulation.md
@@ -789,16 +789,37 @@ Lifecycle/pacing contract:
Implementation spans `53ce2b7...` through `2888beb...`.
+## Implementation checkpoint — Tranche 6D optional evidence release and team readiness/progress
+
+Release/progress contract:
+- optional release UI is derived only from the current stage's explicit `optionalReleaseIds` and the protected Instructor-authorized staged definition; future-stage optional Student content is not rendered as a release control;
+- a new optional release PATCHes `{ action: "release-content", expectedRevision, contentId }` only while the represented exercise is active/current-stage work;
+- an already-recorded release is idempotent and renders **Released** without a duplicate revision increment;
+- a genuinely new stale release returns conflict and the client performs one authoritative GET; stale release intent is never automatically replayed;
+- current-stage team progress is derived only from Instructor-authorized `workspaceState`; represented rows show Ready/Working while absence of a readiness row is reported honestly as no readiness signal yet;
+- progress buttons reuse the existing Instructor `selectWorkspace()` observer/coaching flow rather than minting any new workspace authority or observer path;
+- release/progress controls are local-only and summary-excluded; staged content, readiness, and capabilities remain outside Intake persistence/exports;
+- deterministic browser acceptance uses only synthetic optional evidence/readiness and proves release -> Released, Ready progress, observer navigation, and server-backed state after reload;
+- expansion of the Instructor console exposed a real nested-scroll usability bug in the rail. The inner workspace list no longer owns a competing scroll region, so the outer dashboard keeps workspace rows reachable/clickable without force-click workarounds.
+
+Implementation spans `1de00a1...` through `2c8145b...`.
+
+Validation on `2c8145b31f96403f59ff3b41d9396fd2a3ac86ed`:
+- repository quality: **285 tests / 284 pass / 0 fail / 1 intentional skip**;
+- required Browser E2E: **26 passed / 8 intentional project-scoped skips / 0 failed**;
+- CI, CodeQL, Dependency Review, and Template Manifest Guard: green.
+
## Exact next implementation action
-Implement **Tranche 6D — optional evidence release and team readiness/progress signals**.
+Implement **Tranche 6E — Begin Debrief, checkpoint review, and Freeze/Allow Editing**.
Required next slice:
-- render current-stage optional Student content to the Instructor as unreleased/released controls without exposing future-stage content;
-- PATCH `release-content` with the current exercise revision and selected current-stage content ID;
-- preserve release idempotency and 409 authoritative-refresh behavior;
-- show current-stage per-workspace readiness/working state from the existing Instructor exercise payload;
-- make progress rows useful for existing observer/coaching navigation without creating a second observer;
-- extend the synthetic staged browser case with deterministic optional evidence/readiness only as needed for acceptance.
-
-Do **not** add Begin Debrief, Freeze/Unfreeze, Advance, or Complete in 6D. Keep Student staged-case UI and production official Case Study staging deferred.
+- expose Begin Debrief only for an active current-stage work phase and send the current `exerciseRevision`;
+- after Begin Debrief, render authoritative current-stage checkpoint capture status/revision for represented workspaces without replacing live Intake;
+- render the authoritative `studentEditingEnabled` policy separately from Pause state;
+- expose explicit **Freeze editing** / **Allow editing** only in active current-stage debrief and PATCH revision-safe `set-editing`;
+- preserve the server-enforced 423 boundary already implemented in Tranche 5;
+- every 409 lifecycle/edit-policy conflict must authoritative-refresh rather than replay stale intent;
+- extend focused and synthetic-browser coverage for work -> debrief checkpoint capture -> freeze/unfreeze while observer/coaching remains available.
+
+Do **not** add Advance or Complete in 6E; reserve those for Tranche 6F. Keep Student staged-case UI and production official Case Study staging deferred.
From 044e4f82beec3681c862717260b45365e2530b03 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 20:09:03 -0400
Subject: [PATCH 115/206] Add debrief controls and class rail collapse
---
index.html | 21 +++++++++++++++++++++
1 file changed, 21 insertions(+)
diff --git a/index.html b/index.html
index 1098ae0..2898541 100644
--- a/index.html
+++ b/index.html
@@ -403,6 +403,7 @@
Class
Available staged Case Studies
From 8944ed6a5c3f28dd3d03a6865219f0b3e4035d8a Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 20:09:29 -0400
Subject: [PATCH 116/206] Style debrief controls and collapsible class rail
---
styles.css | 41 +++++++++++++++++++++++++++++++++++++++--
1 file changed, 39 insertions(+), 2 deletions(-)
diff --git a/styles.css b/styles.css
index 714b9cf..3fa8806 100644
--- a/styles.css
+++ b/styles.css
@@ -3163,6 +3163,11 @@ body.dialog-open{overflow:hidden;}
.instructor-dashboard__header h2{margin:0;color:var(--ink-strong);font-size:22px;letter-spacing:-.02em;}
.instructor-dashboard__header p{margin:6px 0 0;color:var(--muted);font-size:12px;}
.instructor-dashboard__actions{display:flex;gap:8px;flex-wrap:wrap;}
+.instructor-dashboard.is-collapsed{min-height:0;}
+.instructor-dashboard.is-collapsed>:not(.instructor-dashboard__header){display:none !important;}
+.instructor-dashboard.is-collapsed .instructor-dashboard__header{padding-bottom:0;border-bottom:0;align-items:center;}
+.instructor-dashboard.is-collapsed .instructor-dashboard__header>div:first-child>:not(h2){display:none;}
+.instructor-dashboard.is-collapsed .instructor-dashboard__actions>:not(#instructorClassPanelToggle){display:none;}
.instructor-live-management{margin:16px 0;padding:12px;border:1px solid var(--line);border-radius:14px;background:var(--panel-muted);}
.instructor-live-management__heading{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px;}
.instructor-live-management__heading>div{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap;}
@@ -3208,15 +3213,26 @@ body[data-experience-role="instructor"].instructor-class-connected .instructor-s
body[data-experience-role="instructor"].instructor-class-connected .instructor-shell .instructor-dashboard{overflow-x:hidden;overflow-y:auto;overscroll-behavior:contain;}
body[data-experience-role="instructor"].instructor-class-connected .instructor-workspace-list{flex-shrink:0;overflow:visible;padding-bottom:4px;}
body[data-experience-role="instructor"].instructor-class-connected .wrap{max-width:none;margin-left:370px;margin-right:24px;}
+body[data-experience-role="instructor"].instructor-class-connected.instructor-rail-collapsed .instructor-shell{bottom:auto;width:190px;}
+body[data-experience-role="instructor"].instructor-class-connected.instructor-rail-collapsed .instructor-shell .experience-shell__card{height:auto;}
+body[data-experience-role="instructor"].instructor-class-connected.instructor-rail-collapsed .wrap{margin-left:230px;}
@media(max-width:980px){
- body[data-experience-role="instructor"].instructor-class-connected .instructor-shell{position:static;width:auto;max-width:1100px;margin:20px auto;padding:0 12px;}
+ body[data-experience-role="instructor"].instructor-class-connected .instructor-shell,
+ body[data-experience-role="instructor"].instructor-class-connected.instructor-rail-collapsed .instructor-shell{position:static;width:auto;max-width:1100px;margin:20px auto;padding:0 12px;}
body[data-experience-role="instructor"].instructor-class-connected .instructor-shell .experience-shell__card{height:auto;max-height:none;overflow:visible;}
body[data-experience-role="instructor"].instructor-class-connected .instructor-workspace-list{max-height:360px;}
- body[data-experience-role="instructor"].instructor-class-connected .wrap{margin-left:auto;margin-right:auto;}
+ body[data-experience-role="instructor"].instructor-class-connected .wrap,
+ body[data-experience-role="instructor"].instructor-class-connected.instructor-rail-collapsed .wrap{margin-left:auto;margin-right:auto;}
}
@media(max-width:700px){
+ body[data-experience-role="instructor"].instructor-class-connected .instructor-shell{margin:10px auto 8px;padding:0 10px;}
+ body[data-experience-role="instructor"].instructor-class-connected .instructor-shell .experience-shell__card{padding:12px;border-radius:14px;}
.instructor-dashboard__header,.instructor-observation-notice{flex-direction:column;align-items:stretch;}
.instructor-dashboard__actions{flex-direction:column;}
+ .instructor-dashboard.is-collapsed .instructor-dashboard__header{flex-direction:row;align-items:center;gap:10px;}
+ .instructor-dashboard.is-collapsed .instructor-dashboard__header h2{font-size:17px;}
+ .instructor-dashboard.is-collapsed .instructor-dashboard__actions{flex-direction:row;flex:0 0 auto;}
+ .instructor-dashboard.is-collapsed #instructorClassPanelToggle{padding:7px 9px;font-size:10px;white-space:nowrap;}
.instructor-roster-controls{grid-template-columns:1fr;}
.instructor-workspace-create{grid-template-columns:1fr;}
.instructor-participant-heading{align-items:flex-start;flex-direction:column;}
@@ -3271,6 +3287,22 @@ body[data-experience-role="instructor"].instructor-class-connected .wrap{max-wid
.instructor-exercise-progress__state{flex:0 0 auto;font-size:9px;font-weight:800;}
.instructor-exercise-progress__state--ready{color:var(--accent);}
.instructor-exercise-progress__empty{margin:7px 0 0;color:var(--muted);font-size:9px;line-height:1.4;}
+.instructor-exercise-console__debrief{margin-top:10px;padding:10px;border:1px solid color-mix(in srgb,var(--accent) 30%,var(--line));border-radius:11px;background:color-mix(in srgb,var(--accent) 5%,var(--panel));}
+.instructor-exercise-console__debrief-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;}
+.instructor-exercise-console__debrief-heading>div:first-child{min-width:0;}
+.instructor-exercise-console__debrief-heading strong{display:block;color:var(--ink-strong);font-size:11px;}
+.instructor-exercise-console__debrief-heading span{display:block;margin-top:2px;color:var(--muted);font-size:9px;line-height:1.35;}
+.instructor-exercise-console__editing-actions{display:flex;gap:5px;flex-wrap:wrap;justify-content:flex-end;}
+.instructor-exercise-console__editing-actions .btn,.instructor-exercise-console__editing-actions .btn-secondary{padding:6px 8px;font-size:9px;white-space:nowrap;}
+.instructor-exercise-console__checkpoints{margin-top:9px;padding-top:9px;border-top:1px solid var(--line);}
+.instructor-exercise-console__checkpoint-heading{display:flex;align-items:baseline;justify-content:space-between;gap:8px;}
+.instructor-exercise-console__checkpoint-heading span{color:var(--muted);font-size:9px;font-weight:800;letter-spacing:.05em;text-transform:uppercase;}
+.instructor-exercise-console__checkpoint-heading strong{color:var(--ink-strong);font-size:9px;}
+.instructor-exercise-checkpoint-list{display:flex;flex-direction:column;gap:5px;margin-top:7px;}
+.instructor-exercise-checkpoint__row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:7px 8px;border:1px solid var(--line);border-radius:10px;background:var(--panel);}
+.instructor-exercise-checkpoint__row span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--ink);font-size:10px;}
+.instructor-exercise-checkpoint__row strong{flex:0 0 auto;color:var(--muted);font-size:9px;}
+.instructor-exercise-checkpoint__empty{margin:7px 0 0;color:var(--muted);font-size:9px;line-height:1.4;}
.instructor-exercise-console__available{margin-top:9px;}
.instructor-exercise-console__available>span{display:block;color:var(--muted);font-size:9px;font-weight:800;letter-spacing:.05em;text-transform:uppercase;}
.instructor-exercise-console__available ul{display:flex;flex-direction:column;gap:5px;max-height:92px;overflow:auto;margin:6px 0 0;padding:0;list-style:none;}
@@ -3303,3 +3335,8 @@ body[data-experience-role="instructor"].instructor-class-connected .wrap{max-wid
.classroom-coaching--student .classroom-coaching__heading{margin-bottom:4px;}
.classroom-coaching--student .classroom-coaching__heading strong{font-size:11px;color:var(--ink-strong);}
th .classroom-coaching{margin-top:8px;text-align:left;font-weight:400;}
+
+@media(max-width:700px){
+ .instructor-exercise-console__debrief-heading{flex-direction:column;}
+ .instructor-exercise-console__editing-actions{justify-content:flex-start;}
+}
From 35d91cb7e40681a30d3a483d7a7875e2ff3b61a7 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 20:09:50 -0400
Subject: [PATCH 117/206] Add collapsible Instructor class rail
---
src/classroomInstructor.js | 40 ++++++++++++++++++++++++++++++++++++--
1 file changed, 38 insertions(+), 2 deletions(-)
diff --git a/src/classroomInstructor.js b/src/classroomInstructor.js
index fff204a..739994b 100644
--- a/src/classroomInstructor.js
+++ b/src/classroomInstructor.js
@@ -138,6 +138,9 @@ export function createInstructorClassroomController({
let searchQuery = '';
let kindFilter = 'all';
let lastError = '';
+ const mobileRailQuery = windowRef?.matchMedia?.('(max-width: 700px)') || null;
+ let railExpanded = !mobileRailQuery?.matches;
+ let railPreferenceTouched = false;
const readonlyRecords = new Map();
const element = id => documentRef?.getElementById(id) || null;
@@ -145,7 +148,29 @@ export function createInstructorClassroomController({
const setStatus = status => {
if (documentRef?.body) documentRef.body.dataset.instructorClassStatus = status;
};
- const setConnectedLayout = connected => documentRef?.body?.classList?.toggle('instructor-class-connected', connected);
+ const setConnectedLayout = connected => {
+ documentRef?.body?.classList?.toggle('instructor-class-connected', connected);
+ documentRef?.body?.classList?.toggle('instructor-rail-collapsed', Boolean(connected && !railExpanded));
+ };
+ const renderRailExpansion = () => {
+ const dashboard = element('instructorClassDashboard');
+ const toggle = element('instructorClassPanelToggle');
+ dashboard?.classList?.toggle('is-collapsed', !railExpanded);
+ if (toggle) {
+ toggle.setAttribute('aria-expanded', String(railExpanded));
+ toggle.textContent = railExpanded ? 'Collapse class panel' : 'Open class panel';
+ }
+ documentRef?.body?.classList?.toggle(
+ 'instructor-rail-collapsed',
+ Boolean(activeSession && !railExpanded)
+ );
+ };
+ const setRailExpanded = (expanded, { user = false } = {}) => {
+ railExpanded = Boolean(expanded);
+ if (user) railPreferenceTouched = true;
+ renderRailExpansion();
+ return railExpanded;
+ };
const setError = message => {
lastError = message || '';
const error = element('instructorClassError');
@@ -229,6 +254,7 @@ export function createInstructorClassroomController({
if (element('instructorClassEntryCard')) element('instructorClassEntryCard').hidden = true;
if (element('instructorClassDashboard')) element('instructorClassDashboard').hidden = false;
if (element('instructorClassTitle')) element('instructorClassTitle').textContent = activeSession.class.title;
+ renderRailExpansion();
renderExpiry(activeSession.class.expiresAt);
const joinCode = formatInstructorJoinCode(activeSession.joinCode || activeSession.class?.joinCode || '');
if (element('instructorJoinCode')) element('instructorJoinCode').textContent = joinCode || 'Unavailable';
@@ -1034,6 +1060,11 @@ export function createInstructorClassroomController({
);
};
const handleCopyJoinCode = () => { void copyJoinCode(); };
+ const handleRailToggle = () => { setRailExpanded(!railExpanded, { user: true }); };
+ const handleRailMediaChange = event => {
+ if (railPreferenceTouched) return;
+ setRailExpanded(!event.matches);
+ };
const handleRetry = () => { void resume(); };
const handleLeave = () => leaveClass();
const handleSearch = event => {
@@ -1064,6 +1095,8 @@ export function createInstructorClassroomController({
element('instructorClassForm')?.addEventListener('submit', handleSubmit);
element('instructorWorkspaceCreateForm')?.addEventListener('submit', handleWorkspaceCreate);
element('instructorCopyJoinCodeBtn')?.addEventListener('click', handleCopyJoinCode);
+ element('instructorClassPanelToggle')?.addEventListener('click', handleRailToggle);
+ mobileRailQuery?.addEventListener?.('change', handleRailMediaChange);
element('instructorClassRetryBtn')?.addEventListener('click', handleRetry);
element('instructorClassLeaveBtn')?.addEventListener('click', handleLeave);
element('instructorWorkspaceSearch')?.addEventListener('input', handleSearch);
@@ -1085,6 +1118,8 @@ export function createInstructorClassroomController({
element('instructorClassForm')?.removeEventListener('submit', handleSubmit);
element('instructorWorkspaceCreateForm')?.removeEventListener('submit', handleWorkspaceCreate);
element('instructorCopyJoinCodeBtn')?.removeEventListener('click', handleCopyJoinCode);
+ element('instructorClassPanelToggle')?.removeEventListener('click', handleRailToggle);
+ mobileRailQuery?.removeEventListener?.('change', handleRailMediaChange);
element('instructorClassRetryBtn')?.removeEventListener('click', handleRetry);
element('instructorClassLeaveBtn')?.removeEventListener('click', handleLeave);
element('instructorWorkspaceSearch')?.removeEventListener('input', handleSearch);
@@ -1105,9 +1140,10 @@ export function createInstructorClassroomController({
refreshRoster,
selectWorkspace,
leaveClass,
+ setRailExpanded: expanded => setRailExpanded(expanded, { user: true }),
getState: () => ({
activeSession, workspaces, participants, selectedWorkspaceId, observerEpoch,
- latestObservation, busy, searchQuery, kindFilter, lastError
+ latestObservation, busy, searchQuery, kindFilter, lastError, railExpanded
})
};
}
From 4c11e772f575bd954475556a7b430c11e7557e07 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 20:10:24 -0400
Subject: [PATCH 118/206] Add debrief checkpoints and editing controls
---
src/classroomExerciseInstructor.js | 224 ++++++++++++++++++++++++++++-
1 file changed, 218 insertions(+), 6 deletions(-)
diff --git a/src/classroomExerciseInstructor.js b/src/classroomExerciseInstructor.js
index d5715b3..159f050 100644
--- a/src/classroomExerciseInstructor.js
+++ b/src/classroomExerciseInstructor.js
@@ -1,11 +1,11 @@
/**
* @module classroomExerciseInstructor
- * @summary Owns Instructor staged-exercise setup, pacing, release, progress, and console state.
+ * @summary Owns Instructor staged-exercise setup, pacing, release, debrief, progress, and console state.
* @description
* Receives the active Instructor capability from the class lifecycle, keeps it
* in memory only, and reads/writes the Instructor-authorized exercise endpoint.
- * Tranche 6D adds current-stage optional evidence release plus team readiness
- * and observer navigation; debrief/freeze/advance/complete remain deferred.
+ * Tranche 6E adds revision-safe debrief entry, checkpoint status, and explicit
+ * Student editing policy controls; advance/complete remain deferred.
*/
export const INSTRUCTOR_EXERCISE_ENDPOINT = '/api/classes/exercise';
@@ -62,6 +62,25 @@ function sanitizeWorkspaceState(value) {
};
}
+function sanitizeCheckpoint(value) {
+ if (!value || typeof value !== 'object') return null;
+ const workspaceId = typeof value.workspaceId === 'string' ? value.workspaceId.trim() : '';
+ const workspaceLabel = typeof value.workspaceLabel === 'string' ? value.workspaceLabel.trim() : '';
+ const stageId = typeof value.stageId === 'string' ? value.stageId.trim() : '';
+ const workspaceRevision = Number(value.workspaceRevision);
+ if (!workspaceId || !workspaceLabel || !stageId || !Number.isInteger(workspaceRevision) || workspaceRevision < 1) {
+ return null;
+ }
+ return {
+ workspaceId,
+ workspaceKind: typeof value.workspaceKind === 'string' ? value.workspaceKind : '',
+ workspaceLabel,
+ stageId,
+ workspaceRevision,
+ capturedAt: typeof value.capturedAt === 'string' ? value.capturedAt : null
+ };
+}
+
/**
* Create the Instructor exercise console controller.
*
@@ -86,6 +105,7 @@ export function createInstructorExerciseConsoleController({
let selectedCaseStudyId = '';
let releases = [];
let workspaceState = [];
+ let checkpoints = [];
const element = id => documentRef?.getElementById?.(id) || null;
@@ -199,12 +219,15 @@ export function createInstructorExerciseConsoleController({
const start = element('instructorExerciseStartBtn');
const pause = element('instructorExercisePauseBtn');
const resume = element('instructorExerciseResumeBtn');
+ const beginDebrief = element('instructorExerciseBeginDebriefBtn');
+ const help = element('instructorExerciseLifecycleHelp');
if (!lifecycle) return;
lifecycle.hidden = !exercise;
const canStart = exercise?.status === 'draft' && !exercise.currentStageId;
const canPause = exercise?.status === 'active';
const canResume = exercise?.status === 'paused';
+ const canBeginDebrief = exercise?.status === 'active' && exercise?.stagePhase === 'work' && Boolean(exercise.currentStageId);
if (start) {
start.hidden = !canStart;
@@ -218,6 +241,15 @@ export function createInstructorExerciseConsoleController({
resume.hidden = !canResume;
resume.disabled = loading || !canResume;
}
+ if (beginDebrief) {
+ beginDebrief.hidden = !canBeginDebrief;
+ beginDebrief.disabled = loading || !canBeginDebrief;
+ }
+ if (help) {
+ help.textContent = exercise?.stagePhase === 'debrief'
+ ? 'Debrief preserves the captured team checkpoints. Pause still controls class pacing; editing policy is controlled below.'
+ : 'Pause controls class pacing only. Student editing freeze is a separate debrief control.';
+ }
};
const renderStage = () => {
@@ -289,7 +321,11 @@ export function createInstructorExerciseConsoleController({
button.className = 'btn-secondary';
button.setAttribute('data-persistence', 'local-only');
button.setAttribute('data-summary', 'exclude');
- button.textContent = canRelease ? 'Release to Students' : 'Resume to release';
+ button.textContent = canRelease
+ ? 'Release to Students'
+ : exercise?.stagePhase === 'debrief'
+ ? 'Not released before debrief'
+ : 'Resume to release';
button.disabled = !canRelease;
button.setAttribute('aria-label', `Release ${item.content.title || 'optional evidence'} to Students`);
button.addEventListener('click', () => { void releaseContent(item.content.id); });
@@ -354,6 +390,69 @@ export function createInstructorExerciseConsoleController({
list.append(wrapper);
};
+ const renderDebrief = () => {
+ const panel = element('instructorExerciseDebriefPanel');
+ const editingStatus = element('instructorExerciseEditingStatus');
+ const freezeButton = element('instructorExerciseFreezeBtn');
+ const allowButton = element('instructorExerciseAllowEditingBtn');
+ const summary = element('instructorExerciseCheckpointSummary');
+ const list = element('instructorExerciseCheckpointList');
+ if (!panel || !list) return;
+
+ const isDebrief = exercise?.stagePhase === 'debrief' && Boolean(exercise?.currentStageId);
+ panel.hidden = !isDebrief;
+ list.replaceChildren();
+ if (!isDebrief) {
+ if (editingStatus) editingStatus.textContent = 'Student editing status unavailable';
+ if (summary) summary.textContent = '0 captured';
+ return;
+ }
+
+ const editingAllowed = exercise.studentEditingEnabled !== false;
+ const canChangeEditing = exercise.status === 'active' && !loading;
+ if (editingStatus) {
+ editingStatus.textContent = editingAllowed
+ ? 'Student editing allowed during debrief'
+ : 'Student editing frozen during debrief';
+ }
+ if (freezeButton) {
+ freezeButton.hidden = !editingAllowed;
+ freezeButton.disabled = !canChangeEditing || !editingAllowed;
+ }
+ if (allowButton) {
+ allowButton.hidden = editingAllowed;
+ allowButton.disabled = !canChangeEditing || editingAllowed;
+ }
+
+ const stageCheckpoints = checkpoints.filter(item => item.stageId === exercise.currentStageId);
+ if (summary) {
+ summary.textContent = stageCheckpoints.length === 1
+ ? '1 captured'
+ : `${stageCheckpoints.length} captured`;
+ }
+ if (!stageCheckpoints.length) {
+ const empty = documentRef.createElement('p');
+ empty.className = 'instructor-exercise-checkpoint__empty';
+ empty.textContent = 'No workspace checkpoint was captured for this stage.';
+ list.append(empty);
+ return;
+ }
+
+ const wrapper = documentRef.createElement('div');
+ wrapper.className = 'instructor-exercise-checkpoint-list';
+ stageCheckpoints.forEach(item => {
+ const row = documentRef.createElement('div');
+ row.className = 'instructor-exercise-checkpoint__row';
+ const label = documentRef.createElement('span');
+ label.textContent = item.workspaceLabel;
+ const revision = documentRef.createElement('strong');
+ revision.textContent = `Revision ${item.workspaceRevision}`;
+ row.append(label, revision);
+ wrapper.append(row);
+ });
+ list.append(wrapper);
+ };
+
const render = () => {
const panel = element('instructorExerciseConsole');
const status = element('instructorExerciseStatus');
@@ -400,6 +499,7 @@ export function createInstructorExerciseConsoleController({
renderStage();
renderReleases();
renderProgress();
+ renderDebrief();
renderAvailable();
};
@@ -414,6 +514,7 @@ export function createInstructorExerciseConsoleController({
selectedCaseStudyId = '';
releases = [];
workspaceState = [];
+ checkpoints = [];
};
const applyPayload = body => {
@@ -426,6 +527,9 @@ export function createInstructorExerciseConsoleController({
workspaceState = Array.isArray(body?.workspaceState)
? body.workspaceState.map(sanitizeWorkspaceState).filter(Boolean)
: exercise ? workspaceState : [];
+ checkpoints = Array.isArray(body?.checkpoints)
+ ? body.checkpoints.map(sanitizeCheckpoint).filter(Boolean)
+ : exercise ? checkpoints : [];
if (Array.isArray(body?.availableCaseStudies)) {
availableCaseStudies = body.availableCaseStudies
.map(sanitizeCaseStudySummary)
@@ -567,6 +671,12 @@ export function createInstructorExerciseConsoleController({
(action === 'start' && exercise.status === 'draft' && !exercise.currentStageId)
|| (action === 'pause' && exercise.status === 'active')
|| (action === 'resume' && exercise.status === 'paused')
+ || (
+ action === 'begin-debrief'
+ && exercise.status === 'active'
+ && exercise.stagePhase === 'work'
+ && Boolean(exercise.currentStageId)
+ )
);
if (!allowed) return false;
@@ -616,7 +726,9 @@ export function createInstructorExerciseConsoleController({
? 'exercise started'
: action === 'pause'
? 'exercise paused'
- : 'exercise resumed';
+ : action === 'resume'
+ ? 'exercise resumed'
+ : 'debrief started';
return true;
} catch {
if (destroyed || localEpoch !== epoch) return false;
@@ -719,6 +831,94 @@ export function createInstructorExerciseConsoleController({
}
};
+ const setEditing = async enabled => {
+ if (
+ destroyed
+ || !capability
+ || !exercise
+ || loading
+ || typeof fetchImpl !== 'function'
+ || exercise.status !== 'active'
+ || exercise.stagePhase !== 'debrief'
+ || typeof enabled !== 'boolean'
+ ) return false;
+
+ const currentEnabled = exercise.studentEditingEnabled !== false;
+ if (currentEnabled === enabled) {
+ lastError = '';
+ lastNotice = enabled ? 'editing already allowed' : 'editing already frozen';
+ render();
+ return true;
+ }
+
+ const expectedRevision = Number(exercise.exerciseRevision);
+ if (!Number.isInteger(expectedRevision) || expectedRevision < 1) {
+ lastError = 'Exercise revision is unavailable. Refresh and retry.';
+ lastNotice = '';
+ render();
+ return false;
+ }
+
+ const localCapability = capability;
+ const localEpoch = epoch;
+ loading = true;
+ lastError = '';
+ lastNotice = '';
+ render();
+
+ try {
+ const response = await fetchImpl(INSTRUCTOR_EXERCISE_ENDPOINT, {
+ method: 'PATCH',
+ headers: {
+ Authorization: `Bearer ${localCapability}`,
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ action: 'set-editing',
+ expectedRevision,
+ enabled
+ })
+ });
+ const body = await response.json().catch(() => ({}));
+ if (destroyed || localEpoch !== epoch || capability !== localCapability) return false;
+
+ if (response.status === 409) {
+ loading = false;
+ const reloaded = await refresh();
+ if (reloaded && !destroyed && localEpoch === epoch) {
+ lastNotice = 'current state reloaded';
+ render();
+ } else if (!lastError) {
+ lastError = typeof body.error === 'string' ? body.error : 'Exercise changed. Refresh and retry.';
+ render();
+ }
+ return false;
+ }
+
+ if (!response.ok) {
+ lastError = response.status === 401 || response.status === 404
+ ? 'Exercise access is no longer available.'
+ : typeof body.error === 'string' && body.error
+ ? body.error
+ : 'Could not update Student editing.';
+ return false;
+ }
+
+ applyPayload(body);
+ lastNotice = enabled ? 'Student editing allowed' : 'Student editing frozen';
+ return true;
+ } catch {
+ if (destroyed || localEpoch !== epoch) return false;
+ lastError = 'Could not update Student editing.';
+ return false;
+ } finally {
+ if (!destroyed && localEpoch === epoch) {
+ loading = false;
+ render();
+ }
+ }
+ };
+
const connectInstructor = async token => {
if (!validCapability(token) || destroyed) return false;
epoch += 1;
@@ -749,6 +949,9 @@ export function createInstructorExerciseConsoleController({
const handleStart = () => { void mutateLifecycle('start'); };
const handlePause = () => { void mutateLifecycle('pause'); };
const handleResume = () => { void mutateLifecycle('resume'); };
+ const handleBeginDebrief = () => { void mutateLifecycle('begin-debrief'); };
+ const handleFreeze = () => { void setEditing(false); };
+ const handleAllowEditing = () => { void setEditing(true); };
element('instructorExerciseRefreshBtn')?.addEventListener('click', handleRefresh);
element('instructorExerciseCaseSelect')?.addEventListener('change', handleSelect);
@@ -756,6 +959,9 @@ export function createInstructorExerciseConsoleController({
element('instructorExerciseStartBtn')?.addEventListener('click', handleStart);
element('instructorExercisePauseBtn')?.addEventListener('click', handlePause);
element('instructorExerciseResumeBtn')?.addEventListener('click', handleResume);
+ element('instructorExerciseBeginDebriefBtn')?.addEventListener('click', handleBeginDebrief);
+ element('instructorExerciseFreezeBtn')?.addEventListener('click', handleFreeze);
+ element('instructorExerciseAllowEditingBtn')?.addEventListener('click', handleAllowEditing);
render();
return {
@@ -766,6 +972,8 @@ export function createInstructorExerciseConsoleController({
start: () => mutateLifecycle('start'),
pause: () => mutateLifecycle('pause'),
resume: () => mutateLifecycle('resume'),
+ beginDebrief: () => mutateLifecycle('begin-debrief'),
+ setEditing,
releaseContent,
getState: () => ({
connected: Boolean(capability),
@@ -778,7 +986,8 @@ export function createInstructorExerciseConsoleController({
caseStudy: caseStudy ? sanitizeCaseStudySummary(caseStudy) : null,
availableCaseStudies: availableCaseStudies.map(item => ({ ...item })),
releases: releases.map(item => ({ ...item })),
- workspaceState: workspaceState.map(item => ({ ...item }))
+ workspaceState: workspaceState.map(item => ({ ...item })),
+ checkpoints: checkpoints.map(item => ({ ...item }))
}),
destroy: () => {
if (destroyed) return;
@@ -788,6 +997,9 @@ export function createInstructorExerciseConsoleController({
element('instructorExerciseStartBtn')?.removeEventListener('click', handleStart);
element('instructorExercisePauseBtn')?.removeEventListener('click', handlePause);
element('instructorExerciseResumeBtn')?.removeEventListener('click', handleResume);
+ element('instructorExerciseBeginDebriefBtn')?.removeEventListener('click', handleBeginDebrief);
+ element('instructorExerciseFreezeBtn')?.removeEventListener('click', handleFreeze);
+ element('instructorExerciseAllowEditingBtn')?.removeEventListener('click', handleAllowEditing);
disconnect();
destroyed = true;
}
From c013b1984f4170ed254d47d1a64757185dc8390c Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 20:10:55 -0400
Subject: [PATCH 119/206] Add synthetic debrief checkpoints and editing policy
---
scripts/serve-browser-tests.mjs | 65 +++++++++++++++++++++++++++++----
1 file changed, 58 insertions(+), 7 deletions(-)
diff --git a/scripts/serve-browser-tests.mjs b/scripts/serve-browser-tests.mjs
index be153af..205246b 100644
--- a/scripts/serve-browser-tests.mjs
+++ b/scripts/serve-browser-tests.mjs
@@ -107,6 +107,7 @@ const classroomWorkspaces = new Map();
const classroomCoachingFeedback = new Map();
const classroomExercises = new Map();
const classroomExerciseReleases = new Map();
+const classroomExerciseCheckpoints = new Map();
const CONTENT_TYPES = Object.freeze({
'.css': 'text/css; charset=utf-8',
@@ -384,6 +385,24 @@ function browserExerciseWorkspaceState(token, exercise) {
});
}
+function captureBrowserExerciseCheckpoints(token, exercise) {
+ if (!exercise?.currentStageId) return [];
+ const fixture = managedInstructorFixture(token);
+ const workspaces = fixture?.workspaces || instructorRoster().workspaces;
+ return workspaces.map(workspace => {
+ const observed = fixture?.workspaceStates?.get(workspace.id);
+ return {
+ workspaceId: workspace.id,
+ workspaceKind: workspace.kind,
+ workspaceLabel: workspace.label,
+ stageId: exercise.currentStageId,
+ workspaceRevision: observed?.revision || 1,
+ snapshot: structuredClone(observed?.snapshot || freshClassroomSnapshot()),
+ capturedAt: '2099-12-31T23:35:00.000Z'
+ };
+ });
+}
+
function managedInstructorObservation(token, workspaceId) {
const fixture = managedInstructorFixture(token);
if (!fixture) return null;
@@ -675,6 +694,7 @@ async function handleClassroomApi(request, response, url) {
integratedInstructorParticipants = [];
classroomExercises.delete(INTEGRATED_INSTRUCTOR_TOKEN);
classroomExerciseReleases.delete(INTEGRATED_INSTRUCTOR_TOKEN);
+ classroomExerciseCheckpoints.delete(INTEGRATED_INSTRUCTOR_TOKEN);
sendJson(response, 201, {
class: integratedInstructorClass(),
instructorToken: INTEGRATED_INSTRUCTOR_TOKEN,
@@ -694,6 +714,7 @@ async function handleClassroomApi(request, response, url) {
liveInstructorWorkspaceStates.clear();
classroomExercises.delete(LIVE_INSTRUCTOR_TOKEN);
classroomExerciseReleases.delete(LIVE_INSTRUCTOR_TOKEN);
+ classroomExerciseCheckpoints.delete(LIVE_INSTRUCTOR_TOKEN);
liveInstructorParticipants = [{
id: LIVE_PARTICIPANT_ID,
displayName: 'Waiting Student',
@@ -730,6 +751,9 @@ async function handleClassroomApi(request, response, url) {
const exerciseProgress = exercise => structuredClone(
browserExerciseWorkspaceState(instructorToken, exercise)
);
+ const exerciseCheckpoints = () => structuredClone(
+ classroomExerciseCheckpoints.get(instructorToken) || []
+ );
if (request.method === 'GET') {
const exercise = classroomExercises.get(instructorToken) || null;
@@ -740,7 +764,7 @@ async function handleClassroomApi(request, response, url) {
caseStudy: structuredClone(BROWSER_STAGED_CASE),
releases: exerciseReleases(),
workspaceState: exerciseProgress(exercise),
- checkpoints: [],
+ checkpoints: exerciseCheckpoints(),
editFreezeEnforced: true,
availableCaseStudies
}
@@ -775,7 +799,7 @@ async function handleClassroomApi(request, response, url) {
caseStudy: structuredClone(BROWSER_STAGED_CASE),
releases: exerciseReleases(),
workspaceState: exerciseProgress(existing),
- checkpoints: [],
+ checkpoints: exerciseCheckpoints(),
editFreezeEnforced: true,
created: false
});
@@ -795,13 +819,14 @@ async function handleClassroomApi(request, response, url) {
};
classroomExercises.set(instructorToken, exercise);
classroomExerciseReleases.set(instructorToken, []);
+ classroomExerciseCheckpoints.set(instructorToken, []);
sendJson(response, 201, {
class: classContext,
exercise: structuredClone(exercise),
caseStudy: structuredClone(BROWSER_STAGED_CASE),
releases: exerciseReleases(),
workspaceState: exerciseProgress(exercise),
- checkpoints: [],
+ checkpoints: exerciseCheckpoints(),
editFreezeEnforced: true,
created: true
});
@@ -840,7 +865,7 @@ async function handleClassroomApi(request, response, url) {
caseStudy: structuredClone(BROWSER_STAGED_CASE),
releases: exerciseReleases(),
workspaceState: exerciseProgress(exercise),
- checkpoints: [],
+ checkpoints: exerciseCheckpoints(),
editFreezeEnforced: true,
changed: false
});
@@ -869,7 +894,7 @@ async function handleClassroomApi(request, response, url) {
caseStudy: structuredClone(BROWSER_STAGED_CASE),
releases: exerciseReleases(),
workspaceState: exerciseProgress(exercise),
- checkpoints: [],
+ checkpoints: exerciseCheckpoints(),
editFreezeEnforced: true,
changed: true
});
@@ -884,6 +909,7 @@ async function handleClassroomApi(request, response, url) {
return true;
}
+ let capturedCount;
if (body.action === 'start') {
if (exercise.status !== 'draft' || exercise.currentStageId !== null) {
sendJson(response, 409, {
@@ -914,6 +940,30 @@ async function handleClassroomApi(request, response, url) {
return true;
}
exercise.status = 'active';
+ } else if (body.action === 'begin-debrief') {
+ if (exercise.status !== 'active' || exercise.stagePhase !== 'work' || !exercise.currentStageId) {
+ sendJson(response, 409, {
+ error: 'Exercise is not ready to enter debrief.',
+ exercise: structuredClone(exercise)
+ });
+ return true;
+ }
+ const stage = BROWSER_STAGED_CASE.simulation.stages.find(item => item.id === exercise.currentStageId);
+ const checkpoints = captureBrowserExerciseCheckpoints(instructorToken, exercise);
+ classroomExerciseCheckpoints.set(instructorToken, checkpoints);
+ capturedCount = checkpoints.length;
+ exercise.stagePhase = 'debrief';
+ exercise.studentEditingEnabled = stage?.defaultDebriefEditPolicy !== 'frozen';
+ } else if (body.action === 'set-editing') {
+ if (
+ exercise.status !== 'active'
+ || exercise.stagePhase !== 'debrief'
+ || typeof body.enabled !== 'boolean'
+ ) {
+ sendJson(response, 400, { error: 'Invalid debrief editing policy.' });
+ return true;
+ }
+ exercise.studentEditingEnabled = body.enabled;
} else {
sendJson(response, 400, { error: 'Invalid exercise action.' });
return true;
@@ -927,9 +977,10 @@ async function handleClassroomApi(request, response, url) {
caseStudy: structuredClone(BROWSER_STAGED_CASE),
releases: exerciseReleases(),
workspaceState: exerciseProgress(exercise),
- checkpoints: [],
+ checkpoints: exerciseCheckpoints(),
editFreezeEnforced: true,
- changed: true
+ changed: true,
+ ...(Number.isInteger(capturedCount) ? { capturedCount } : {})
});
return true;
}
From a89df3d28660080638bce9a2002254c6f1ac35e9 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 20:11:30 -0400
Subject: [PATCH 120/206] Test debrief checkpoints and editing policy
---
...assroomExerciseInstructor.feature.test.mjs | 216 ++++++++++++++++++
1 file changed, 216 insertions(+)
diff --git a/tests/classroomExerciseInstructor.feature.test.mjs b/tests/classroomExerciseInstructor.feature.test.mjs
index d366138..24d7c5d 100644
--- a/tests/classroomExerciseInstructor.feature.test.mjs
+++ b/tests/classroomExerciseInstructor.feature.test.mjs
@@ -564,6 +564,222 @@ test('Instructor evidence-release conflict refreshes authoritative released stat
controller.destroy();
});
+test('Instructor begins debrief, renders immutable checkpoint revisions, and controls Student editing explicitly', async () => {
+ dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' });
+ const requests = [];
+ let exerciseState = {
+ id: 'exercise-1',
+ caseStudyId: 'staged-a',
+ status: 'active',
+ stagePhase: 'work',
+ studentEditingEnabled: true,
+ exerciseRevision: 4,
+ currentStageId: 'stage-1'
+ };
+ let checkpoints = [];
+ const caseStudy = {
+ id: 'staged-a',
+ name: 'Staged Case A',
+ description: 'Instructor simulation.',
+ supportedModes: ['full'],
+ simulation: {
+ studentContent: [],
+ instructorContent: [],
+ stages: [{
+ id: 'stage-1',
+ title: 'Clarify',
+ studentObjective: 'Clarify the problem.',
+ suggestedMinutes: 5,
+ optionalReleaseIds: [],
+ instructorContentIds: [],
+ defaultDebriefEditPolicy: 'frozen'
+ }]
+ }
+ };
+ const body = () => ({
+ class: { id: 'class-1', title: 'PSDM Class' },
+ exercise: { ...exerciseState },
+ caseStudy,
+ releases: [],
+ workspaceState: [{
+ workspaceId: 'workspace-a',
+ workspaceKind: 'group',
+ workspaceLabel: 'Team Alpha',
+ stageId: 'stage-1',
+ readyForDebrief: true,
+ readyWorkspaceRevision: 7
+ }],
+ checkpoints: checkpoints.map(item => ({ ...item })),
+ editFreezeEnforced: true,
+ availableCaseStudies: discoveryBody().availableCaseStudies
+ });
+
+ const controller = createInstructorExerciseConsoleController({
+ documentRef: dom.window.document,
+ fetchImpl: async (_url, options) => {
+ requests.push({
+ method: options.method,
+ body: options.body ? JSON.parse(options.body) : null
+ });
+ if (options.method === 'GET') return response(200, body());
+ if (options.method === 'PATCH') {
+ const payload = JSON.parse(options.body);
+ if (payload.action === 'begin-debrief') {
+ assert.deepEqual(payload, { action: 'begin-debrief', expectedRevision: 4 });
+ exerciseState = {
+ ...exerciseState,
+ stagePhase: 'debrief',
+ studentEditingEnabled: false,
+ exerciseRevision: 5
+ };
+ checkpoints = [{
+ workspaceId: 'workspace-a',
+ workspaceKind: 'group',
+ workspaceLabel: 'Team Alpha',
+ stageId: 'stage-1',
+ workspaceRevision: 7,
+ snapshot: { pre: { oneLine: 'Captured but not exposed by controller state.' } },
+ capturedAt: 'captured'
+ }];
+ return response(200, { ...body(), changed: true, capturedCount: 1 });
+ }
+ if (payload.action === 'set-editing') {
+ assert.equal(payload.expectedRevision, exerciseState.exerciseRevision);
+ exerciseState = {
+ ...exerciseState,
+ studentEditingEnabled: payload.enabled,
+ exerciseRevision: exerciseState.exerciseRevision + 1
+ };
+ return response(200, { ...body(), changed: true });
+ }
+ return response(400, {});
+ }
+ return response(405, {});
+ }
+ });
+
+ assert.equal(await controller.connectInstructor(TOKEN), true);
+ assert.equal(dom.window.document.getElementById('instructorExerciseBeginDebriefBtn').hidden, false);
+
+ assert.equal(await controller.beginDebrief(), true);
+ assert.equal(dom.window.document.getElementById('instructorExerciseStatus').textContent, 'Debrief · debrief started');
+ assert.equal(dom.window.document.getElementById('instructorExerciseDebriefPanel').hidden, false);
+ assert.equal(
+ dom.window.document.getElementById('instructorExerciseEditingStatus').textContent,
+ 'Student editing frozen during debrief'
+ );
+ assert.equal(dom.window.document.getElementById('instructorExerciseCheckpointSummary').textContent, '1 captured');
+ assert.equal(dom.window.document.getElementById('instructorExerciseCheckpointList').textContent.includes('Team Alpha'), true);
+ assert.equal(dom.window.document.getElementById('instructorExerciseCheckpointList').textContent.includes('Revision 7'), true);
+ assert.equal(JSON.stringify(controller.getState()).includes('Captured but not exposed'), false);
+ assert.equal(dom.window.document.getElementById('instructorExerciseAllowEditingBtn').hidden, false);
+ assert.equal(dom.window.document.getElementById('instructorExerciseFreezeBtn').hidden, true);
+
+ assert.equal(await controller.setEditing(true), true);
+ assert.deepEqual(requests.at(-1), {
+ method: 'PATCH',
+ body: { action: 'set-editing', expectedRevision: 5, enabled: true }
+ });
+ assert.equal(
+ dom.window.document.getElementById('instructorExerciseEditingStatus').textContent,
+ 'Student editing allowed during debrief'
+ );
+ assert.equal(dom.window.document.getElementById('instructorExerciseFreezeBtn').hidden, false);
+
+ assert.equal(await controller.setEditing(false), true);
+ assert.deepEqual(requests.at(-1), {
+ method: 'PATCH',
+ body: { action: 'set-editing', expectedRevision: 6, enabled: false }
+ });
+ assert.equal(controller.getState().exercise.exerciseRevision, 7);
+ assert.equal(controller.getState().exercise.studentEditingEnabled, false);
+ assert.equal(controller.getState().checkpoints[0].workspaceRevision, 7);
+ controller.destroy();
+});
+
+test('Instructor debrief editing conflict refreshes authoritative policy without replaying stale intent', async () => {
+ dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' });
+ const requests = [];
+ let getCount = 0;
+ const caseStudy = {
+ id: 'staged-a',
+ name: 'Staged Case A',
+ description: 'Instructor simulation.',
+ supportedModes: ['full'],
+ simulation: {
+ studentContent: [],
+ instructorContent: [],
+ stages: [{
+ id: 'stage-1',
+ title: 'Clarify',
+ studentObjective: 'Clarify the problem.',
+ suggestedMinutes: 5,
+ optionalReleaseIds: [],
+ instructorContentIds: [],
+ defaultDebriefEditPolicy: 'frozen'
+ }]
+ }
+ };
+ const payload = ({ revision, enabled }) => ({
+ class: { id: 'class-1', title: 'PSDM Class' },
+ exercise: {
+ id: 'exercise-1',
+ caseStudyId: 'staged-a',
+ status: 'active',
+ stagePhase: 'debrief',
+ studentEditingEnabled: enabled,
+ exerciseRevision: revision,
+ currentStageId: 'stage-1'
+ },
+ caseStudy,
+ releases: [],
+ workspaceState: [],
+ checkpoints: [{
+ workspaceId: 'workspace-a',
+ workspaceKind: 'group',
+ workspaceLabel: 'Team Alpha',
+ stageId: 'stage-1',
+ workspaceRevision: 7,
+ snapshot: { pre: { oneLine: 'Immutable checkpoint' } },
+ capturedAt: 'captured'
+ }],
+ editFreezeEnforced: true,
+ availableCaseStudies: discoveryBody().availableCaseStudies
+ });
+
+ const controller = createInstructorExerciseConsoleController({
+ documentRef: dom.window.document,
+ fetchImpl: async (_url, options) => {
+ requests.push(options.method);
+ if (options.method === 'GET') {
+ getCount += 1;
+ return response(200, getCount === 1
+ ? payload({ revision: 5, enabled: false })
+ : payload({ revision: 6, enabled: true }));
+ }
+ if (options.method === 'PATCH') {
+ return response(409, {
+ error: 'Exercise changed. Refresh and retry.',
+ exercise: payload({ revision: 6, enabled: true }).exercise
+ });
+ }
+ return response(405, {});
+ }
+ });
+
+ await controller.connectInstructor(TOKEN);
+ assert.equal(await controller.setEditing(true), false);
+ assert.deepEqual(requests, ['GET', 'PATCH', 'GET']);
+ assert.equal(controller.getState().exercise.exerciseRevision, 6);
+ assert.equal(controller.getState().exercise.studentEditingEnabled, true);
+ assert.equal(dom.window.document.getElementById('instructorExerciseStatus').textContent, 'Debrief · current state reloaded');
+ assert.equal(
+ dom.window.document.getElementById('instructorExerciseEditingStatus').textContent,
+ 'Student editing allowed during debrief'
+ );
+ controller.destroy();
+});
+
test('Instructor exercise console keeps a connected shell on transient read failure and can refresh', async () => {
dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' });
let attempt = 0;
From d71009fafaaada9350189398a372830f0b056711 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 20:11:44 -0400
Subject: [PATCH 121/206] Test mobile class rail collapse
---
tests/classroomInstructor.feature.test.mjs | 47 +++++++++++++++++++++-
1 file changed, 46 insertions(+), 1 deletion(-)
diff --git a/tests/classroomInstructor.feature.test.mjs b/tests/classroomInstructor.feature.test.mjs
index 630f6d5..e579ce0 100644
--- a/tests/classroomInstructor.feature.test.mjs
+++ b/tests/classroomInstructor.feature.test.mjs
@@ -88,8 +88,22 @@ function observeBody(id, statement) {
};
}
-function setup(fetchImpl, { onObservation = () => {}, onObservationEnd = () => {}, onClassConnected = () => {}, onClassDisconnected = () => {} } = {}) {
+function setup(fetchImpl, {
+ onObservation = () => {},
+ onObservationEnd = () => {},
+ onClassConnected = () => {},
+ onClassDisconnected = () => {},
+ mobile = false
+} = {}) {
dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' });
+ if (mobile) {
+ dom.window.matchMedia = query => ({
+ matches: query === '(max-width: 700px)',
+ media: query,
+ addEventListener: () => {},
+ removeEventListener: () => {}
+ });
+ }
persistExperienceRolePreference(EXPERIENCE_ROLE_IDS.INSTRUCTOR, dom.window.localStorage);
initExperienceRoleController({
documentRef: dom.window.document,
@@ -149,6 +163,37 @@ afterEach(() => {
dom = null;
});
+test('Instructor class rail defaults collapsed on mobile and remains explicitly expandable', async () => {
+ const env = setup(async (url) => {
+ if (url === '/api/classes/workspaces') return response(200, rosterBody());
+ if (url === '/api/classes/participants') return response(200, participantBody());
+ if (url.includes(W1)) return response(200, observeBody(W1, 'First'));
+ return response(404, {});
+ }, { mobile: true });
+
+ await env.controller.openClass(TOKEN);
+
+ const dashboard = dom.window.document.getElementById('instructorClassDashboard');
+ const toggle = dom.window.document.getElementById('instructorClassPanelToggle');
+ assert.equal(env.controller.getState().railExpanded, false);
+ assert.equal(dashboard.classList.contains('is-collapsed'), true);
+ assert.equal(dom.window.document.body.classList.contains('instructor-rail-collapsed'), true);
+ assert.equal(toggle.getAttribute('aria-expanded'), 'false');
+ assert.equal(toggle.textContent, 'Open class panel');
+
+ toggle.click();
+ assert.equal(env.controller.getState().railExpanded, true);
+ assert.equal(dashboard.classList.contains('is-collapsed'), false);
+ assert.equal(dom.window.document.body.classList.contains('instructor-rail-collapsed'), false);
+ assert.equal(toggle.getAttribute('aria-expanded'), 'true');
+ assert.equal(toggle.textContent, 'Collapse class panel');
+
+ toggle.click();
+ assert.equal(env.controller.getState().railExpanded, false);
+ assert.equal(dashboard.classList.contains('is-collapsed'), true);
+ env.controller.destroy();
+});
+
test('Instructor opens one class, renders roster, and observes through the GET-only class endpoint', async () => {
const requests = [];
const env = setup(async (url, options) => {
From 9abcd778a767ff73532e9f68351d8236a37941ed Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 20:12:05 -0400
Subject: [PATCH 122/206] Cover debrief controls and mobile rail
---
tests/e2e/instructor.spec.mjs | 26 ++++++++++++++++++++++++--
1 file changed, 24 insertions(+), 2 deletions(-)
diff --git a/tests/e2e/instructor.spec.mjs b/tests/e2e/instructor.spec.mjs
index 9a3f8a9..748f8a3 100644
--- a/tests/e2e/instructor.spec.mjs
+++ b/tests/e2e/instructor.spec.mjs
@@ -126,10 +126,23 @@ test('Instructor starts a live class, creates a team, assigns a waiting Student,
await page.getByRole('button', { name: 'Observe Team Alpha, Ready' }).click();
await expect(page.locator('#instructorObservedWorkspace')).toHaveText('Team Alpha');
+ await page.getByRole('button', { name: 'Begin debrief' }).click();
+ await expect(page.locator('#instructorExerciseStatus')).toHaveText('Debrief · debrief started');
+ await expect(page.locator('#instructorExerciseEditingStatus')).toHaveText('Student editing frozen during debrief');
+ await expect(page.locator('#instructorExerciseCheckpointSummary')).toHaveText('1 captured');
+ await expect(page.locator('#instructorExerciseCheckpointList')).toContainText('Team Alpha');
+ await expect(page.locator('#instructorExerciseCheckpointList')).toContainText('Revision 1');
+ await expect(page.locator('#instructorObservedWorkspace')).toHaveText('Team Alpha');
+
+ await page.getByRole('button', { name: 'Allow editing' }).click();
+ await expect(page.locator('#instructorExerciseEditingStatus')).toHaveText('Student editing allowed during debrief');
+ await page.getByRole('button', { name: 'Freeze editing' }).click();
+ await expect(page.locator('#instructorExerciseEditingStatus')).toHaveText('Student editing frozen during debrief');
+
expect(classroomRequests.some(request => request.method === 'POST' && request.pathname === '/api/classes')).toBe(true);
expect(classroomRequests.some(request => request.method === 'POST' && request.pathname === '/api/classes/workspaces')).toBe(true);
expect(classroomRequests.some(request => request.method === 'POST' && request.pathname === '/api/classes/exercise')).toBe(true);
- expect(classroomRequests.filter(request => request.method === 'PATCH' && request.pathname === '/api/classes/exercise')).toHaveLength(4);
+ expect(classroomRequests.filter(request => request.method === 'PATCH' && request.pathname === '/api/classes/exercise')).toHaveLength(7);
expect(classroomRequests.some(request => request.method === 'PATCH' && request.pathname === '/api/classes/participants')).toBe(true);
expect(classroomRequests.some(request => request.pathname === '/api/classes/join')).toBe(false);
expect(classroomRequests.some(request => request.body.includes('assignmentToken'))).toBe(false);
@@ -143,13 +156,16 @@ test('Instructor starts a live class, creates a team, assigns a waiting Student,
await expect(page.locator('#instructorJoinCode')).toHaveText('K7FM-P4Q2');
await expect(page.locator('#instructorParticipantSummary')).toHaveText('1 student · 0 waiting');
await expect(page.getByLabel('Assignment for Waiting Student')).not.toHaveValue('');
- await expect(page.locator('#instructorExerciseStatus')).toHaveText('In progress');
+ await expect(page.locator('#instructorExerciseStatus')).toHaveText('Debrief');
await expect(page.locator('#instructorExerciseCaseName')).toHaveText('Browser Staged Simulation');
await expect(page.getByLabel('Staged Case Study')).toHaveValue('browser-staged-simulation');
await expect(page.locator('#instructorExerciseCreateBtn')).toBeDisabled();
await expect(page.locator('#instructorExerciseStageTitle')).toHaveText('Clarify the browser case');
await expect(page.locator('#instructorExerciseReleaseList')).toContainText('Released');
await expect(page.locator('#instructorExerciseProgressSummary')).toHaveText('1 ready · 0 working');
+ await expect(page.locator('#instructorExerciseEditingStatus')).toHaveText('Student editing frozen during debrief');
+ await expect(page.locator('#instructorExerciseCheckpointList')).toContainText('Team Alpha');
+ await expect(page.locator('#instructorExerciseCheckpointList')).toContainText('Revision 1');
await expect(page.locator('#instructorExercisePauseBtn')).toBeVisible();
await expectNoBlockingA11yViolations(page);
@@ -181,6 +197,12 @@ test('Instructor opens a class, observes work read-only, switches workspaces, an
await expect(page.locator('#instructorClassDashboard')).toBeVisible();
await expect(page.locator('#instructorClassTitle')).toHaveText('Browser Test Classroom');
+ if (testInfo.project.name === 'chromium-mobile') {
+ await expect(page.locator('#instructorClassDashboard')).toHaveClass(/is-collapsed/);
+ await expect(page.getByRole('button', { name: 'Open class panel' })).toBeVisible();
+ await page.getByRole('button', { name: 'Open class panel' }).click();
+ await expect(page.locator('#instructorClassDashboard')).not.toHaveClass(/is-collapsed/);
+ }
await expect(page.locator('#instructorRosterSummary')).toHaveText('2 workspaces');
await expect(page.locator('#instructorObservedWorkspace')).toHaveText('Alex Student');
await expect(page.locator('#instructorObserverStatus')).toHaveText('Live read-only view');
From 61aa582e9faee9a7e500796634c5cc06e256ff14 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 20:16:55 -0400
Subject: [PATCH 123/206] Open collapsed class rail in mobile coaching journey
---
tests/e2e/coaching.spec.mjs | 3 +++
1 file changed, 3 insertions(+)
diff --git a/tests/e2e/coaching.spec.mjs b/tests/e2e/coaching.spec.mjs
index cfef1d0..714aa53 100644
--- a/tests/e2e/coaching.spec.mjs
+++ b/tests/e2e/coaching.spec.mjs
@@ -57,6 +57,9 @@ test('Instructor feedback reaches only the assigned Student and becomes stale af
await expect(page.locator('#instructorObservedWorkspace')).toHaveText('Alex Student');
if (suffix === 'm') {
+ await expect(page.locator('#instructorClassDashboard')).toHaveClass(/is-collapsed/);
+ await page.getByRole('button', { name: 'Open class panel' }).click();
+ await expect(page.locator('#instructorClassDashboard')).not.toHaveClass(/is-collapsed/);
await page.locator('[data-workspace-id="22222222-2222-4222-8222-222222222222"]').click();
await expect(page.locator('#instructorObservedWorkspace')).toHaveText('Team Beta');
}
From 41c2bdd8fd34257b474d01ccedc8820a80ba7246 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 20:21:34 -0400
Subject: [PATCH 124/206] Document Tranche 6E checkpoint
---
docs/classroom-staged-simulation.md | 47 +++++++++++++++++++++++------
docs/classroom-workstream.md | 13 +++++++-
docs/commenting-guide.md | 4 +--
src/AGENTS.md | 4 ++-
4 files changed, 54 insertions(+), 14 deletions(-)
diff --git a/docs/classroom-staged-simulation.md b/docs/classroom-staged-simulation.md
index 7cab03d..3821f27 100644
--- a/docs/classroom-staged-simulation.md
+++ b/docs/classroom-staged-simulation.md
@@ -809,17 +809,44 @@ Validation on `2c8145b31f96403f59ff3b41d9396fd2a3ac86ed`:
- required Browser E2E: **26 passed / 8 intentional project-scoped skips / 0 failed**;
- CI, CodeQL, Dependency Review, and Template Manifest Guard: green.
+## Implementation checkpoint — Tranche 6E Begin Debrief, checkpoint review, and editing policy
+
+Debrief/edit-policy contract:
+- **Begin debrief** is exposed only for an active exercise in current-stage work and PATCHes `{ action: "begin-debrief", expectedRevision }`;
+- successful debrief entry consumes the server response after immutable checkpoint capture and renders only checkpoint workspace identity + captured workspace revision for the current stage;
+- checkpoint snapshot bodies remain server-owned evidence: the browser controller sanitizes them away from public state and does not create an editable or persisted checkpoint copy;
+- the console presents `studentEditingEnabled` independently from Pause/Resume so class pacing and Student write authorization remain visibly separate concepts;
+- active debrief exposes **Freeze editing** when editing is allowed and **Allow editing** when editing is frozen; both use revision-safe `set-editing`;
+- idempotent local policy requests do not issue unnecessary PATCHes, while a 409 on a real mutation performs one authoritative GET and never replays stale intent;
+- the server-enforced HTTP 423 `classroom-editing-locked` boundary from Tranche 5 remains the actual authorization control; the Instructor toggle is not treated as client-side security;
+- existing observer/coaching remains available through debrief, including while Student editing is frozen;
+- real-browser acceptance proves work -> debrief checkpoint capture -> default frozen policy -> allow -> freeze -> reload with checkpoint/release/progress state restored.
+
+Instructor workspace/mobile UX added in the same bounded slice:
+- the existing class/team rail has an explicit accessible Collapse/Open control;
+- desktop collapse reclaims horizontal Intake space;
+- at <=700px the rail defaults collapsed to a compact class header instead of occupying the mobile page, but remains discoverable and can be reopened normally;
+- tests use the real Open class panel control before mobile team switching; no forced-click workaround is used;
+- broader Notes/mobile chrome alignment remains a follow-on: prefer compact discoverable launchers/default-collapsed behavior rather than making either workspace silently disappear.
+
+Implementation spans `044e4f82...` through `61aa582...`.
+
+Validation on `61aa582e9faee9a7e500796634c5cc06e256ff14`:
+- repository quality: **288 tests / 287 pass / 0 fail / 1 intentional skip**;
+- required Browser E2E: **26 passed / 8 intentional project-scoped skips / 0 failed**;
+- CI, CodeQL, Dependency Review, and Template Manifest Guard: green.
+
## Exact next implementation action
-Implement **Tranche 6E — Begin Debrief, checkpoint review, and Freeze/Allow Editing**.
+Implement **Tranche 6F — Advance/Complete and Instructor-console lifecycle completion**.
Required next slice:
-- expose Begin Debrief only for an active current-stage work phase and send the current `exerciseRevision`;
-- after Begin Debrief, render authoritative current-stage checkpoint capture status/revision for represented workspaces without replacing live Intake;
-- render the authoritative `studentEditingEnabled` policy separately from Pause state;
-- expose explicit **Freeze editing** / **Allow editing** only in active current-stage debrief and PATCH revision-safe `set-editing`;
-- preserve the server-enforced 423 boundary already implemented in Tranche 5;
-- every 409 lifecycle/edit-policy conflict must authoritative-refresh rather than replay stale intent;
-- extend focused and synthetic-browser coverage for work -> debrief checkpoint capture -> freeze/unfreeze while observer/coaching remains available.
-
-Do **not** add Advance or Complete in 6E; reserve those for Tranche 6F. Keep Student staged-case UI and production official Case Study staging deferred.
+- expose **Advance** only in active debrief when a next authored stage exists;
+- expose **Complete exercise** only in active debrief on the final authored stage;
+- send the current `exerciseRevision` for both actions and authoritative-refresh on 409 without replay;
+- after Advance, render the next authored stage in work phase and authoritative Student editing restored for work;
+- after Complete, render a terminal completed state without implying that Instructor-only/exemplar material becomes Student-visible;
+- keep observer/coaching usable across stage transition and completion where the existing Classroom session remains valid;
+- extend the synthetic staged fixture to at least two stages so browser acceptance proves Stage 1 debrief -> Advance -> Stage 2 work -> Stage 2 debrief -> Complete.
+
+Keep Student staged-case reference/UI in Tranche 7. Do not invent production official Case Study staging.
diff --git a/docs/classroom-workstream.md b/docs/classroom-workstream.md
index 4568221..e13022d 100644
--- a/docs/classroom-workstream.md
+++ b/docs/classroom-workstream.md
@@ -485,7 +485,18 @@ Continue #295 on draft PR #307 from the current branch head.
- deterministic browser coverage uses only synthetic optional evidence/readiness and proves release, persisted release after reload, Ready progress, and one-click observer navigation;
- the prior four-browser failure was traced to nested scrolling in the expanded Instructor rail, not product authorization; the rail layout now lets the outer dashboard own scrolling so workspace buttons remain genuinely clickable without forced test clicks;
- validation on `2c8145b...`: repository quality **285 tests / 284 pass / 0 fail / 1 intentional skip**; required Browser E2E **26 passed / 8 intentional skips / 0 failed**; CI, CodeQL, Dependency Review, and Template Manifest Guard green.
-- Exact next action: implement **Tranche 6E — Begin Debrief, checkpoint review, and Freeze/Allow Editing**. Add revision-safe Begin Debrief, render captured immutable current-stage checkpoint status/revision per workspace, expose explicit Freeze / Allow Editing only during debrief with authoritative `studentEditingEnabled` state, and preserve 409 authoritative-refresh/no-replay behavior. Keep Advance/Complete deferred to 6F; keep Student staged-case UI and production official-case staging deferred.
+- Tranche 6E — Begin Debrief, checkpoint review, Freeze/Allow Editing, and compact Instructor rail:
+ - implementation spans `044e4f82...` through green implementation head `61aa582...`;
+ - **Begin debrief** is exposed only for active current-stage work and PATCHes the current `exerciseRevision`; 409 conflicts authoritative-refresh and never replay stale intent;
+ - entering debrief renders the server-captured immutable checkpoint status per current-stage workspace as workspace label + captured collaboration revision; checkpoint snapshot bodies remain server-side/in-memory and are deliberately omitted from controller public state and the checkpoint UI;
+ - debrief shows the authoritative `studentEditingEnabled` state separately from Pause/Resume and exposes exactly one applicable **Freeze editing** or **Allow editing** action;
+ - `set-editing` is debrief-only and revision-safe; conflicts refresh without replay, while the existing HTTP 423 `classroom-editing-locked` write guard remains the actual Student enforcement boundary;
+ - deterministic browser acceptance proves work -> Begin Debrief -> immutable checkpoint capture -> stage-default freeze -> Allow Editing -> Freeze Editing -> reload while the existing observer remains active;
+ - the connected Instructor class/team rail now has explicit collapse/expand controls. At <=700px it defaults to a compact collapsed class header rather than consuming the full mobile viewport; the Instructor can reopen it normally, and desktop collapse also reclaims horizontal Intake space;
+ - mobile coaching/observer acceptance now explicitly reopens the compact class rail before switching teams, proving the collapsed state remains usable rather than relying on hidden/forced clicks;
+ - validation on `61aa582e9faee9a7e500796634c5cc06e256ff14`: repository quality **288 tests / 287 pass / 0 fail / 1 intentional skip**; required Browser E2E **26 passed / 8 intentional skips / 0 failed**; CI, CodeQL, Dependency Review, and Template Manifest Guard green.
+- Mobile workspace UX follow-on: align the Notes workspace and Instructor class rail around the same compact mobile principle. Prefer an always-discoverable collapsed header/launcher over fully hiding either workspace; evaluate making Notes default-collapsed on narrow screens and reducing its collapsed chrome so it does not obstruct primary Intake work.
+- Exact next action: implement **Tranche 6F — Advance/Complete and Instructor-console lifecycle completion**. Add revision-safe Advance from non-final debrief, revision-safe Complete from final-stage debrief, authoritative stage transition rendering/edit-policy reset, conflict refresh/no replay, and deterministic browser coverage through the end of the synthetic exercise. Keep Student staged-case UI in Tranche 7 and production official-case staging deferred.
## Approved Classroom follow-on direction
diff --git a/docs/commenting-guide.md b/docs/commenting-guide.md
index d44e354..c3e19d4 100644
--- a/docs/commenting-guide.md
+++ b/docs/commenting-guide.md
@@ -48,8 +48,8 @@ Anchors keep HTML and documentation in sync. Preserve existing tokens and regist
| `[feature:experience-role-switch]` | `index.html`, View menu | Reopens the experience chooser without resetting Intake data. | `src/experienceRoleController.js`, `src/menuBar.js` | `experienceRoleMenuBtn`, `data-experience-role-label`; local-only, summary-excluded. |
| `[feature:student-class-entry]` | `index.html`, between role chooser and Instructor shell | Student class join/reconnect gate shown before the assigned Intake is available. | `src/classroomStudent.js`, `src/experienceRoleController.js` | `studentClassEntryShell`, `studentClassJoinForm`, class/assignment/name inputs, retry controls; all local-only/summary-excluded. |
| `[feature:student-experience-notice]` | `index.html`, top of the connected Student Intake | Class/workspace/participant context plus explicit Leave class. | `src/classroomStudent.js`, `src/experienceRoleController.js` | `studentExperienceNotice`, `studentClassContextTitle`, `studentClassWorkspace`, `studentClassIdentity`, `studentClassLeaveBtn`; no persisted Intake fields. |
-| `[feature:instructor-shell]` | `index.html`, between header and Intake wrap | Instructor class entry/resume and connected class workspace rail. | `src/classroomInstructor.js`, `src/experienceRoleController.js` | `instructorExperienceShell`, `instructorClassForm`, `instructorClassDashboard`, roster search/filter/list, Leave class; all class/session controls are local-only and summary-excluded. |
-| `[feature:instructor-exercise-console]` | `index.html`, inside the connected Instructor dashboard | Instructor staged-exercise discovery, draft setup, lifecycle pacing, current-stage facilitation/evidence release, and team progress within the existing class rail. | `src/classroomExerciseInstructor.js` | `instructorExerciseConsole`, staged selector/Create draft, Start/Pause/Resume, current stage/objective/timing/facilitation, optional evidence release, Ready/Working progress rows, existing observer navigation, availability list, Refresh; all exercise context is memory-only and summary-excluded. |
+| `[feature:instructor-shell]` | `index.html`, between header and Intake wrap | Instructor class entry/resume and connected class workspace rail, including compact collapse/expand behavior. | `src/classroomInstructor.js`, `src/experienceRoleController.js` | `instructorExperienceShell`, `instructorClassForm`, `instructorClassDashboard`, `instructorClassPanelToggle`, roster search/filter/list, Leave class; narrow screens default collapsed but retain an explicit Open class panel control; all class/session controls are local-only and summary-excluded. |
+| `[feature:instructor-exercise-console]` | `index.html`, inside the connected Instructor dashboard | Instructor staged-exercise discovery, pacing, evidence release, debrief/editing policy, checkpoint status, and team progress within the existing class rail. | `src/classroomExerciseInstructor.js` | `instructorExerciseConsole`, staged selector/Create draft, Start/Pause/Resume/Begin debrief, current stage/objective/timing/facilitation, optional evidence release, Ready/Working rows, checkpoint workspace/revision status, Freeze/Allow Editing, existing observer navigation, availability list, Refresh; checkpoint snapshots are not projected into public controller state and all exercise context is memory-only/summary-excluded. |
| `[feature:instructor-observer]` | `index.html`, top of the temporarily revealed Intake wrap | Live read-only context for the selected Student/team workspace, including revision and recent activity. Dynamic coaching panels are appended beside registered targets inside this same observed Intake; they do not introduce a new structural HTML anchor. | `src/classroomInstructor.js`, `src/classroomCoaching.js`, `src/coachableFields.js` | `instructorObservationNotice`, observer metadata, plus `.classroom-coaching--instructor` / `.classroom-coaching--student` dynamic local-only panels. Observed Student state and coaching UI are excluded from Intake persistence/summary capture. |
| `[feature:intake-mode-selector]` | `index.html` header title | Wraps the accessible intake mode selector used to choose between General, IT, Pharma, and Major Incident Management lenses before capture. | `src/intakeModes.js`, `src/intakeModeController.js` | `intakeModeSelect`; options use stable mode values and persist under `meta.intakeMode` in `kt-intake-full-v2`. |
| `[feature:major-incident-role-legend]` | `index.html`, below the header | Provides the Major Incident-only four-phase legend mount; phase/primary-role badges decorate mapped workflow headings (including Decision and Risk gates) and every KT row, then hide without unmounting data when the mode changes. Icons and colour swatches are decorative because visible and accessible role text carries their meaning. | `src/majorIncidentRoles.js`, `src/intakeModes.js` | `majorIncidentRoleLegend`, `majorIncidentRoleLegendItems`; role presentation is not persisted, while the decorated controls retain their normal field contracts. |
diff --git a/src/AGENTS.md b/src/AGENTS.md
index 046d9c0..d1ccdec 100644
--- a/src/AGENTS.md
+++ b/src/AGENTS.md
@@ -17,7 +17,9 @@ Keep feature modules independently initialisable, own only their feature-specifi
## Protected Classroom resources
-`classroomExerciseInstructor.js` owns the Instructor staged-exercise console client. It receives the Instructor capability only through the class lifecycle, keeps that credential and exercise payload in memory, and reads/writes `/api/classes/exercise`; draft selection/create may POST only an explicitly discovered staged `caseStudyId`. Start/Pause/Resume and new current-stage `release-content` PATCH actions must send the currently observed `exerciseRevision`; a 409 must reload authoritative server state rather than replay stale intent. Release UI may render only the current stage's explicit `optionalReleaseIds`, never future-stage Student content. Team readiness/progress must come from the Instructor-authorized `workspaceState` payload and reuse the existing Instructor `selectWorkspace()` observer/coaching path. Current-stage Instructor facilitation may render only in Instructor experience. Exercise state, staged definitions, releases, readiness, and capabilities must never enter Intake persistence, summaries, exports, URLs, or public manifests.
+`classroomExerciseInstructor.js` owns the Instructor staged-exercise console client. It receives the Instructor capability only through the class lifecycle, keeps that credential and exercise payload in memory, and reads/writes `/api/classes/exercise`; draft selection/create may POST only an explicitly discovered staged `caseStudyId`. Start/Pause/Resume, current-stage `release-content`, Begin Debrief, and debrief-only `set-editing` mutations must send the currently observed `exerciseRevision`; a 409 must reload authoritative server state rather than replay stale intent. Release UI may render only the current stage's explicit `optionalReleaseIds`, never future-stage Student content. Team readiness/progress must come from the Instructor-authorized `workspaceState` payload and reuse the existing Instructor `selectWorkspace()` observer/coaching path. Debrief checkpoint UI may expose capture status, workspace identity, and captured workspace revision, but must not expose checkpoint snapshots through controller public state or create an editable checkpoint copy. `studentEditingEnabled` is authoritative presentation only; the server-side 423 collaboration write guard remains the actual freeze boundary. Current-stage Instructor facilitation may render only in Instructor experience. Exercise state, staged definitions, releases, readiness, checkpoints, and capabilities must never enter Intake persistence, summaries, exports, URLs, or public manifests.
+
+`classroomInstructor.js` owns the connected Instructor class rail. Its collapse/expand state is local presentation state, not Intake/session authority. Narrow screens default to the compact collapsed class header, but the class controls must remain explicitly discoverable through an accessible Open/Collapse control; do not hide the Instructor workspace surface on mobile without an equivalent launcher.
`classroomCaseStudies.js` owns the authorized in-memory protected Case Study catalog/payload client. It receives active capabilities only through Student/Instructor lifecycle callbacks, keeps no localStorage/sessionStorage record, and clears catalog/context when the class role disconnects. Its public state must never expose a bearer capability.
From e855df09dd049417910bf05b3211485fcc9b559a Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Tue, 6 Oct 2026 20:37:06 -0400
Subject: [PATCH 125/206] Add Advance and Complete exercise controls
---
index.html | 2 ++
1 file changed, 2 insertions(+)
diff --git a/index.html b/index.html
index 2898541..bd2f28f 100644
--- a/index.html
+++ b/index.html
@@ -439,6 +439,8 @@
Class
+
+
Pause controls class pacing only. Student editing freeze is a separate debrief control.