From bd4b2f25f0859af394d9c1d14ec9f1ff6ba6912e Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:04:10 -0400
Subject: [PATCH 01/47] Add startup hub context and explicit routing
---
src/startupExperienceHub.js | 440 ++++++++++++++++++++++++++++++++++++
1 file changed, 440 insertions(+)
create mode 100644 src/startupExperienceHub.js
diff --git a/src/startupExperienceHub.js b/src/startupExperienceHub.js
new file mode 100644
index 0000000..ea9c6ae
--- /dev/null
+++ b/src/startupExperienceHub.js
@@ -0,0 +1,440 @@
+/**
+ * @module startupExperienceHub
+ * @description Detects resumable local contexts and owns explicit startup intent.
+ *
+ * The hub never serializes navigation state into Intake. It reads current
+ * canonical persistence envelopes, renders independent Continue choices, and
+ * delegates actual Student/Instructor resume to their existing lifecycle
+ * controllers by applying the corresponding experience role.
+ */
+
+import { APP_STATE_VERSION } from './appStateVersion.js';
+import {
+ clearInstructorSession,
+ isInstructorSessionExpired,
+ readInstructorSession
+} from './classroomInstructor.js';
+import {
+ clearStudentSession,
+ isStudentSessionExpired,
+ readStudentSession
+} from './classroomStudent.js';
+import { readClassroomJoinIntent } from './classroomJoinLink.js';
+import {
+ applyExperienceRole,
+ closeExperienceRoleChooser,
+ openExperienceRoleChooser,
+ resetExperienceRoleSelection
+} from './experienceRoleController.js';
+import { EXPERIENCE_ROLE_IDS } from './experienceRoles.js';
+import { DEFAULT_INTAKE_MODE } from './intakeModes.js';
+import { STORAGE_KEY, migrateAppState } from './storage.js';
+
+const BASELINE_STATE = migrateAppState({ meta: { version: APP_STATE_VERSION } });
+
+let documentRef = null;
+let windowRef = null;
+let storageRef = null;
+let locationRef = null;
+let startFreshRef = () => {};
+let context = null;
+
+function nonEmpty(value) {
+ return typeof value === 'string' && value.trim().length > 0;
+}
+
+function anyNonEmpty(values) {
+ return values.some(nonEmpty);
+}
+
+function meaningfulTable(table) {
+ return Array.isArray(table) && table.some(row => (
+ row
+ && !row.band
+ && anyNonEmpty([row.is, row.no, row.di, row.ch])
+ ));
+}
+
+function meaningfulCauses(causes) {
+ return Array.isArray(causes) && causes.some(cause => {
+ if (!cause || typeof cause !== 'object') return false;
+ if (anyNonEmpty([
+ cause.suspect,
+ cause.accusation,
+ cause.impact,
+ cause.summaryText,
+ cause.confidence,
+ cause.evidence
+ ])) return true;
+ return Object.values(cause.findings || {}).some(finding => (
+ finding
+ && typeof finding === 'object'
+ && anyNonEmpty([finding.mode, finding.note])
+ ));
+ });
+}
+
+function meaningfulSteps(steps) {
+ return Array.isArray(steps?.items) && steps.items.some(item => item?.checked === true);
+}
+
+function meaningfulHandover(handover) {
+ if (!handover || typeof handover !== 'object') return false;
+ return Object.values(handover).some(value => (
+ Array.isArray(value)
+ ? value.some(nonEmpty)
+ : nonEmpty(value)
+ ));
+}
+
+function stable(value) {
+ try {
+ return JSON.stringify(value ?? null);
+ } catch {
+ return '';
+ }
+}
+
+function meaningfulMajorAnalysis(snapshot) {
+ if (!BASELINE_STATE) return false;
+ return stable(snapshot?.decisionAnalysis) !== stable(BASELINE_STATE.decisionAnalysis)
+ || stable(snapshot?.potentialProblemAnalysis) !== stable(BASELINE_STATE.potentialProblemAnalysis);
+}
+
+/**
+ * Determine whether a normalized current-version Intake contains user work.
+ *
+ * Timestamp/presentation-only noise is deliberately ignored: savedAt, bridge
+ * activation time, next communication time, table focus mode, theme, Notes
+ * open/collapse state, action analysis ID, table labels, and unchecked steps.
+ *
+ * @param {unknown} candidate Current-version normalized or raw snapshot.
+ * @returns {boolean} Whether the snapshot is worth offering as "Continue".
+ */
+export function isSubstantiveSavedIntake(candidate) {
+ const snapshot = migrateAppState(candidate);
+ if (!snapshot) return false;
+
+ if (snapshot.meta?.intakeMode && snapshot.meta.intakeMode !== DEFAULT_INTAKE_MODE) return true;
+ if (anyNonEmpty(Object.values(snapshot.pre || {}))) return true;
+ if (anyNonEmpty(Object.values(snapshot.impact || {}))) return true;
+
+ const ops = snapshot.ops || {};
+ if (anyNonEmpty([
+ ops.icName,
+ ops.bcName,
+ ops.semOpsName,
+ ops.severity,
+ ops.containStatus,
+ ops.containDesc,
+ ops.commCadence
+ ])) return true;
+ if ([
+ ops.detectMonitoring,
+ ops.detectUserReport,
+ ops.detectAutomation,
+ ops.detectOther,
+ ops.evScreenshot,
+ ops.evLogs,
+ ops.evMetrics,
+ ops.evRepro,
+ ops.evOther
+ ].some(Boolean)) return true;
+ if (Array.isArray(ops.commLog) && ops.commLog.length > 0) return true;
+
+ if (meaningfulTable(snapshot.table)) return true;
+ if (meaningfulCauses(snapshot.causes)) return true;
+ if (nonEmpty(snapshot.likelyCauseId)) return true;
+ if (meaningfulSteps(snapshot.steps)) return true;
+ if (Array.isArray(snapshot.actions?.items) && snapshot.actions.items.length > 0) return true;
+ if (meaningfulHandover(snapshot.handover)) return true;
+ if (Array.isArray(snapshot.notesWorkspace?.notes) && snapshot.notesWorkspace.notes.some(note => nonEmpty(note?.text))) {
+ return true;
+ }
+ return meaningfulMajorAnalysis(snapshot);
+}
+
+/**
+ * Read a current, substantive saved Intake without mutating storage.
+ *
+ * @param {Storage|null} storage Browser storage.
+ * @returns {object|null} Normalized substantive snapshot or null.
+ */
+export function readSubstantiveSavedIntake(storage = globalThis.localStorage) {
+ if (!storage) return null;
+ try {
+ const raw = JSON.parse(storage.getItem(STORAGE_KEY) || 'null');
+ const normalized = migrateAppState(raw);
+ return normalized && isSubstantiveSavedIntake(normalized) ? normalized : null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Detect independent resumable contexts for the startup hub.
+ *
+ * @param {object} [options] Detection dependencies.
+ * @param {Storage|null} [options.storage=localStorage] Local storage.
+ * @param {Location|null} [options.location=location] Browser location.
+ * @param {number} [options.nowMs=Date.now()] Current epoch.
+ * @returns {object} Startup context.
+ */
+export function detectStartupContext({
+ storage = globalThis.localStorage,
+ location = globalThis.location,
+ nowMs = Date.now()
+} = {}) {
+ const savedIntake = readSubstantiveSavedIntake(storage);
+ const student = readStudentSession(storage);
+ const instructor = readInstructorSession(storage);
+ const studentResumable = Boolean(student && !isStudentSessionExpired(student, nowMs));
+ const instructorResumable = Boolean(instructor && !isInstructorSessionExpired(instructor, nowMs));
+
+ return {
+ savedIntake,
+ student: studentResumable ? student : null,
+ instructor: instructorResumable ? instructor : null,
+ hasStoredStudentSession: Boolean(student),
+ hasStoredInstructorSession: Boolean(instructor),
+ studentSessionExpired: Boolean(student && !studentResumable),
+ instructorSessionExpired: Boolean(instructor && !instructorResumable),
+ joinIntent: readClassroomJoinIntent(location) || ''
+ };
+}
+
+function formatSavedAt(value) {
+ const stamp = Date.parse(value || '');
+ if (!Number.isFinite(stamp)) return '';
+ try {
+ return new Date(stamp).toLocaleString();
+ } catch {
+ return '';
+ }
+}
+
+function element(id) {
+ return documentRef?.getElementById(id) || null;
+}
+
+function buildResumeButton({ kind, title, description, meta }) {
+ const button = documentRef.createElement('button');
+ button.type = 'button';
+ button.className = 'startup-resume-card';
+ button.dataset.startupResume = kind;
+ button.setAttribute('data-persistence', 'local-only');
+ button.setAttribute('data-summary', 'exclude');
+
+ const copy = documentRef.createElement('span');
+ copy.className = 'startup-resume-card__copy';
+ const heading = documentRef.createElement('strong');
+ heading.textContent = title;
+ const detail = documentRef.createElement('span');
+ detail.textContent = description;
+ copy.append(heading, detail);
+
+ if (meta) {
+ const small = documentRef.createElement('small');
+ small.textContent = meta;
+ copy.append(small);
+ }
+
+ const action = documentRef.createElement('span');
+ action.className = 'startup-resume-card__action';
+ action.textContent = 'Continue';
+ button.append(copy, action);
+ return button;
+}
+
+function renderContinueCards() {
+ const section = element('startupContinueSection');
+ const list = element('startupContinueList');
+ if (!section || !list || !context) return;
+
+ list.replaceChildren();
+
+ if (context.savedIntake) {
+ const problem = context.savedIntake.pre?.oneLine?.trim();
+ const savedAt = formatSavedAt(context.savedIntake.meta?.savedAt);
+ list.append(buildResumeButton({
+ kind: 'intake',
+ title: 'Continue your saved Intake',
+ description: problem || 'Return to your saved independent Intake.',
+ meta: savedAt ? `Saved ${savedAt}` : ''
+ }));
+ }
+
+ if (context.student) {
+ const assignment = context.student.assignment?.label;
+ list.append(buildResumeButton({
+ kind: 'student',
+ title: `Rejoin ${context.student.class.title}`,
+ description: `Continue as ${context.student.participant.displayName}.`,
+ meta: assignment ? `Assigned to ${assignment}` : 'Waiting for assignment'
+ }));
+ }
+
+ if (context.instructor) {
+ list.append(buildResumeButton({
+ kind: 'instructor',
+ title: `Continue managing ${context.instructor.class.title}`,
+ description: 'Resume the Instructor workspace on this device.',
+ meta: context.instructor.joinCode ? `Class code ${context.instructor.joinCode}` : ''
+ }));
+ }
+
+ section.hidden = list.childElementCount === 0;
+}
+
+function renderJoinIntent() {
+ const notice = element('startupJoinIntentNotice');
+ const studentButton = documentRef?.querySelector('[data-experience-role-choice="student"]');
+ const code = context?.joinIntent || '';
+ if (notice) {
+ notice.hidden = !code;
+ notice.textContent = code
+ ? `Class link detected · ${code.slice(0, 4)}-${code.slice(4)}. Choose Join a class to continue.`
+ : '';
+ }
+ studentButton?.classList?.toggle('is-recommended', Boolean(code));
+ studentButton?.toggleAttribute?.('data-join-intent', Boolean(code));
+}
+
+function setHubStatus(message = '', state = '') {
+ const status = element('startupHubStatus');
+ if (!status) return;
+ status.textContent = message;
+ status.dataset.state = state;
+ status.hidden = !message;
+}
+
+function render() {
+ context = detectStartupContext({
+ storage: storageRef,
+ location: locationRef,
+ nowMs: Date.now()
+ });
+ renderContinueCards();
+ renderJoinIntent();
+ return context;
+}
+
+function confirmReplace(message) {
+ return typeof windowRef?.confirm === 'function' ? windowRef.confirm(message) : true;
+}
+
+function chooseRole(role) {
+ const applied = applyExperienceRole(role);
+ if (!applied) return false;
+ closeExperienceRoleChooser({ force: true });
+ setHubStatus('');
+ return true;
+}
+
+function chooseFreshStandalone() {
+ if (context?.savedIntake) {
+ const proceed = confirmReplace(
+ 'Start a new independent Intake? Your currently saved Intake will be cleared. Save it to a file first if you need a portable copy.'
+ );
+ if (!proceed) return true;
+ startFreshRef();
+ }
+ return chooseRole(EXPERIENCE_ROLE_IDS.STANDALONE);
+}
+
+function chooseNewStudent() {
+ const stored = readStudentSession(storageRef);
+ if (stored) {
+ const proceed = isStudentSessionExpired(stored)
+ || confirmReplace(
+ `Join a different class? This device will stop resuming “${stored.class.title}” as ${stored.participant.displayName}.`
+ );
+ if (!proceed) return true;
+ clearStudentSession(storageRef);
+ }
+ return chooseRole(EXPERIENCE_ROLE_IDS.STUDENT);
+}
+
+function chooseNewInstructor() {
+ const stored = readInstructorSession(storageRef);
+ if (stored) {
+ const proceed = isInstructorSessionExpired(stored)
+ || confirmReplace(
+ `Run a different class? This device will stop automatically resuming “${stored.class.title}”. Administration / Maintenance can reissue Instructor access later if needed.`
+ );
+ if (!proceed) return true;
+ clearInstructorSession(storageRef);
+ }
+ return chooseRole(EXPERIENCE_ROLE_IDS.INSTRUCTOR);
+}
+
+/**
+ * Handle one new-session role intent from the shared role controls.
+ *
+ * @param {string} role Canonical experience role.
+ * @returns {boolean} True because the hub owns all supported role intents.
+ */
+function handleRoleIntent(role) {
+ render();
+ if (role === EXPERIENCE_ROLE_IDS.STANDALONE) return chooseFreshStandalone();
+ if (role === EXPERIENCE_ROLE_IDS.STUDENT) return chooseNewStudent();
+ if (role === EXPERIENCE_ROLE_IDS.INSTRUCTOR) return chooseNewInstructor();
+ return false;
+}
+
+function handleResumeClick(event) {
+ const button = event.target?.closest?.('[data-startup-resume]');
+ if (!button) return;
+ const kind = button.dataset.startupResume;
+ if (kind === 'intake') {
+ chooseRole(EXPERIENCE_ROLE_IDS.STANDALONE);
+ } else if (kind === 'student') {
+ chooseRole(EXPERIENCE_ROLE_IDS.STUDENT);
+ } else if (kind === 'instructor') {
+ chooseRole(EXPERIENCE_ROLE_IDS.INSTRUCTOR);
+ }
+}
+
+/**
+ * Reopen the required startup hub after a saved server-backed resume becomes invalid.
+ *
+ * @param {string} message Human-facing recovery explanation.
+ * @returns {void}
+ */
+function showResumeFailure(message) {
+ resetExperienceRoleSelection();
+ render();
+ setHubStatus(message || 'That saved session is no longer available. Choose what you want to do next.', 'error');
+ openExperienceRoleChooser({ required: true });
+}
+
+/**
+ * Initialize startup-hub detection and intent handling.
+ *
+ * @param {object} [options] Dependencies.
+ * @returns {object} Startup hub controller.
+ */
+export function initStartupExperienceHub({
+ documentRef: nextDocument = globalThis.document,
+ windowRef: nextWindow = globalThis.window,
+ storage = globalThis.localStorage,
+ location = nextWindow?.location || globalThis.location,
+ startFresh = () => {}
+} = {}) {
+ documentRef = nextDocument;
+ windowRef = nextWindow;
+ storageRef = storage;
+ locationRef = location;
+ startFreshRef = typeof startFresh === 'function' ? startFresh : () => {};
+ context = null;
+
+ element('startupContinueList')?.addEventListener('click', handleResumeClick);
+ render();
+
+ return {
+ refresh: render,
+ handleRoleIntent,
+ showResumeFailure,
+ getContext: () => context
+ };
+}
From 5550f2ca06d4d7c46731c54f20010be2a4c7eb38 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:04:31 -0400
Subject: [PATCH 02/47] Route ordinary startup through explicit hub
---
src/experienceRoleController.js | 49 +++++++++++++++++++--------------
1 file changed, 28 insertions(+), 21 deletions(-)
diff --git a/src/experienceRoleController.js b/src/experienceRoleController.js
index 66242cc..5ffa2f4 100644
--- a/src/experienceRoleController.js
+++ b/src/experienceRoleController.js
@@ -14,7 +14,6 @@ import {
isExperienceSurfaceVisible,
normalizeExperienceRole
} from './experienceRoles.js';
-import { readClassroomJoinIntent } from './classroomJoinLink.js';
/** Dedicated local-only role preference, never part of serialized Intake state. */
export const EXPERIENCE_ROLE_STORAGE_KEY = 'kt-experience-role-v1';
@@ -30,6 +29,8 @@ let storageRef = null;
let locationRef = null;
let chooserRequired = false;
let chooserReturnFocus = null;
+let roleIntentHandler = null;
+let chooserOpenHandler = null;
/**
* Safely resolve the default browser localStorage object.
@@ -215,6 +216,22 @@ export function applyExperienceRole(role, { persist = true, announce = true } =
return normalized;
}
+/**
+ * Clear the currently projected experience without deleting any saved resume data.
+ *
+ * This is used by the startup hub when navigation intent must become explicit
+ * again after launch or after a server-backed resume becomes invalid.
+ *
+ * @returns {void}
+ */
+export function resetExperienceRoleSelection() {
+ activeExperienceRole = null;
+ if (!documentRef?.body) return;
+ documentRef.body.dataset.experienceRole = 'unselected';
+ renderRoleLabels(null);
+ renderRoleSurfaces(null);
+}
+
/**
* Return all focusable controls inside the role chooser.
*
@@ -291,6 +308,7 @@ export function openExperienceRoleChooser({ required = false, returnFocus = null
chooserRequired = required;
chooserReturnFocus = returnFocus || documentRef?.activeElement || null;
+ chooserOpenHandler?.({ required });
gate.hidden = false;
gate.setAttribute('aria-hidden', 'false');
documentRef.body.classList.add('experience-role-gate-open');
@@ -379,6 +397,9 @@ function bindRoleControls() {
button.dataset.experienceRoleBound = 'true';
button.addEventListener('click', () => {
const role = button.getAttribute('data-experience-role-choice');
+ if (typeof roleIntentHandler === 'function' && roleIntentHandler(role) === true) {
+ return;
+ }
if (applyExperienceRole(role)) {
closeExperienceRoleChooser({ force: true });
}
@@ -428,7 +449,9 @@ export function initExperienceRoleController({
documentRef: nextDocument = typeof document !== 'undefined' ? document : null,
windowRef: nextWindow = typeof window !== 'undefined' ? window : null,
storage = defaultStorage(),
- location = nextWindow?.location || null
+ location = nextWindow?.location || null,
+ onRoleIntent = null,
+ onChooserOpen = null
} = {}) {
documentRef = nextDocument;
windowRef = nextWindow;
@@ -436,6 +459,8 @@ export function initExperienceRoleController({
locationRef = location;
chooserRequired = false;
chooserReturnFocus = null;
+ roleIntentHandler = typeof onRoleIntent === 'function' ? onRoleIntent : null;
+ chooserOpenHandler = typeof onChooserOpen === 'function' ? onChooserOpen : null;
if (!documentRef?.body) {
return null;
@@ -452,25 +477,7 @@ export function initExperienceRoleController({
return collaborationRole;
}
- if (readClassroomJoinIntent(locationRef)) {
- const studentRole = applyExperienceRole(EXPERIENCE_ROLE_IDS.STUDENT, {
- persist: true,
- announce: false
- });
- closeExperienceRoleChooser({ force: true });
- return studentRole;
- }
-
- const storedRole = readExperienceRolePreference(storageRef);
- if (storedRole) {
- applyExperienceRole(storedRole, { persist: false, announce: false });
- closeExperienceRoleChooser({ force: true });
- return storedRole;
- }
-
- activeExperienceRole = null;
- documentRef.body.dataset.experienceRole = 'unselected';
- renderRoleLabels(null);
+ resetExperienceRoleSelection();
openExperienceRoleChooser({ required: true });
return null;
}
From a9f30f8c1fe46c2d5b48b37e7a2a03622f385c1c Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:04:49 -0400
Subject: [PATCH 03/47] Turn experience chooser into startup hub
---
index.html | 31 +++++++++++++++++++++++--------
1 file changed, 23 insertions(+), 8 deletions(-)
diff --git a/index.html b/index.html
index 7f7916f..3d58658 100644
--- a/index.html
+++ b/index.html
@@ -255,32 +255,47 @@
+
+
+
+ Continue
+
Continue where you left off
+
+
+
+
+
+ Start / Join
+
Start or join something else
+
+
+
+
Inspect and clean server-side class or collaboration data with a separate Admin access key.
From ff84ef01e2798d654baa0846ff72844fddf10c2d Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:05:09 -0400
Subject: [PATCH 04/47] Use Run a class product language
---
src/experienceRoles.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/experienceRoles.js b/src/experienceRoles.js
index 554dd1a..674cd4b 100644
--- a/src/experienceRoles.js
+++ b/src/experienceRoles.js
@@ -34,8 +34,8 @@ export const EXPERIENCE_ROLES = Object.freeze([
Object.freeze({
id: EXPERIENCE_ROLE_IDS.INSTRUCTOR,
label: 'Instructor',
- actionLabel: 'Teach a class',
- description: 'Open the instructor workspace for class supervision and coaching.'
+ actionLabel: 'Run a class',
+ description: 'Start a new Instructor-led class and manage participants, teams, exercises, and coaching.'
})
]);
From a565758192c5289b37e51c596e02deca9e85b6a2 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:05:15 -0400
Subject: [PATCH 05/47] Focus startup resume before new-session choices
---
src/experienceRoleController.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/experienceRoleController.js b/src/experienceRoleController.js
index 5ffa2f4..bd6df86 100644
--- a/src/experienceRoleController.js
+++ b/src/experienceRoleController.js
@@ -281,7 +281,7 @@ function getRoleSwitcherFocusOrigin(button) {
* @returns {void}
*/
function focusFirstChoice() {
- const target = documentRef?.querySelector('[data-experience-role-choice]');
+ const target = documentRef?.querySelector('[data-startup-resume], [data-experience-role-choice]');
if (!target || typeof target.focus !== 'function') {
return;
}
From 38e738109fefd3a9031b65040947c6879a2b2536 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:05:21 -0400
Subject: [PATCH 06/47] Return invalid Student resumes to startup hub
---
src/classroomStudent.js | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/src/classroomStudent.js b/src/classroomStudent.js
index bd7eb09..e81c821 100644
--- a/src/classroomStudent.js
+++ b/src/classroomStudent.js
@@ -178,6 +178,7 @@ export function createStudentClassroomController({
onClassDisconnected = () => {},
onSessionConnected = () => {},
onSessionDisconnected = () => {},
+ onResumeUnavailable = () => {},
setTimeoutImpl = globalThis.setTimeout?.bind(globalThis),
clearTimeoutImpl = globalThis.clearTimeout?.bind(globalThis),
AbortControllerImpl = globalThis.AbortController
@@ -435,7 +436,9 @@ export function createStudentClassroomController({
activeSession = null;
restoreLocalRecovery();
renderEntry();
- setError(message || 'This class session is no longer available. Ask your instructor for the current class code.');
+ const explanation = message || 'This class session is no longer available. Ask your instructor for the current class code.';
+ setError(explanation);
+ onResumeUnavailable(explanation);
return false;
};
From b92e0522c17e32450fc5531000d5a75ac67287d8 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:05:27 -0400
Subject: [PATCH 07/47] Return invalid Instructor resumes to startup hub
---
src/classroomInstructor.js | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/src/classroomInstructor.js b/src/classroomInstructor.js
index 8fd69a9..79c09eb 100644
--- a/src/classroomInstructor.js
+++ b/src/classroomInstructor.js
@@ -120,6 +120,7 @@ export function createInstructorClassroomController({
onObservationEnd = () => {},
onClassConnected = () => {},
onClassDisconnected = () => {},
+ onResumeUnavailable = () => {},
setTimeoutImpl = globalThis.setTimeout?.bind(globalThis),
clearTimeoutImpl = globalThis.clearTimeout?.bind(globalThis),
AbortControllerImpl = globalThis.AbortController
@@ -731,7 +732,9 @@ export function createInstructorClassroomController({
selectedWorkspaceId = '';
restoreLocal();
renderEntry();
- setError(message || 'This instructor class is no longer available.');
+ const explanation = message || 'This instructor class is no longer available.';
+ setError(explanation);
+ onResumeUnavailable(explanation);
return false;
};
From c7ff6457d238e1d0385e7f3b493fa522d2533f91 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:05:41 -0400
Subject: [PATCH 08/47] Wire startup hub into boot and resume recovery
---
main.js | 20 +++++++++++++++++++-
1 file changed, 19 insertions(+), 1 deletion(-)
diff --git a/main.js b/main.js
index 5ee8307..a6cf313 100644
--- a/main.js
+++ b/main.js
@@ -68,6 +68,7 @@ import { initClassroomCaseStudies } from './src/classroomCaseStudies.js';
import { initInstructorExerciseConsole } from './src/classroomExerciseInstructor.js';
import { initStudentExerciseReference } from './src/classroomExerciseStudent.js';
import { initAdminMaintenance } from './src/adminMaintenance.js';
+import { initStartupExperienceHub } from './src/startupExperienceHub.js';
/** Active shared-session controller, initialized during boot. @type {object|null} */
let collaborationController = null;
@@ -78,6 +79,7 @@ let classroomDebriefComparisonController = null;
let classroomCaseStudiesController = null;
let instructorExerciseConsoleController = null;
let studentExerciseReferenceController = null;
+let startupExperienceHubController = null;
/** Destroys collaboration resources during application or test teardown. @returns {void} */
export function destroyCollaboration() {
@@ -195,7 +197,17 @@ function boot() {
window.showToast = showToast;
initThemeFromStorage();
- initExperienceRoleController();
+ startupExperienceHubController = initStartupExperienceHub({
+ documentRef: document,
+ windowRef: window,
+ storage: window.localStorage,
+ location: window.location,
+ startFresh
+ });
+ initExperienceRoleController({
+ onRoleIntent: role => startupExperienceHubController?.handleRoleIntent?.(role) === true,
+ onChooserOpen: () => startupExperienceHubController?.refresh?.()
+ });
initAdminMaintenance({ documentRef: document, windowRef: window, toast: showToast });
configureKT({
@@ -281,6 +293,9 @@ function boot() {
},
onSessionDisconnected: () => {
studentExerciseReferenceController?.disconnect?.();
+ },
+ onResumeUnavailable: message => {
+ startupExperienceHubController?.showResumeFailure?.(message);
}
});
instructorClassroomController = initInstructorClassroom({
@@ -299,6 +314,9 @@ function boot() {
classroomCaseStudiesController?.disconnect?.();
instructorExerciseConsoleController?.disconnect?.();
classroomDebriefComparisonController?.disconnect?.();
+ },
+ onResumeUnavailable: message => {
+ startupExperienceHubController?.showResumeFailure?.(message);
}
});
wireThemeToggle();
From ae49d898ef32778ad2da89dbfea70f6a742839cf Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:05:56 -0400
Subject: [PATCH 09/47] Style responsive startup experience hub
---
styles.css | 20 ++++++++++++++++++++
1 file changed, 20 insertions(+)
diff --git a/styles.css b/styles.css
index f9b4d6a..4d1c9c9 100644
--- a/styles.css
+++ b/styles.css
@@ -328,6 +328,24 @@ body.experience-role-gate-open{overflow:hidden;}
font-weight:800;
}
.experience-role-option__description{margin-top:auto;color:var(--muted);font-size:13px;line-height:1.55;}
+.startup-hub__status{margin:0 0 18px;padding:10px 12px;border:1px solid var(--line);border-radius:12px;background:var(--panel-muted);color:var(--ink);font-size:12px;line-height:1.45;}
+.startup-hub__status[data-state="error"]{border-color:var(--mi-role-red-border);background:var(--mi-role-red-bg);color:var(--mi-role-red);}
+.startup-hub__continue{margin:0 0 22px;padding:18px;border:1px solid color-mix(in srgb,var(--accent) 24%,var(--line));border-radius:18px;background:color-mix(in srgb,var(--accent) 4%,var(--panel));}
+.startup-hub__section-heading{margin-bottom:12px;}
+.startup-hub__section-heading h3{margin:0;color:var(--ink-strong);font-size:17px;letter-spacing:-.01em;}
+.startup-hub__resume-list{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;}
+.startup-resume-card{appearance:none;display:flex;align-items:center;justify-content:space-between;gap:14px;min-width:0;padding:14px;border:1px solid var(--control-border);border-radius:14px;background:var(--control-bg);color:var(--ink);text-align:left;cursor:pointer;transition:transform var(--speed),border-color var(--speed),box-shadow var(--speed),background var(--speed);}
+.startup-resume-card:hover{transform:translateY(-1px);border-color:var(--accent);background:var(--control-hover);box-shadow:var(--shadow);}
+.startup-resume-card:focus-visible{outline:none;border-color:var(--accent);box-shadow:0 0 0 4px var(--focus-ring);}
+.startup-resume-card__copy{display:flex;min-width:0;flex-direction:column;gap:3px;}
+.startup-resume-card__copy strong{overflow:hidden;text-overflow:ellipsis;color:var(--ink-strong);font-size:13px;}
+.startup-resume-card__copy>span{overflow:hidden;text-overflow:ellipsis;color:var(--muted);font-size:11px;line-height:1.4;}
+.startup-resume-card__copy small{color:var(--muted);font-size:9px;line-height:1.35;}
+.startup-resume-card__action{flex:0 0 auto;color:var(--accent-text);font-size:10px;font-weight:850;}
+.startup-hub__new{padding-top:2px;}
+.startup-hub__join-intent{margin:0 0 12px;padding:9px 11px;border:1px solid var(--mi-role-blue-border);border-radius:11px;background:var(--mi-role-blue-bg);color:var(--mi-role-blue);font-size:11px;font-weight:700;line-height:1.4;}
+.experience-role-option.is-recommended{border-color:var(--accent);box-shadow:0 0 0 2px color-mix(in srgb,var(--accent) 18%,transparent);}
+.experience-role-option.is-recommended::after{content:"Class link detected";margin-top:8px;color:var(--accent-text);font-size:10px;font-weight:850;}
.experience-role-dialog__footer{
display:flex;
align-items:center;
@@ -404,6 +422,8 @@ body.admin-maintenance-open{overflow:hidden;}
.admin-maintenance__recovery label{display:block;margin-top:10px;color:var(--ink-strong);font-size:10px;font-weight:800;}
.admin-maintenance__recovery textarea{box-sizing:border-box;width:100%;margin-top:5px;font:11px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;resize:none;}
@media(max-width:760px){
+ .startup-hub__resume-list{grid-template-columns:1fr;}
+ .startup-resume-card{padding:12px;}
.experience-role-admin-entry{align-items:flex-start;flex-direction:column;}
.admin-maintenance-gate{padding:0;place-items:stretch;}
.admin-maintenance{width:100%;max-height:100vh;min-height:100vh;border-radius:0;padding:16px;}
From 541af1e19ed8f16b2fc59bec0f122ef3eb78f943 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:06:18 -0400
Subject: [PATCH 10/47] Test startup hub resume detection
---
tests/startupExperienceHub.unit.test.mjs | 185 +++++++++++++++++++++++
1 file changed, 185 insertions(+)
create mode 100644 tests/startupExperienceHub.unit.test.mjs
diff --git a/tests/startupExperienceHub.unit.test.mjs b/tests/startupExperienceHub.unit.test.mjs
new file mode 100644
index 0000000..9055b54
--- /dev/null
+++ b/tests/startupExperienceHub.unit.test.mjs
@@ -0,0 +1,185 @@
+/**
+ * Startup experience hub detection and routing coverage.
+ */
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+
+import { APP_STATE_VERSION } from '../src/appStateVersion.js';
+import {
+ detectStartupContext,
+ isSubstantiveSavedIntake,
+ readSubstantiveSavedIntake
+} from '../src/startupExperienceHub.js';
+import { STORAGE_KEY } from '../src/storage.js';
+import { STUDENT_SESSION_STORAGE_KEY, STUDENT_SESSION_VERSION } from '../src/classroomStudent.js';
+import { INSTRUCTOR_SESSION_STORAGE_KEY, INSTRUCTOR_SESSION_VERSION } from '../src/classroomInstructor.js';
+
+const STUDENT_TOKEN = 's'.repeat(43);
+const INSTRUCTOR_TOKEN = 'i'.repeat(43);
+
+function storageWith(entries = {}) {
+ const map = new Map(Object.entries(entries));
+ return {
+ getItem(key) { return map.has(key) ? map.get(key) : null; },
+ setItem(key, value) { map.set(key, String(value)); },
+ removeItem(key) { map.delete(key); },
+ clear() { map.clear(); }
+ };
+}
+
+function studentSession(overrides = {}) {
+ return {
+ version: STUDENT_SESSION_VERSION,
+ mode: 'live',
+ class: {
+ id: 'class-a',
+ title: 'PSDM Browser Class',
+ expiresAt: '2099-12-31T23:59:59.000Z'
+ },
+ participant: {
+ id: 'participant-a',
+ displayName: 'Alex'
+ },
+ studentSessionToken: STUDENT_TOKEN,
+ assignmentRevision: 2,
+ assignment: {
+ id: '11111111-1111-4111-8111-111111111111',
+ kind: 'group',
+ label: 'Team Alpha'
+ },
+ ...overrides
+ };
+}
+
+function instructorSession(overrides = {}) {
+ return {
+ version: INSTRUCTOR_SESSION_VERSION,
+ instructorToken: INSTRUCTOR_TOKEN,
+ joinCode: 'K7FM-P4Q2',
+ class: {
+ id: 'class-b',
+ title: 'Instructor Browser Class',
+ expiresAt: '2099-12-31T23:59:59.000Z'
+ },
+ selectedWorkspaceId: '22222222-2222-4222-8222-222222222222',
+ ...overrides
+ };
+}
+
+test('empty/default persistence noise is not a substantive saved Intake', () => {
+ const empty = {
+ meta: {
+ version: APP_STATE_VERSION,
+ savedAt: '2026-10-08T20:00:00.000Z'
+ },
+ ops: {
+ bridgeOpenedUtc: '2026-10-08T20:00:00.000Z',
+ commNextDueIso: '2026-10-08T20:15:00.000Z',
+ commNextUpdateTime: '20:15',
+ tableFocusMode: 'full'
+ },
+ appearance: { theme: 'dark' },
+ notesWorkspace: { notes: [], open: false },
+ steps: {
+ items: [{ id: 'one', label: 'Default step label', checked: false }],
+ drawerOpen: true
+ },
+ table: [
+ { band: 'What' },
+ { questionId: 'what-object', q: 'What object?', is: '', no: '', di: '', ch: '' }
+ ],
+ actions: {
+ analysisId: 'generated-analysis-id',
+ items: []
+ }
+ };
+
+ assert.equal(isSubstantiveSavedIntake(empty), false);
+});
+
+test('meaningful user-authored Intake fields create a Continue candidate', () => {
+ assert.equal(isSubstantiveSavedIntake({
+ meta: { version: APP_STATE_VERSION },
+ pre: { oneLine: 'Checkout latency increased after a routing change.' }
+ }), true);
+
+ assert.equal(isSubstantiveSavedIntake({
+ meta: { version: APP_STATE_VERSION },
+ table: [{ q: 'Where?', is: 'Toronto traffic', no: '', di: '', ch: '', questionId: 'where' }]
+ }), true);
+
+ assert.equal(isSubstantiveSavedIntake({
+ meta: { version: APP_STATE_VERSION },
+ notesWorkspace: { notes: [{ id: 'note-1', text: 'Check gateway logs' }], open: false }
+ }), true);
+
+ assert.equal(isSubstantiveSavedIntake({
+ meta: { version: APP_STATE_VERSION, intakeMode: 'it' }
+ }), true);
+});
+
+test('readSubstantiveSavedIntake accepts only current valid meaningful snapshots', () => {
+ const storage = storageWith({
+ [STORAGE_KEY]: JSON.stringify({
+ meta: { version: APP_STATE_VERSION, savedAt: '2026-10-08T20:00:00.000Z' },
+ pre: { oneLine: 'Saved browser Intake' }
+ })
+ });
+
+ assert.equal(readSubstantiveSavedIntake(storage)?.pre?.oneLine, 'Saved browser Intake');
+
+ storage.setItem(STORAGE_KEY, JSON.stringify({
+ meta: { version: APP_STATE_VERSION - 1 },
+ pre: { oneLine: 'Old snapshot' }
+ }));
+ assert.equal(readSubstantiveSavedIntake(storage), null);
+});
+
+test('startup detection keeps saved Intake, Student, and Instructor resumptions independent', () => {
+ const storage = storageWith({
+ [STORAGE_KEY]: JSON.stringify({
+ meta: { version: APP_STATE_VERSION, savedAt: '2026-10-08T20:00:00.000Z' },
+ pre: { oneLine: 'Independent saved Intake' }
+ }),
+ [STUDENT_SESSION_STORAGE_KEY]: JSON.stringify(studentSession()),
+ [INSTRUCTOR_SESSION_STORAGE_KEY]: JSON.stringify(instructorSession())
+ });
+
+ const context = detectStartupContext({
+ storage,
+ location: { hash: '', search: '' },
+ nowMs: Date.parse('2026-10-08T20:00:00.000Z')
+ });
+
+ assert.equal(context.savedIntake.pre.oneLine, 'Independent saved Intake');
+ assert.equal(context.student.participant.displayName, 'Alex');
+ assert.equal(context.student.class.title, 'PSDM Browser Class');
+ assert.equal(context.instructor.class.title, 'Instructor Browser Class');
+ assert.equal(context.hasStoredStudentSession, true);
+ assert.equal(context.hasStoredInstructorSession, true);
+});
+
+test('expired classroom envelopes are not offered as Continue cards but remain detectable for explicit replacement', () => {
+ const storage = storageWith({
+ [STUDENT_SESSION_STORAGE_KEY]: JSON.stringify(studentSession({
+ class: { id: 'class-a', title: 'Expired Student Class', expiresAt: '2026-10-01T00:00:00.000Z' }
+ })),
+ [INSTRUCTOR_SESSION_STORAGE_KEY]: JSON.stringify(instructorSession({
+ class: { id: 'class-b', title: 'Expired Instructor Class', expiresAt: '2026-10-01T00:00:00.000Z' }
+ }))
+ });
+
+ const context = detectStartupContext({
+ storage,
+ location: { hash: '#join=K7FMP4Q2', search: '' },
+ nowMs: Date.parse('2026-10-08T20:00:00.000Z')
+ });
+
+ assert.equal(context.student, null);
+ assert.equal(context.instructor, null);
+ assert.equal(context.hasStoredStudentSession, true);
+ assert.equal(context.hasStoredInstructorSession, true);
+ assert.equal(context.studentSessionExpired, true);
+ assert.equal(context.instructorSessionExpired, true);
+ assert.equal(context.joinIntent, 'K7FMP4Q2');
+});
From 59001aac8626e6dfc9223acb9fafb359ec48f722 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:06:41 -0400
Subject: [PATCH 11/47] Update experience-role tests for explicit startup
---
.../experienceRoleController.feature.test.mjs | 38 ++++++++++---------
1 file changed, 20 insertions(+), 18 deletions(-)
diff --git a/tests/experienceRoleController.feature.test.mjs b/tests/experienceRoleController.feature.test.mjs
index 5477815..d8349f5 100644
--- a/tests/experienceRoleController.feature.test.mjs
+++ b/tests/experienceRoleController.feature.test.mjs
@@ -118,17 +118,18 @@ test('existing collaboration links silently enter Standalone for backward compat
assert.equal(dom.window.document.getElementById('experienceRoleGate').hidden, true);
});
-test('a valid Classroom join fragment enters Student experience before the first-run chooser', () => {
+test('a valid Classroom join fragment leaves startup intent explicit', () => {
dom.window.close();
mount('https://intake.test/#join=K7FMP4Q2');
dom.window.localStorage.clear();
const role = initialize();
- assert.equal(role, EXPERIENCE_ROLE_IDS.STUDENT);
- assert.equal(dom.window.document.getElementById('experienceRoleGate').hidden, true);
- assert.equal(dom.window.document.getElementById('studentClassEntryShell').hidden, false);
- assert.equal(JSON.parse(dom.window.localStorage.getItem(EXPERIENCE_ROLE_STORAGE_KEY)).role, EXPERIENCE_ROLE_IDS.STUDENT);
+ assert.equal(role, null);
+ assert.equal(dom.window.document.body.dataset.experienceRole, 'unselected');
+ assert.equal(dom.window.document.getElementById('experienceRoleGate').hidden, false);
+ assert.equal(dom.window.document.getElementById('studentClassEntryShell').hidden, true);
+ assert.equal(dom.window.localStorage.getItem(EXPERIENCE_ROLE_STORAGE_KEY), null);
});
test('Standalone collaboration query authority wins over a simultaneous Classroom join fragment', () => {
@@ -155,34 +156,35 @@ test('explicit Standalone collaboration link overrides a stored Instructor prefe
assert.equal(JSON.parse(dom.window.localStorage.getItem(EXPERIENCE_ROLE_STORAGE_KEY)).role, EXPERIENCE_ROLE_IDS.STANDALONE);
});
-test('stored Student role resumes the Intake and shows student context without a class dependency', () => {
+test('stored Student preference does not silently resume the Student experience', () => {
persistExperienceRolePreference(EXPERIENCE_ROLE_IDS.STUDENT, dom.window.localStorage);
const role = initialize();
- assert.equal(role, EXPERIENCE_ROLE_IDS.STUDENT);
- assert.equal(dom.window.document.querySelector('.wrap').hidden, false);
- assert.equal(dom.window.document.getElementById('studentExperienceNotice').hidden, false);
- assert.equal(dom.window.document.getElementById('instructorExperienceShell').hidden, true);
+ assert.equal(role, null);
+ assert.equal(dom.window.document.body.dataset.experienceRole, 'unselected');
+ assert.equal(dom.window.document.getElementById('experienceRoleGate').hidden, false);
+ assert.equal(dom.window.document.querySelector('.wrap').hidden, true);
+ assert.equal(dom.window.document.getElementById('studentExperienceNotice').hidden, true);
+ assert.equal(JSON.parse(dom.window.localStorage.getItem(EXPERIENCE_ROLE_STORAGE_KEY)).role, EXPERIENCE_ROLE_IDS.STUDENT);
});
-test('stored Instructor role resumes the instructor shell and hides editable Intake surfaces', () => {
+test('stored Instructor preference does not silently resume the Instructor shell', () => {
persistExperienceRolePreference(EXPERIENCE_ROLE_IDS.INSTRUCTOR, dom.window.localStorage);
const role = initialize();
- assert.equal(role, EXPERIENCE_ROLE_IDS.INSTRUCTOR);
+ assert.equal(role, null);
+ assert.equal(dom.window.document.body.dataset.experienceRole, 'unselected');
assert.equal(dom.window.document.querySelector('.wrap').hidden, true);
assert.equal(dom.window.document.querySelector('.workspace-dock').hidden, true);
- assert.equal(dom.window.document.getElementById('instructorExperienceShell').hidden, false);
- dom.window.document.querySelectorAll('[data-experience-surface="intake-control"]').forEach((control) => {
- assert.equal(control.hidden, true);
- });
+ assert.equal(dom.window.document.getElementById('instructorExperienceShell').hidden, true);
+ assert.equal(dom.window.document.getElementById('experienceRoleGate').hidden, false);
});
test('role switching can be dismissed with Escape and restores focus when it is not first-run', () => {
- persistExperienceRolePreference(EXPERIENCE_ROLE_IDS.STANDALONE, dom.window.localStorage);
initialize();
+ dom.window.document.querySelector('[data-experience-role-choice="standalone"]').click();
const switcher = dom.window.document.getElementById('experienceRoleMenuBtn');
const viewTrigger = dom.window.document.querySelector('[data-menu-target="viewMenu"]');
@@ -203,8 +205,8 @@ test('role switching can be dismissed with Escape and restores focus when it is
});
test('role changes are announced without writing through the Intake save path', () => {
- persistExperienceRolePreference(EXPERIENCE_ROLE_IDS.STANDALONE, dom.window.localStorage);
initialize();
+ applyExperienceRole(EXPERIENCE_ROLE_IDS.STANDALONE);
const changes = [];
dom.window.addEventListener('intake:experience-role-changed', (event) => changes.push(event.detail.role));
From ffaf173483245713e4bed419d34c091f7e283570 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:07:10 -0400
Subject: [PATCH 12/47] Cover explicit startup hub choices
---
tests/startupExperienceHub.feature.test.mjs | 230 ++++++++++++++++++++
1 file changed, 230 insertions(+)
create mode 100644 tests/startupExperienceHub.feature.test.mjs
diff --git a/tests/startupExperienceHub.feature.test.mjs b/tests/startupExperienceHub.feature.test.mjs
new file mode 100644
index 0000000..6bb613f
--- /dev/null
+++ b/tests/startupExperienceHub.feature.test.mjs
@@ -0,0 +1,230 @@
+/**
+ * Feature coverage for the startup experience hub against production markup.
+ */
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { afterEach, beforeEach, test } from 'node:test';
+import { JSDOM } from 'jsdom';
+
+import { APP_STATE_VERSION } from '../src/appStateVersion.js';
+import {
+ EXPERIENCE_ROLE_STORAGE_KEY,
+ getActiveExperienceRole,
+ initExperienceRoleController
+} from '../src/experienceRoleController.js';
+import { EXPERIENCE_ROLE_IDS } from '../src/experienceRoles.js';
+import {
+ INSTRUCTOR_SESSION_STORAGE_KEY,
+ INSTRUCTOR_SESSION_VERSION
+} from '../src/classroomInstructor.js';
+import {
+ STUDENT_SESSION_STORAGE_KEY,
+ STUDENT_SESSION_VERSION
+} from '../src/classroomStudent.js';
+import { initStartupExperienceHub } from '../src/startupExperienceHub.js';
+import { STORAGE_KEY } from '../src/storage.js';
+
+const INDEX_HTML = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
+const STUDENT_TOKEN = 's'.repeat(43);
+const INSTRUCTOR_TOKEN = 'i'.repeat(43);
+
+let dom = null;
+
+function mount(url = 'https://intake.test/') {
+ dom = new JSDOM(INDEX_HTML, { url, pretendToBeVisual: true });
+ dom.window.localStorage.clear();
+ dom.window.sessionStorage.clear();
+ dom.window.confirm = () => true;
+ return dom.window.document;
+}
+
+function studentSession() {
+ return {
+ version: STUDENT_SESSION_VERSION,
+ mode: 'live',
+ class: {
+ id: 'class-student',
+ title: 'PSDM Student Class',
+ expiresAt: '2099-12-31T23:59:59.000Z'
+ },
+ participant: {
+ id: 'student-1',
+ displayName: 'Alex Learner'
+ },
+ studentSessionToken: STUDENT_TOKEN,
+ assignmentRevision: 1,
+ assignment: {
+ id: '11111111-1111-4111-8111-111111111111',
+ kind: 'group',
+ label: 'Team Alpha'
+ }
+ };
+}
+
+function instructorSession() {
+ return {
+ version: INSTRUCTOR_SESSION_VERSION,
+ instructorToken: INSTRUCTOR_TOKEN,
+ joinCode: 'K7FM-P4Q2',
+ class: {
+ id: 'class-instructor',
+ title: 'PSDM Instructor Class',
+ expiresAt: '2099-12-31T23:59:59.000Z'
+ },
+ selectedWorkspaceId: '22222222-2222-4222-8222-222222222222'
+ };
+}
+
+function initialize({ startFresh = () => {} } = {}) {
+ const hub = initStartupExperienceHub({
+ documentRef: dom.window.document,
+ windowRef: dom.window,
+ storage: dom.window.localStorage,
+ location: dom.window.location,
+ startFresh
+ });
+ const role = initExperienceRoleController({
+ documentRef: dom.window.document,
+ windowRef: dom.window,
+ storage: dom.window.localStorage,
+ location: dom.window.location,
+ onRoleIntent: hub.handleRoleIntent,
+ onChooserOpen: hub.refresh
+ });
+ return { hub, role };
+}
+
+beforeEach(() => {
+ mount();
+});
+
+afterEach(() => {
+ dom?.window.close();
+ dom = null;
+});
+
+test('startup hub presents saved Intake, Student, and Instructor resumes as separate choices', () => {
+ dom.window.localStorage.setItem(STORAGE_KEY, JSON.stringify({
+ meta: { version: APP_STATE_VERSION, savedAt: '2026-10-08T20:00:00.000Z' },
+ pre: { oneLine: 'Checkout latency increased.' }
+ }));
+ dom.window.localStorage.setItem(STUDENT_SESSION_STORAGE_KEY, JSON.stringify(studentSession()));
+ dom.window.localStorage.setItem(INSTRUCTOR_SESSION_STORAGE_KEY, JSON.stringify(instructorSession()));
+
+ const { role } = initialize();
+
+ assert.equal(role, null);
+ assert.equal(getActiveExperienceRole(), null);
+ assert.equal(dom.window.document.getElementById('experienceRoleGate').hidden, false);
+ assert.equal(dom.window.document.getElementById('startupContinueSection').hidden, false);
+
+ const cards = [...dom.window.document.querySelectorAll('[data-startup-resume]')];
+ assert.deepEqual(cards.map(card => card.dataset.startupResume), ['intake', 'student', 'instructor']);
+ assert.match(cards[0].textContent, /Checkout latency increased/);
+ assert.match(cards[1].textContent, /PSDM Student Class/);
+ assert.match(cards[1].textContent, /Alex Learner/);
+ assert.match(cards[2].textContent, /PSDM Instructor Class/);
+
+ cards[1].click();
+
+ assert.equal(getActiveExperienceRole(), EXPERIENCE_ROLE_IDS.STUDENT);
+ assert.equal(dom.window.document.getElementById('experienceRoleGate').hidden, true);
+ assert.ok(dom.window.localStorage.getItem(STUDENT_SESSION_STORAGE_KEY));
+ assert.equal(JSON.parse(dom.window.localStorage.getItem(EXPERIENCE_ROLE_STORAGE_KEY)).role, EXPERIENCE_ROLE_IDS.STUDENT);
+});
+
+test('join link highlights normal admission without silently replacing a saved Student resume', () => {
+ dom.window.close();
+ mount('https://intake.test/#join=K7FMP4Q2');
+ dom.window.localStorage.setItem(STUDENT_SESSION_STORAGE_KEY, JSON.stringify(studentSession()));
+
+ let confirmations = 0;
+ dom.window.confirm = () => {
+ confirmations += 1;
+ return false;
+ };
+
+ initialize();
+
+ const notice = dom.window.document.getElementById('startupJoinIntentNotice');
+ const join = dom.window.document.querySelector('[data-experience-role-choice="student"]');
+ assert.equal(getActiveExperienceRole(), null);
+ assert.equal(notice.hidden, false);
+ assert.match(notice.textContent, /K7FM-P4Q2/);
+ assert.equal(join.classList.contains('is-recommended'), true);
+
+ join.click();
+
+ assert.equal(confirmations, 1);
+ assert.equal(getActiveExperienceRole(), null);
+ assert.ok(dom.window.localStorage.getItem(STUDENT_SESSION_STORAGE_KEY));
+ assert.equal(dom.window.document.getElementById('experienceRoleGate').hidden, false);
+
+ dom.window.confirm = () => true;
+ join.click();
+
+ assert.equal(getActiveExperienceRole(), EXPERIENCE_ROLE_IDS.STUDENT);
+ assert.equal(dom.window.localStorage.getItem(STUDENT_SESSION_STORAGE_KEY), null);
+ assert.equal(dom.window.location.hash, '#join=K7FMP4Q2', 'Student controller consumes the safe fragment only after routing');
+});
+
+test('fresh independent work requires confirmation before clearing substantive saved Intake', () => {
+ dom.window.localStorage.setItem(STORAGE_KEY, JSON.stringify({
+ meta: { version: APP_STATE_VERSION },
+ pre: { oneLine: 'Preserve this Intake until I explicitly replace it.' }
+ }));
+
+ let startFreshCalls = 0;
+ const { hub } = initialize({
+ startFresh: () => {
+ startFreshCalls += 1;
+ dom.window.localStorage.removeItem(STORAGE_KEY);
+ }
+ });
+
+ dom.window.confirm = () => false;
+ dom.window.document.querySelector('[data-experience-role-choice="standalone"]').click();
+
+ assert.equal(startFreshCalls, 0);
+ assert.equal(getActiveExperienceRole(), null);
+ assert.ok(dom.window.localStorage.getItem(STORAGE_KEY));
+
+ dom.window.confirm = () => true;
+ dom.window.document.querySelector('[data-experience-role-choice="standalone"]').click();
+
+ assert.equal(startFreshCalls, 1);
+ assert.equal(getActiveExperienceRole(), EXPERIENCE_ROLE_IDS.STANDALONE);
+ assert.equal(dom.window.localStorage.getItem(STORAGE_KEY), null);
+ assert.equal(hub.getContext().savedIntake?.pre?.oneLine, 'Preserve this Intake until I explicitly replace it.');
+});
+
+test('empty/default Intake storage does not render a misleading Continue card', () => {
+ dom.window.localStorage.setItem(STORAGE_KEY, JSON.stringify({
+ meta: { version: APP_STATE_VERSION, savedAt: '2026-10-08T20:00:00.000Z' },
+ ops: { bridgeOpenedUtc: '2026-10-08T20:00:00.000Z' },
+ appearance: { theme: 'dark' }
+ }));
+
+ initialize();
+
+ assert.equal(dom.window.document.getElementById('startupContinueSection').hidden, true);
+ assert.equal(dom.window.document.querySelectorAll('[data-startup-resume]').length, 0);
+});
+
+test('explicit Standalone workspace link bypasses startup while ordinary saved preferences do not', () => {
+ dom.window.localStorage.setItem(EXPERIENCE_ROLE_STORAGE_KEY, JSON.stringify({
+ version: 1,
+ role: EXPERIENCE_ROLE_IDS.INSTRUCTOR
+ }));
+ initialize();
+
+ assert.equal(getActiveExperienceRole(), null);
+ assert.equal(dom.window.document.getElementById('experienceRoleGate').hidden, false);
+
+ dom.window.close();
+ mount('https://intake.test/?workspace=explicit-secret');
+ initialize();
+
+ assert.equal(getActiveExperienceRole(), EXPERIENCE_ROLE_IDS.STANDALONE);
+ assert.equal(dom.window.document.getElementById('experienceRoleGate').hidden, true);
+});
From 2622157e5116c9778a2c83574388b81dc19a0729 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:08:21 -0400
Subject: [PATCH 13/47] Require explicit Continue for saved Standalone Intake
---
tests/e2e/standalone.spec.mjs | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/tests/e2e/standalone.spec.mjs b/tests/e2e/standalone.spec.mjs
index 31e5aff..26f924b 100644
--- a/tests/e2e/standalone.spec.mjs
+++ b/tests/e2e/standalone.spec.mjs
@@ -59,6 +59,11 @@ test('Standalone input generates a summary and survives a real browser reload',
await page.reload();
+ await expect(page.locator('#experienceRoleGate')).toBeVisible();
+ await expect(page.locator('body')).toHaveAttribute('data-experience-role', 'unselected');
+ await expect(page.locator('[data-startup-resume="intake"]')).toContainText('Continue your saved Intake');
+ await page.locator('[data-startup-resume="intake"]').click();
+
await expect(page.locator('#experienceRoleGate')).toBeHidden();
await expect(page.locator('body')).toHaveAttribute('data-experience-role', 'standalone');
await expect(page.locator('#oneLine')).toHaveValue(problem);
From 5abfdc47de9a27b25b012541c2934822023135bf Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:08:27 -0400
Subject: [PATCH 14/47] Require explicit Rejoin for Student resume
---
tests/e2e/student.spec.mjs | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/tests/e2e/student.spec.mjs b/tests/e2e/student.spec.mjs
index 3b63568..adf6c31 100644
--- a/tests/e2e/student.spec.mjs
+++ b/tests/e2e/student.spec.mjs
@@ -188,6 +188,11 @@ test('Student joins with one code, waits, resumes Team Alpha, moves to Team Beta
await page.reload();
+ await expect(page.locator('#experienceRoleGate')).toBeVisible();
+ await expect(page.locator('body')).toHaveAttribute('data-experience-role', 'unselected');
+ await expect(page.locator('[data-startup-resume="student"]')).toContainText('Browser Student Live Classroom');
+ await page.locator('[data-startup-resume="student"]').click();
+
await expect(page.locator('#experienceRoleGate')).toBeHidden();
await expect(page.locator('body')).toHaveAttribute('data-experience-role', 'student');
await expect(page.locator('body')).toHaveAttribute('data-student-class-status', 'connected', { timeout: 10000 });
From 5156d23eb466af7607b8dd7e8ac35168f58daf0b Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:08:34 -0400
Subject: [PATCH 15/47] Require explicit Continue for Instructor resume
---
tests/e2e/instructor.spec.mjs | 12 ++++++++++--
1 file changed, 10 insertions(+), 2 deletions(-)
diff --git a/tests/e2e/instructor.spec.mjs b/tests/e2e/instructor.spec.mjs
index d10507e..c8c5a09 100644
--- a/tests/e2e/instructor.spec.mjs
+++ b/tests/e2e/instructor.spec.mjs
@@ -38,7 +38,7 @@ async function expectNoBlockingA11yViolations(page) {
async function resumeFixtureInstructor(page, instructorToken) {
await startFresh(page);
- await page.getByRole('button', { name: /Teach a class/ }).click();
+ await page.getByRole('button', { name: /Run a class/ }).click();
await page.evaluate(({ key, token, workspaceId }) => {
window.localStorage.setItem(key, JSON.stringify({
version: 1,
@@ -58,6 +58,9 @@ async function resumeFixtureInstructor(page, instructorToken) {
workspaceId: FIRST_WORKSPACE_ID
});
await page.reload();
+ await expect(page.locator('#experienceRoleGate')).toBeVisible();
+ await expect(page.locator('[data-startup-resume="instructor"]')).toContainText('Browser Test Classroom');
+ await page.locator('[data-startup-resume="instructor"]').click();
await expect(page.locator('#instructorClassDashboard')).toBeVisible();
}
@@ -78,7 +81,7 @@ test('Instructor starts a live class, creates a team, assigns a waiting Student,
});
await startFresh(page);
- await page.getByRole('button', { name: /Teach a class/ }).click();
+ await page.getByRole('button', { name: /Run a class/ }).click();
await expect(page.locator('#instructorClassEntryCard')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Run a class' })).toBeVisible();
@@ -224,6 +227,11 @@ test('Instructor starts a live class, creates a team, assigns a waiting Student,
await page.reload();
+ await expect(page.locator('#experienceRoleGate')).toBeVisible();
+ await expect(page.locator('body')).toHaveAttribute('data-experience-role', 'unselected');
+ await expect(page.locator('[data-startup-resume="instructor"]')).toContainText('Browser Live PSDM');
+ await page.locator('[data-startup-resume="instructor"]').click();
+
await expect(page.locator('#experienceRoleGate')).toBeHidden();
await expect(page.locator('body')).toHaveAttribute('data-experience-role', 'instructor');
await expect(page.locator('#instructorClassDashboard')).toBeVisible();
From e836136490dad5e0aed166fa180209736606c12b Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:08:49 -0400
Subject: [PATCH 16/47] Keep expired Student resume out of startup choices
---
tests/e2e/classroom-authorization.spec.mjs | 15 ++++++++++++---
1 file changed, 12 insertions(+), 3 deletions(-)
diff --git a/tests/e2e/classroom-authorization.spec.mjs b/tests/e2e/classroom-authorization.spec.mjs
index fe543ed..834df0f 100644
--- a/tests/e2e/classroom-authorization.spec.mjs
+++ b/tests/e2e/classroom-authorization.spec.mjs
@@ -82,12 +82,21 @@ test('expired saved Student access is discarded before stale workspace content c
await page.reload();
+ await expect(page.locator('#experienceRoleGate')).toBeVisible();
+ await expect(page.locator('body')).toHaveAttribute('data-experience-role', 'unselected');
+ await expect(page.locator('[data-startup-resume="student"]')).toHaveCount(0);
+ await expect(page.locator('#studentClassEntryShell')).toBeHidden();
+
+ const storedBeforeNewIntent = await page.evaluate(key => window.localStorage.getItem(key), STUDENT_SESSION_STORAGE_KEY);
+ expect(storedBeforeNewIntent).not.toBeNull();
+
+ await page.getByRole('button', { name: /Join a class/ }).click();
+
await expect(page.locator('body')).toHaveAttribute('data-experience-role', 'student');
await expect(page.locator('#studentClassEntryShell')).toBeVisible();
- await expect(page.locator('#studentClassJoinError')).toContainText(/expired/i);
await expect(page.locator('body')).not.toHaveAttribute('data-student-class-status', 'connected');
- const storedAfterRecovery = await page.evaluate(key => window.localStorage.getItem(key), STUDENT_SESSION_STORAGE_KEY);
- expect(storedAfterRecovery).toBeNull();
+ const storedAfterNewIntent = await page.evaluate(key => window.localStorage.getItem(key), STUDENT_SESSION_STORAGE_KEY);
+ expect(storedAfterNewIntent).toBeNull();
await expect(page.locator('#studentClassWorkspace')).not.toContainText('Expired Team');
});
From 6d4795a1cdd3c6900071dcb2f7267c8a188d27e1 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:08:56 -0400
Subject: [PATCH 17/47] Route share links through explicit startup Join
---
tests/e2e/classroom-live-integration.spec.mjs | 13 ++++++++++---
1 file changed, 10 insertions(+), 3 deletions(-)
diff --git a/tests/e2e/classroom-live-integration.spec.mjs b/tests/e2e/classroom-live-integration.spec.mjs
index bdea188..9e8e741 100644
--- a/tests/e2e/classroom-live-integration.spec.mjs
+++ b/tests/e2e/classroom-live-integration.spec.mjs
@@ -93,7 +93,7 @@ test('live class integrates Instructor roster, team sync, isolation, coaching, r
try {
await startFresh(instructor);
- await instructor.getByRole('button', { name: /Teach a class/ }).click();
+ await instructor.getByRole('button', { name: /Run a class/ }).click();
await instructor.getByLabel('Class title').fill('Integrated Browser Classroom');
await instructor.getByRole('button', { name: 'Start class' }).click();
@@ -327,7 +327,7 @@ test('Instructor compares immutable debrief checkpoint with current live Intake'
try {
await startFresh(instructor);
- await instructor.getByRole('button', { name: /Teach a class/ }).click();
+ await instructor.getByRole('button', { name: /Run a class/ }).click();
await instructor.getByLabel('Class title').fill('Integrated Browser Classroom');
await instructor.getByRole('button', { name: 'Start class' }).click();
await expect(instructor.locator('#instructorClassDashboard')).toBeVisible();
@@ -438,7 +438,7 @@ test('Instructor share link opens normal Student admission with fragment-only hu
await instructorContext.grantPermissions(['clipboard-read', 'clipboard-write']);
await startFresh(instructor);
- await instructor.getByRole('button', { name: /Teach a class/ }).click();
+ await instructor.getByRole('button', { name: /Run a class/ }).click();
await instructor.getByLabel('Class title').fill('Integrated Browser Classroom');
await instructor.getByRole('button', { name: 'Start class' }).click();
await expect(instructor.locator('#instructorClassDashboard')).toBeVisible();
@@ -466,6 +466,13 @@ test('Instructor share link opens normal Student admission with fragment-only hu
expect(shareUrl).not.toContain('workspace=');
await student.goto(shareUrl);
+ await expect(student.locator('#experienceRoleGate')).toBeVisible();
+ await expect(student.locator('body')).toHaveAttribute('data-experience-role', 'unselected');
+ await expect(student.locator('#startupJoinIntentNotice')).toContainText(joinCode);
+ await expect(student.locator('[data-experience-role-choice="student"]')).toHaveClass(/is-recommended/);
+
+ await student.getByRole('button', { name: /Join a class/ }).click();
+
await expect(student.locator('body')).toHaveAttribute('data-experience-role', 'student');
await expect(student.locator('#studentClassEntryShell')).toBeVisible();
await expect(student.getByLabel('Class code')).toHaveValue(joinCode);
From b24b056879878d77d2c3f27a536af26fec4ae596 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:09:01 -0400
Subject: [PATCH 18/47] Expect Run a class startup wording
---
tests/e2e/intake.smoke.spec.mjs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/e2e/intake.smoke.spec.mjs b/tests/e2e/intake.smoke.spec.mjs
index 00fced5..090e5d0 100644
--- a/tests/e2e/intake.smoke.spec.mjs
+++ b/tests/e2e/intake.smoke.spec.mjs
@@ -29,7 +29,7 @@ test('fresh Intake boots into the required role chooser without browser errors',
await expect(dialog).toBeVisible();
await expect(page.getByRole('button', { name: /Work independently/ })).toBeVisible();
await expect(page.getByRole('button', { name: /Join a class/ })).toBeVisible();
- await expect(page.getByRole('button', { name: /Teach a class/ })).toBeVisible();
+ await expect(page.getByRole('button', { name: /Run a class/ })).toBeVisible();
await expect(page.locator('#experienceRoleCancelBtn')).toBeHidden();
await expect(page.locator('[data-experience-role-choice="standalone"]')).toBeFocused();
From 72e86c17fb6ffc07ac8f4d01aaaf748f9cccb984 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:09:09 -0400
Subject: [PATCH 19/47] Test Run a class role label
---
tests/experienceRoles.unit.test.mjs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/experienceRoles.unit.test.mjs b/tests/experienceRoles.unit.test.mjs
index b8193ba..550586f 100644
--- a/tests/experienceRoles.unit.test.mjs
+++ b/tests/experienceRoles.unit.test.mjs
@@ -22,7 +22,7 @@ test('experience roles normalize independently from Intake workflow modes', () =
test('experience role metadata exposes stable human labels', () => {
assert.equal(getExperienceRoleDefinition(EXPERIENCE_ROLE_IDS.STANDALONE)?.label, 'Standalone');
assert.equal(getExperienceRoleDefinition(EXPERIENCE_ROLE_IDS.STUDENT)?.actionLabel, 'Join a class');
- assert.equal(getExperienceRoleDefinition(EXPERIENCE_ROLE_IDS.INSTRUCTOR)?.actionLabel, 'Teach a class');
+ assert.equal(getExperienceRoleDefinition(EXPERIENCE_ROLE_IDS.INSTRUCTOR)?.actionLabel, 'Run a class');
});
test('role surface policy keeps Standalone and Student in Intake while Instructor uses its shell', () => {
From da0c1b2dca7012189af6a76dee9ea97327aeecc2 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:10:09 -0400
Subject: [PATCH 20/47] Mark dynamic startup controls local-only
---
src/startupExperienceHub.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/startupExperienceHub.js b/src/startupExperienceHub.js
index ea9c6ae..1dfc6f4 100644
--- a/src/startupExperienceHub.js
+++ b/src/startupExperienceHub.js
@@ -218,7 +218,7 @@ function element(id) {
}
function buildResumeButton({ kind, title, description, meta }) {
- const button = documentRef.createElement('button');
+ const button = documentRef.createElement('button'); // data-persistence="local-only" data-summary="exclude"
button.type = 'button';
button.className = 'startup-resume-card';
button.dataset.startupResume = kind;
From 38d2c8471efb7d8c491501d06f5b08c99d2ae165 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:10:38 -0400
Subject: [PATCH 21/47] Honor reduced motion in startup hub
---
styles.css | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/styles.css b/styles.css
index 4d1c9c9..500a71f 100644
--- a/styles.css
+++ b/styles.css
@@ -346,6 +346,10 @@ body.experience-role-gate-open{overflow:hidden;}
.startup-hub__join-intent{margin:0 0 12px;padding:9px 11px;border:1px solid var(--mi-role-blue-border);border-radius:11px;background:var(--mi-role-blue-bg);color:var(--mi-role-blue);font-size:11px;font-weight:700;line-height:1.4;}
.experience-role-option.is-recommended{border-color:var(--accent);box-shadow:0 0 0 2px color-mix(in srgb,var(--accent) 18%,transparent);}
.experience-role-option.is-recommended::after{content:"Class link detected";margin-top:8px;color:var(--accent-text);font-size:10px;font-weight:850;}
+@media (prefers-reduced-motion:reduce){
+ .startup-resume-card,.experience-role-option{transition:none!important;}
+ .startup-resume-card:hover,.experience-role-option:hover{transform:none;}
+}
.experience-role-dialog__footer{
display:flex;
align-items:center;
From b2a29f9fc8dd2bc645798da62151c6b45086adb3 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:10:55 -0400
Subject: [PATCH 22/47] Validate fresh startup hub on desktop and mobile
---
tests/e2e/intake.smoke.spec.mjs | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/tests/e2e/intake.smoke.spec.mjs b/tests/e2e/intake.smoke.spec.mjs
index 090e5d0..841f648 100644
--- a/tests/e2e/intake.smoke.spec.mjs
+++ b/tests/e2e/intake.smoke.spec.mjs
@@ -18,19 +18,20 @@ async function startFresh(page) {
});
}
-test('fresh Intake boots into the required role chooser without browser errors', async ({ page }) => {
+test('fresh Intake boots into the required startup hub without browser errors', async ({ page }) => {
const pageErrors = [];
page.on('pageerror', error => pageErrors.push(error.message));
await startFresh(page);
await page.goto('/');
- const dialog = page.getByRole('dialog', { name: 'How are you using Intake?' });
+ const dialog = page.getByRole('dialog', { name: 'What would you like to do?' });
await expect(dialog).toBeVisible();
await expect(page.getByRole('button', { name: /Work independently/ })).toBeVisible();
await expect(page.getByRole('button', { name: /Join a class/ })).toBeVisible();
await expect(page.getByRole('button', { name: /Run a class/ })).toBeVisible();
await expect(page.locator('#experienceRoleCancelBtn')).toBeHidden();
+ await expect(page.locator('#startupContinueSection')).toBeHidden();
await expect(page.locator('[data-experience-role-choice="standalone"]')).toBeFocused();
const accessibility = await new AxeBuilder({ page }).analyze();
From b447e6ea1aaabebbc3ef7ba0fbcc7c87d42665e3 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:11:33 -0400
Subject: [PATCH 23/47] Document explicit startup hub behavior
---
README.md | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index 23926ba..3bcff2c 100644
--- a/README.md
+++ b/README.md
@@ -5,8 +5,8 @@ KT Intake is a browser-first Kepner–Tregoe (KT) incident workbook designed for
## Quickstart
- Clone or download this repository.
- Open `index.html` in any modern browser. Standalone use remains local-first and does not depend on the classroom backend.
-- A browser with no current experience preference asks whether to **Work independently**, **Join a class**, or **Teach a class**. Existing explicit `?workspace=` collaboration links still route to **Standalone**; saved Intake data by itself no longer silently chooses an experience.
-- The selected experience resumes from the separate `kt-experience-role-v1` preference. Intake work itself still loads from `kt-intake-full-v2`, with action plans under `kt-actions-by-analysis-v1`.
+- Ordinary launch opens the startup hub. Valid saved Intake, Student class, and Instructor class contexts appear as separate **Continue** cards; otherwise choose **Work independently**, **Join a class**, or **Run a class**. A safe `#join=` link highlights normal Student admission, while an explicit `?workspace=` collaboration link still routes directly to **Standalone**.
+- `kt-experience-role-v1` remains a local-only last-choice preference, but it no longer auto-routes startup. Intake work itself still loads from `kt-intake-full-v2`, with action plans under `kt-actions-by-analysis-v1`.
- Use the header controls to **Save to File** (exports a JSON snapshot) or **Load from File** (imports a previously saved snapshot) when you need to move an intake between browsers or machines.
- Open the shared resource drawer to work with curated material. **Standalone** receives public Standard Templates only. Connected **Students** receive Standard Templates plus Classroom-authorized Case Studies; connected **Instructors** receive authorized teaching Case Studies. The rotating Case Study mode password remains a learning/progression control, not authentication.
@@ -72,7 +72,7 @@ See [`docs/architecture-overview.md`](docs/architecture-overview.md) for the boo
### Storage keys
- `kt-intake-full-v2`: Primary snapshot containing the intake form, table, steps, communications log, possible causes, and `notesWorkspace` notes/open preference. Save to File and Load from File include this full snapshot automatically.
-- `kt-experience-role-v1`: Local-only Standalone / Student / Instructor preference. It is deliberately excluded from `collectAppState()`, Intake file exports, summaries, templates, and **Start Fresh** clearing.
+- `kt-experience-role-v1`: Local-only Standalone / Student / Instructor last-choice preference. It is deliberately excluded from `collectAppState()`, Intake file exports, summaries, templates, and **Start Fresh** clearing, and does **not** silently choose startup routing.
- `kt-classroom-student-session-v1`: Student same-device resume key. Its current v2 envelope contains the stable Student class-session capability plus public class/participant/current-assignment context; the assignment-specific workspace capability remains memory-only and is reacquired after reload or reassignment. Older envelope formats are intentionally unsupported before production. The envelope never enters Intake exports/summaries/templates.
- `kt-classroom-student-local-recovery-v1`: Local recovery snapshot captured immediately before joining a class so **Leave class** can restore the prior local Intake. It is separate from the active Intake snapshot and classroom credentials.
- `kt-classroom-instructor-session-v1`: Local-only Instructor same-device resume envelope containing the Instructor class capability, public class metadata, and the last selected public workspace ID. It is never collected into Intake state, files, summaries, templates, or Student workspace credentials.
@@ -84,11 +84,13 @@ Coaching feedback is server-side Classroom data, not a local Intake storage key.
Experience role is a product-level choice, not an Intake workflow mode. General / IT / Pharma / Major Incident remain controlled by `meta.intakeMode`; Standalone / Student / Instructor are controlled separately by `src/experienceRoles.js` and `src/experienceRoleController.js`.
+`src/startupExperienceHub.js` detects substantive saved Intake and current Student/Instructor resume envelopes independently. Opening the hub is non-destructive; starting a fresh independent Intake or replacing a same-type class resume requires explicit user intent.
+
**Administration / Maintenance is not an experience role.** It is a separate privileged utility entered from the chooser or View menu and authorized only by the server-configured `INTAKE_ADMIN_TOKEN`. See [`docs/admin-maintenance.md`](docs/admin-maintenance.md).
- **Standalone** exposes the normal Intake and current collaboration behavior. Its resource drawer contains **Templates only**.
- **Student** joins with a display name and one human class code. An Instructor share/QR link may prefill that same code through a client-only `#join=` fragment; the fragment is consumed locally and never replaces normal server admission. Admission creates a stable high-entropy Student class-session capability; the learner may remain **Waiting / unassigned** with no workspace edit authority until the Instructor assigns a team or individual workspace. Once assigned, the browser exchanges the class session for a fresh assignment-specific editable workspace capability, keeps that workspace capability memory-only, and attaches it to the existing collaboration engine without entering the URL. Reassignment disconnects old authority before the Student enters the destination team's existing Intake; unassign returns the Student to Waiting. Connected Students see class/workspace/identity context, public **Templates**, protected Classroom Case Studies, and read-only Instructor coaching including optional notes and **Changed since review**. On narrow screens the Class, Case, Team, and Notes secondary surfaces default compact but remain one-action accessible; those collapse states are presentation-only. Switching away pauses live classroom sync while preserving class resume; **Leave class** clears resume and restores the local Intake captured before joining.
-- **Instructor** uses **Start a class**, receives one human Student join code, and can copy the code, share a fragment-only join link, or show a fully local QR for that same safe link. The Instructor sees Waiting/assigned participants, creates team or individual workspaces, and assigns/reassigns/unassigns Students through accessible selectors. The Instructor capability is retained locally for same-device resume; lost cross-device authority will be handled by the separately authorized Administration / Maintenance experience in #329 rather than a public bearer-code form. The dashboard can rapidly switch into a **live read-only** view of each Student/team Intake and provide field-level coaching. Observation uses the normal Intake renderer but server authorization keeps the Instructor credential outside Student edit capability. The same Instructor class capability authorizes protected teaching Case Studies. The Class rail defaults compact on narrow screens without changing session authority. Switching away pauses observation while preserving same-device class resume; **Leave class** clears the Instructor resume and restores the instructor's prior local Intake.
+- **Instructor** uses **Start a class**, receives one human Student join code, and can copy the code, share a fragment-only join link, or show a fully local QR for that same safe link. The Instructor sees Waiting/assigned participants, creates team or individual workspaces, and assigns/reassigns/unassigns Students through accessible selectors. The Instructor capability is retained locally for same-device resume; lost cross-device authority is handled by the separately authorized Administration / Maintenance surface rather than a public bearer-code form. The dashboard can rapidly switch into a **live read-only** view of each Student/team Intake and provide field-level coaching. Observation uses the normal Intake renderer but server authorization keeps the Instructor credential outside Student edit capability. The same Instructor class capability authorizes protected teaching Case Studies. The Class rail defaults compact on narrow screens without changing session authority. Switching away pauses observation while preserving same-device class resume; **Leave class** clears the Instructor resume and restores the instructor's prior local Intake.
- Use **View → Experience** to switch roles without changing or deleting Intake data.
## Notes workspace
From 16c8f4a3ff4fc5f9b1ceb269861d17c3124ad108 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:11:40 -0400
Subject: [PATCH 24/47] Map explicit startup intent architecture
---
docs/architecture-overview.md | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md
index 7b8d710..d0ccbd4 100644
--- a/docs/architecture-overview.md
+++ b/docs/architecture-overview.md
@@ -7,7 +7,7 @@ This document explains how the intake app boots, which modules own which DOM reg
`main.js` is the entry point referenced by `index.html`. Once `DOMContentLoaded` fires it runs `boot()`, which performs the following steps:
1. **Expose shared utilities** – assigns `window.showToast` first so modules and tests can emit notifications even before the rest of the UI finishes initializing.
-2. **Restore the product experience role** – `initExperienceRoleController()` reads the separate `kt-experience-role-v1` preference. An explicit `?workspace=` link routes to Standalone, a valid saved preference resumes its role, and otherwise the chooser opens. Saved Intake data by itself does not choose a role. The controller applies only broad `data-experience-surface` visibility and never mutates Intake state.
+2. **Resolve startup intent** – `src/startupExperienceHub.js` inspects substantive saved Intake plus current Student/Instructor resume envelopes and renders them as independent Continue choices. `initExperienceRoleController()` no longer auto-routes from `kt-experience-role-v1`; only an explicit `?workspace=` link bypasses the hub to Standalone. A safe `#join=` fragment biases Join without bypassing normal admission.
3. **Configure the KT table module** – calls `configureKT()` with callbacks owned by other modules:
- `autoResize` keeps textarea heights in sync with content.
- `onSave` points to `saveAppState()` so KT edits trigger persistence.
@@ -36,6 +36,7 @@ This document explains how the intake app boots, which modules own which DOM reg
| --- | --- | --- |
| `main.js` | Orchestrates boot, registers DOM listeners, wires file transfer + global shortcuts, exposes fallbacks for legacy integrations. | Relies on every feature module’s public API, but only coordinates them—it never reaches into DOM anchors it does not own. |
| `src/experienceRoles.js` & `src/experienceRoleController.js` | Define Standalone/Student/Instructor independently from Intake modes, persist the local-only role preference, own the role chooser, and project broad product surfaces. | Own `kt-experience-role-v1`, `[feature:experience-role]`, `[feature:experience-role-switch]`, Student notice, and Instructor shell. Experience state never enters `appState`. |
+| `src/startupExperienceHub.js` | Detects meaningful saved Intake plus valid Student/Instructor resumes, renders explicit Continue cards, highlights safe join-link intent, and owns replacement confirmations. | Startup intent is navigation/session state only. It never writes resume choice into SerializedAppState and delegates actual class resume to the existing Classroom controllers. |
| `src/adminMaintenance.js` | Owns the privileged Administration / Maintenance overlay, tab-scoped Admin session envelope, lifecycle filters, Instructor recovery display, and preview-first destructive UX. | Calls only `/api/admin`; it never sets `data-experience-role` and never enters Intake persistence.
| `src/preface.js` | Manages `[section:preface]` + `[section:impact]` inputs, detection chips, mirror sync, and tokens such as `{OBJECT}` and `{DEVIATION}`. | Supplies `autoResize`, `updatePrefaceTitles`, `startMirrorSync`, `setBridgeOpenedNow`, `getPrefaceState`, `getObjectFull`, `getDeviationFull`. Receives `onSave` from `main.js`. |
| `src/kt.js` | Owns `[section:table]`: builds the IS/IS NOT table, possible-cause cards, focus modes, and cause evidence previews. | Accepts callbacks from Preface & Toast via `configureKT()`. Provides `exportKTTableState`, `importKTTableState`, `getPossibleCauses`, and other helpers consumed by `appState` & summary modules. |
@@ -71,7 +72,7 @@ This document explains how the intake app boots, which modules own which DOM reg
## Persistence and summary data flow
-1. **Experience preference** – `src/experienceRoleController.js` reads/writes `kt-experience-role-v1` independently. It is not an input to `collectAppState()`, summary generation, file transfer, or template export.
+1. **Experience preference / startup intent** – `src/experienceRoleController.js` reads/writes `kt-experience-role-v1` independently, but the preference is no longer startup authority. `src/startupExperienceHub.js` derives current Continue choices from real saved/resumable context. Neither is an input to `collectAppState()`, summary generation, file transfer, or template export.
2. **Student classroom resume** – `src/classroomStudent.js` reads/writes `kt-classroom-student-session-v1` and the pre-class `kt-classroom-student-local-recovery-v1` separately. Neither key is part of SerializedAppState; join/assignment capabilities are never persisted.
3. **Instructor classroom resume** – `src/classroomInstructor.js` reads/writes `kt-classroom-instructor-session-v1` separately. The Instructor class capability, public class metadata, and last selected public workspace ID never enter SerializedAppState, exports, summaries, or templates.
4. **Coaching feedback** – `src/classroomCoaching.js` has no Intake persistence key. Feedback lives server-side in `classroom_coaching_feedback`, with independent per-target revisions and reviewed field fingerprints; it never enters SerializedAppState, Save/Load, templates, summaries, or Student workspace revisions.
From 7f64aab0ff4f02ecdd65d1da56b2ebc3bd5598f9 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:11:54 -0400
Subject: [PATCH 25/47] Advance hardening plan to startup hub
---
docs/preproduction-hardening.md | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/docs/preproduction-hardening.md b/docs/preproduction-hardening.md
index 73be73f..30a6b9e 100644
--- a/docs/preproduction-hardening.md
+++ b/docs/preproduction-hardening.md
@@ -31,7 +31,7 @@ PRODUCTION SIMULATION ENABLEMENT
#317 first production staged KT Case Study
```
-#328 is complete and published. #329 is now active on draft PR #333, with its canonical runtime contract in `docs/admin-maintenance.md`. #330 remains next after #329 so the startup hub can integrate the proven Admin entry instead of designing against a placeholder.
+#328 and #329 are complete and published. #329 merged via PR #333 as `b04684f2432aa474cb84874aa26aa08a5f4c5ab7`; its canonical runtime contract is `docs/admin-maintenance.md`. **#330 is active on draft PR #334** and now integrates the proven Admin entry.
## End-state access model
@@ -82,7 +82,7 @@ Do not remove transformations that remain part of the **current** data model mer
## Administration / Maintenance architecture
-**Implementation status:** active in #329 / draft PR #333. The detailed implemented contract is `docs/admin-maintenance.md`; this section remains the roadmap-level summary.
+**Implementation status:** complete and published in #329 / PR #333. The detailed implemented contract is `docs/admin-maintenance.md`; this section remains the roadmap-level summary.
Administration is a privileged maintenance surface, **not** a fourth Intake experience role.
@@ -155,6 +155,8 @@ Do **not** add autonomous scheduled deletion in the first Admin slice. Prove the
## Startup experience hub
+**Implementation status:** active in #330 / draft PR #334 on `feature/startup-experience-hub`.
+
Ordinary app launch should no longer silently resume a role solely because `kt-experience-role-v1` exists.
### Returning user
From 3b09f6b5e7f6bf689bd778a4244d9d9d9ef22c4e Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:12:00 -0400
Subject: [PATCH 26/47] Advance roadmap to active startup hub
---
docs/classroom-roadmap.md | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/docs/classroom-roadmap.md b/docs/classroom-roadmap.md
index 0097613..bd7f5d4 100644
--- a/docs/classroom-roadmap.md
+++ b/docs/classroom-roadmap.md
@@ -244,13 +244,13 @@ Canonical contract: `docs/preproduction-hardening.md`.
Merged via PR #332 as `8ad3eb2b80d080d244556b9280a0fd2f83565193`. Removed the legacy Student two-code access path, the public Instructor access-code recovery form, and obsolete save/local compatibility migrations that exist only because of pre-production development history. Keep the current one-code Student admission + stable class-session model, current same-device Instructor resume, and intentional Standalone collaboration. This is explicitly allowed to be breaking because there are no production users to migrate.
-**#329 — Administration / Maintenance lifecycle cleanup — ACTIVE**
+**#329 — Administration / Maintenance lifecycle cleanup — COMPLETE**
-Draft PR #333 implements the canonical contract in `docs/admin-maintenance.md`: a separately authorized maintenance surface that inventories Classroom and Standalone collaboration data, derives idle/expired state from server timestamps + recent presence, previews destructive cleanup, and safely purges selected or bulk stale records. Administration is not a fourth Intake experience role. It also becomes the intentional recovery path for rotating/reissuing Instructor authority after #328 removes the public Instructor access-code form.
+Merged PR #333 as `b04684f2432aa474cb84874aa26aa08a5f4c5ab7` implements the canonical contract in `docs/admin-maintenance.md`: a separately authorized maintenance surface that inventories Classroom and Standalone collaboration data, derives idle/expired state from server timestamps + recent presence, previews destructive cleanup, and safely purges selected or bulk stale records. Administration is not a fourth Intake experience role. It also becomes the intentional recovery path for rotating/reissuing Instructor authority after #328 removes the public Instructor access-code form.
-**#330 — Startup experience hub + explicit resume**
+**#330 — Startup experience hub + explicit resume — ACTIVE**
-Replace implicit role restoration with a startup hub that offers **Continue where you left off** when valid saved/resumable context exists, plus **Work independently**, **Join a class**, **Run a class**, and secondary **Administration / Maintenance** entry. Cached Intake, Student resume, and Instructor resume are presented as separate explicit choices rather than silently selecting a role.
+Draft PR #334 replaces implicit role restoration with a startup hub that offers **Continue where you left off** when valid saved/resumable context exists, plus **Work independently**, **Join a class**, **Run a class**, and secondary **Administration / Maintenance** entry. Cached Intake, Student resume, and Instructor resume are presented as separate explicit choices rather than silently selecting a role.
### P1 — production simulation enablement
@@ -278,7 +278,7 @@ PRODUCTION SIMULATION ENABLEMENT
#317 first production staged KT Case Study
```
-**Current product decision:** #328 is complete. **#329 Administration / Maintenance is active on draft PR #333**; complete and production-verify it next, then proceed to #330. Do not start #316/#317 first; their architecture should be built on the finalized compatibility, maintenance, and startup contracts.
+**Current product decision:** #328 and #329 are complete. **#330 Startup experience hub is active on draft PR #334**. Finish and production-verify #330 before #316/#317 so rich staged assets build on finalized compatibility, maintenance, and startup contracts.
## Stacked PR rules
From b5faa85604b82b29b457ec8cb6825609a67c3167 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:12:25 -0400
Subject: [PATCH 27/47] Checkpoint active startup hub work
---
docs/classroom-workstream.md | 80 ++++++++++++++++++------------------
1 file changed, 40 insertions(+), 40 deletions(-)
diff --git a/docs/classroom-workstream.md b/docs/classroom-workstream.md
index 902c4eb..22f9080 100644
--- a/docs/classroom-workstream.md
+++ b/docs/classroom-workstream.md
@@ -6,11 +6,11 @@ This is the live restart document for the Classroom Experience program (#288).
## Current phase
-The secure Classroom foundation (#288), live-class management (#312), staged-simulation engine (#313), universal target layer (#318), debrief comparison (#319), join/mobile polish (#320), and pre-production compatibility reset (#328) are **merged and published**.
+The secure Classroom foundation (#288), live-class management (#312), staged-simulation engine (#313), universal target layer (#318), debrief comparison (#319), join/mobile polish (#320), compatibility reset (#328), and Administration / Maintenance lifecycle (#329) are **merged and published**.
-#328 completed in PR #332, squash-merged to `main` as `8ad3eb2b80d080d244556b9280a0fd2f83565193`. The exact merged SHA reached Vercel production **READY**, GitHub Vercel status was success, and the post-deploy runtime-error scan was clean.
+#329 completed in PR #333, squash-merged to `main` as `b04684f2432aa474cb84874aa26aa08a5f4c5ab7`. The exact merged SHA reached Vercel production **READY** and the post-deploy runtime error/warning scan was clean. Existing Vercel Authentication prevents an unauthenticated automated probe from reaching `/api/admin`; Deployment Protection was intentionally not weakened.
-**#329 Administration / Maintenance is the active implementation slice** on `feature/admin-maintenance` / draft PR #333. Canonical contracts: `docs/preproduction-hardening.md` and `docs/admin-maintenance.md`.
+**#330 Startup experience hub is the active implementation slice** on `feature/startup-experience-hub` / draft PR #334. Governing contract: `docs/preproduction-hardening.md`.
## Program issues
@@ -33,8 +33,8 @@ The secure Classroom foundation (#288), live-class management (#312), staged-sim
| Class debrief comparison | #319 | Complete / published | PR #326 merged as `400a6c5e...` | 319A–F complete; exact merged-main production READY + runtime clean |
| Join/mobile polish | #320 | Complete | PR #324 merged as `273ae643...` | Safe share/QR + compact mobile Class/Case/Team/Notes/Instructor chrome |
| Compatibility reset | #328 | Complete / published | PR #332 merged as `8ad3eb2b...` | Current one-code/session model only; obsolete Classroom access/schema and pre-production save migrations removed |
-| Admin / Maintenance | #329 | **Active implementation** | Draft PR #333 / `feature/admin-maintenance` | Server-authorized inventory, recovery, signed preview, guarded purge, responsive maintenance UI |
-| Startup experience hub | #330 | Planned / blocked by #329 | Open | Explicit Continue / Work independently / Join / Run / Admin choices |
+| Admin / Maintenance | #329 | Complete / published | PR #333 merged as `b04684f2...` | Server-authorized inventory, recovery, signed preview, guarded purge, responsive maintenance UI |
+| Startup experience hub | #330 | **Active implementation** | Draft PR #334 / `feature/startup-experience-hub` | Explicit Continue / Work independently / Join / Run / Admin choices |
| Rich staged assets | #316 | Deferred until hardening complete | Open | Secure image/table/document-page delivery |
| First production staged case | #317 | Deferred / blocked by #316 | Open | Source-faithful official case authoring/rehearsal |
@@ -49,6 +49,7 @@ The secure Classroom foundation (#288), live-class management (#312), staged-sim
- Case Study password is instructional gating, not authentication.
- Protected Case Study metadata/payloads are server-gated, absent from public browser assets, and authored `templates/*.json` is excluded from Vercel deployment.
- Administration / Maintenance is a separate privileged utility, not a fourth experience role. It uses the environment-only `INTAKE_ADMIN_TOKEN`, tab-scoped `sessionStorage`, non-secret maintenance IDs, and signed preview-before-purge semantics documented in `docs/admin-maintenance.md`.
+- Startup never silently resumes from `kt-experience-role-v1`. Substantive saved Intake, Student resume, and Instructor resume are detected independently and offered as explicit Continue choices; only explicit `?workspace=` Standalone authority bypasses the hub.
See `docs/classroom-architecture.md` for the full contract.
@@ -75,52 +76,51 @@ See `docs/classroom-architecture.md` for the full contract.
## Active work
-**#329 — Administration / Maintenance lifecycle cleanup** is active on `feature/admin-maintenance` / draft PR #333.
+**#330 — Startup experience hub + explicit resume** is active on `feature/startup-experience-hub` / draft PR #334.
-Base: `main` at completed #328 merge `8ad3eb2b80d080d244556b9280a0fd2f83565193`.
+Base: `main` at completed #329 merge `b04684f2432aa474cb84874aa26aa08a5f4c5ab7`.
-### 329A — server lifecycle boundary: complete
+### Startup detection and routing: implemented
-- one deployable `api/admin.js` entrypoint backed by `api/_admin.js`;
-- environment-only 43-character `INTAKE_ADMIN_TOKEN`; missing/malformed configuration fails closed;
-- constant-time Admin credential comparison; no-store/no-referrer responses;
-- collaboration workspaces gain a non-secret UUID `public_id` for maintenance identity;
-- server inventory derives Class and collaboration activity from authoritative update + presence timestamps and returns no Intake snapshots or bearer capabilities;
-- inventory includes class lifecycle, participant/workspace/presence/subordinate counts, current staged exercise context, workspace ownership, and derived idle age;
-- Admin can close/revoke a class immediately;
-- Admin can rotate/reissue active Instructor authority, returning the new raw capability once;
-- physical cleanup is preview-first with a signed 10-minute exact candidate plan;
-- commit re-reads inventory, recomputes candidate fingerprints, and fails with conflict if activity/state changed;
-- bulk class/workspace delete SQL rechecks the complete requested set before mutation;
-- independent collaboration cleanup cannot delete a Classroom-owned workspace;
-- class purge deletes class-owned collaboration workspaces first and relies on FK cascades for capabilities/presence/Classroom subordinate data;
-- Admin adds exactly one deployable Vercel function, taking the expected repository count from 5 to 6 while remaining below the conservative limit of 12;
-- deterministic `tests/admin-api.unit.test.mjs` covers configuration/auth rejection, private headers, no secret leakage, recent-presence protection, class-owned isolation, preview/commit matching, stale preview rejection, preview expiry, terminal-only single-class purge, class close, and Instructor rotation.
+- ordinary startup no longer auto-routes from `kt-experience-role-v1`;
+- explicit Standalone `?workspace=` authority still bypasses the hub;
+- safe `#join=
` is detected and highlights **Join a class** but is not consumed until normal Student routing;
+- `src/startupExperienceHub.js` derives three independent resumable contexts: substantive local Intake, current Student session, current Instructor session;
+- a deterministic substantive-Intake predicate ignores timestamp/presentation noise such as `savedAt`, bridge time, theme, Notes open state, table labels, action analysis IDs, and unchecked steps;
+- meaningful Intake mode, problem/impact/ops content, table evidence, causes/findings, checked steps, actions, handover notes, Notes items, and Major Incident analysis count as substantive;
+- expired Classroom envelopes are not shown as Continue cards.
-### 329B — browser maintenance console: implementation complete
+### Startup UI and intent: implemented
-- secondary **Administration / Maintenance** entry exists in the experience chooser and View menu without creating a new experience role;
-- verified Admin key is retained only in tab-scoped `sessionStorage` under `kt-admin-session-v1`;
-- UI provides refresh, sign out, search/filtering, lifecycle/ownership filters, selectable idle threshold, class close, Instructor reissue, single/bulk preview, exact confirmation, and responsive mobile layout;
-- recovery panel displays the newly rotated Instructor capability without persisting it;
-- Escape handling and keyboard focus containment are implemented;
-- `tests/e2e/admin-maintenance.spec.mjs` covers Admin/role isolation + no localStorage leakage + axe scan, exact destructive previews protecting active/class-owned data, and non-persistence of reissued Instructor credentials.
+- Continue cards appear independently for saved Intake, Student class, and Instructor class, including human-facing context;
+- Start / Join section provides **Work independently**, **Join a class**, and **Run a class**;
+- Administration / Maintenance remains a separate secondary privileged entry;
+- opening/reopening the hub does not delete cached Intake or Classroom resume state;
+- starting a fresh independent Intake requires confirmation when substantive local Intake exists;
+- choosing a different Student/Instructor experience confirms replacement of the same-type current resume; expired same-type envelopes are cleared only when the user explicitly chooses that new intent;
+- Student/Instructor lifecycle controllers remain authoritative for server revalidation;
+- terminal saved-session failure can reset the current presentation and return to the required hub with an explanation;
+- startup Continue buttons are keyboard controls, the existing chooser focus trap remains authoritative, mobile uses single-column Continue cards, and startup hover motion honors `prefers-reduced-motion`.
-### Documentation / external dependency
+### Coverage checkpoint
-- canonical contract: `docs/admin-maintenance.md`;
-- root/server agent guidance, SECURITY, README, AI onboarding, architecture map, commenting guide, and pre-production hardening docs are being reconciled in this PR;
-- **production remains intentionally fail-closed until `INTAKE_ADMIN_TOKEN` is configured in Vercel**. Do not put that value in GitHub or repository files.
+- new `tests/startupExperienceHub.unit.test.mjs` covers substantive/default-state classification and independent/expired resume detection;
+- new `tests/startupExperienceHub.feature.test.mjs` covers three simultaneous Continue cards, safe join-link bias, explicit replacement confirmation, fresh-independent confirmation, and explicit workspace precedence;
+- experience-role tests now prove saved preferences and join fragments do not silently route;
+- Standalone, Student, and Instructor browser reload journeys click their explicit Continue card;
+- integrated share-link browser flow clicks highlighted **Join a class** before normal admission;
+- expired Student browser coverage proves stale workspace context cannot silently resume;
+- fresh startup smoke runs desktop + Pixel 7 axe coverage.
+- First PR CI found only a persistence-guard classification issue for a dynamic local-only Continue button; fixed by making the local-only marker explicit on the creation line. Fresh exact-head validation is pending.
### Exact next action
-1. refresh PR #333 exact head and inspect required checks;
+1. refresh PR #334 exact head and required checks;
2. fix only concrete quality/unit/browser/security failures;
-3. finish any remaining documentation drift and update PR #333 with exact validation;
-4. configure a fresh production `INTAKE_ADMIN_TOKEN` through Vercel environment settings without committing/logging it;
-5. rerun/verify final required checks, mark PR #333 ready, and squash-merge only on an unchanged green head;
-6. verify exact merged-main Vercel production **READY**, exercise Admin authorization/inventory on production without destructive cleanup, scan runtime errors, close #329;
-7. proceed to #330 startup experience hub.
+3. finish README/architecture/onboarding/commenting/agent documentation reconciliation;
+4. once all required checks are green, update PR #334 with exact evidence, mark ready, and squash-merge the unchanged head;
+5. verify exact merged-main Vercel production **READY**, scan runtime errors, close #330;
+6. reassess #316/#317 as the next product work now that #328–#330 hardening is complete.
### Historical checkpoint ledger
From bf7ef681376b748f9cd546ef1956e52ef5b925f5 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:12:44 -0400
Subject: [PATCH 28/47] Document startup hub boot contract
---
docs/AI-ONBOARDING.md | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/docs/AI-ONBOARDING.md b/docs/AI-ONBOARDING.md
index 46bc50d..c90ca35 100644
--- a/docs/AI-ONBOARDING.md
+++ b/docs/AI-ONBOARDING.md
@@ -7,7 +7,7 @@ For any work in the Standalone / Student / Instructor program (#288), also read
## Entry Point & Boot Sequence
1. `index.html` renders the full layout and loads the ES module entry via ``.
2. `main.js` registers a `DOMContentLoaded` listener that calls `boot()`.
-3. `boot()` restores the independent product experience through `initExperienceRoleController()` before configuring the Intake feature modules. Existing saved Intakes / collaboration links become Standalone; genuinely new browsers receive the required chooser.
+3. `boot()` initializes `src/startupExperienceHub.js` and `initExperienceRoleController()`. Ordinary launch stays unselected and shows explicit Continue/new-session choices; only an explicit `?workspace=` collaboration capability routes directly to Standalone.
4. `boot()` then performs the Intake initialization:
- Wires the KT helpers by calling `configureKT({ autoResize, updatePrefaceTitles, showToast, getObjectFull, getDeviationFull })`.
- Initialises each feature module: `initPreface`, `initializeCommunications`, `initStepsFeature`, `initTable`, `ensurePossibleCausesUI`, and `renderCauses`.
@@ -20,8 +20,9 @@ For any work in the Standalone / Student / Instructor program (#288), also read
| ------ | ----------- |
| `src/appState.js` | `collectAppState()`, `applyAppState()`, `getSummaryState()` for round-trip UI testing and summary hydration. |
| `src/experienceRoles.js` | Canonical Standalone / Student / Instructor IDs, labels, and declarative product-surface policy. |
-| `src/experienceRoleController.js` | First-run chooser, current-format role resume/switching, join-link routing, and local-only `kt-experience-role-v1` preference. Pre-production raw-role and implicit saved-Intake migration compatibility is intentionally removed. |
+| `src/experienceRoleController.js` | Applies Standalone/Student/Instructor surface projection, owns the startup/View dialog focus lifecycle, and persists the local-only `kt-experience-role-v1` last-choice preference. The preference is not startup authority. |
| `src/classroomStudent.js` | One-code Student admission, Waiting/own-status polling, current class-session resume, assignment-specific memory-only workspace access/reassignment/unassign, and coaching/resource lifecycle hooks. |
+| `src/startupExperienceHub.js` | Detects substantive saved Intake and current Student/Instructor resumes independently, renders Continue cards, highlights safe `#join` intent, and owns explicit replacement confirmations. Delegates real class resume/revalidation to the Classroom controllers. |
| `src/adminMaintenance.js` | Administration / Maintenance dialog, tab-scoped Admin credential envelope, lifecycle inventory/filtering, recovery display, and signed preview/commit UX. It never sets an Intake experience role. |
| `src/classroomInstructor.js` | Start Class, human join-code display, same-device Instructor resume, live roster/team assignment management, read-only observation, and coaching lifecycle hooks. |
| `src/coachableFields.js` | Stable coaching target IDs and versioned field fingerprints; DOM placement is deliberately separate from persistence identity. |
From 84b7cd88ac0d2ab4a3bcc2bff0d242947177d5c1 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:12:50 -0400
Subject: [PATCH 29/47] Register startup hub feature contract
---
docs/commenting-guide.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/commenting-guide.md b/docs/commenting-guide.md
index c1a4f4e..aab8ff3 100644
--- a/docs/commenting-guide.md
+++ b/docs/commenting-guide.md
@@ -44,9 +44,9 @@ Anchors keep HTML and documentation in sync. Preserve existing tokens and regist
| `[feature:collaboration]` | `index.html`, Collaboration menu | Provides secret-link session actions, synchronization status, and conflict recovery choices in every intake mode. | `src/collaboration.js`, `api/workspaces/*` | `collaborationStatus`, session action buttons; recovery uses `kt-collaboration-recovery-v1` while intake snapshots remain under `kt-intake-full-v2`. |
| `[feature:collaboration-presence]` | `index.html`, in the workspace dock immediately before Notes workspace | Shows the editable shared team name and recently active participants, plus a compact mobile Team card and the accessible start/join/edit identity dialog. | `src/collaboration.js`, `api/workspaces/presence.js` | `collaborationWorkspace`, `collaborationWorkspaceToggle`, `collaborationWorkspaceCompactSummary`, `collaborationParticipants`, `collaborationDialog`; mobile expand/collapse is presentation-only, local identity preference uses `kt-collaboration-profile-v1`, team metadata stays server-side, and neither presentation nor metadata enters intake snapshots. |
| `[feature:templates-drawer]` | `index.html` header toolbar + Instructor shell launcher | Shared role-aware Templates / Case Studies drawer. Standalone renders public Standard Templates only; connected Student/Instructor roles receive the current authorized protected Case Study catalog in memory. Student fetches a protected payload only when applying it after the pedagogical unlock. | `src/templatesDrawer.js`, `src/templates.js`, `src/templateAvailability.js`, `src/classroomCaseStudies.js` | `templatesBtn`, `instructorTeachingResourcesBtn`, `templatesDrawer`, `templatesList`, `templatesModeSection`, `templatesAuthSection`, `templatesTeachingNotice`, `templatesDrawerFooter`; Classroom capability/catalog state is local-only and excluded from Intake persistence. |
-| `[feature:experience-role]` | `index.html`, immediately after the header | Accessible first-run/switching dialog for Standalone, Student, and Instructor. | `src/experienceRoles.js`, `src/experienceRoleController.js` | `experienceRoleGate`, `experienceRoleDialog`, `experienceRoleCancelBtn`; preference uses `kt-experience-role-v1` and is excluded from Intake state. |
+| `[feature:experience-role]` | `index.html`, immediately after the header | Accessible startup/View hub with independent Continue cards plus Work independently / Join / Run choices. | `src/startupExperienceHub.js`, `src/experienceRoles.js`, `src/experienceRoleController.js` | `experienceRoleGate`, `experienceRoleDialog`, `startupContinueSection`, `startupContinueList`, `startupJoinIntentNotice`, `startupHubStatus`, `experienceRoleCancelBtn`; startup intent is local navigation state, `kt-experience-role-v1` is last-choice only, and neither enters Intake state. |
| `[feature:admin-maintenance]` | `index.html`, immediately after the experience chooser | Privileged maintenance overlay for server lifecycle inventory, Instructor recovery, and preview-first cleanup. | `src/adminMaintenance.js`, `api/admin.js`, `api/_admin.js` | `adminMaintenanceGate`, `adminMaintenanceDialog`, `adminMaintenanceAuthForm`, `adminMaintenanceConsole`, `adminPurgePreview`, `adminRecoveryPanel`; Admin credential is tab-scoped under `kt-admin-session-v1` and excluded from Intake state. |
-| `[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:experience-role-switch]` | `index.html`, View menu | Reopens the startup hub without resetting Intake/Classroom data; optional dismissal returns to the current experience. | `src/startupExperienceHub.js`, `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; valid `#join=` intents prefill this normal admission surface and are removed client-side. | `src/classroomStudent.js`, `src/classroomJoinLink.js`, `src/experienceRoleController.js` | `studentClassEntryShell`, `studentClassJoinForm`, class/assignment/name inputs, retry controls; join intent contains no bearer capability and all controls remain local-only/summary-excluded. |
| `[feature:student-experience-notice]` | `index.html`, top of the connected Student Intake | Class/workspace/participant context plus explicit Leave class; narrow screens default to a compact class summary. | `src/classroomStudent.js`, `src/experienceRoleController.js` | `studentExperienceNotice`, `studentClassContextTitle`, `studentClassCompactSummary`, `studentClassContextToggle`, `studentClassWorkspace`, `studentClassIdentity`, `studentClassLeaveBtn`; mobile expansion is presentation-only and no Class-context field is persisted in Intake state. |
| `[feature:student-case-reference]` | `index.html`, between Classroom shells and the connected Intake | Student-only staged Case Study reference for the current exercise, using only Student-authorized released material. | `src/classroomExerciseStudent.js`, `src/classroomStudent.js` session lifecycle | `studentCaseReference`, title/status/current stage/objective, released-content list, frozen-debrief message, Collapse/Open control; stable Student class-session capability and staged content remain memory-only, future/Instructor content is not projected, mobile defaults collapsed, and the surface is summary/persistence-excluded. |
From 5a2af449a668557c01e27df33e4a4797966ed82f Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:12:57 -0400
Subject: [PATCH 30/47] Add startup hub markup guardrails
---
index.AGENTS.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/index.AGENTS.md b/index.AGENTS.md
index 8bc6791..73409e1 100644
--- a/index.AGENTS.md
+++ b/index.AGENTS.md
@@ -10,7 +10,7 @@ This file applies to `index.html`. Follow these instructions when editing any po
- `[header]` & `[section:*]` – Visual cards for each workflow stage (bridge activation, problem summary, evidence, baseline/current, impact, communications, KT table, possible causes, steps, summary export).
- `[subtitle]` – Single `h2` below the preface stack used by `src/preface.js` / `src/summary.js` to mirror the narrative subtitle stored with `kt-intake-full-v2`.
- `[feature:templates-drawer]` – Shared Templates / Case Studies drawer owned by `src/templatesDrawer.js` and `src/templateAvailability.js`. Preserve the role policy: Standalone = Templates only, Student = Templates + Case Studies, Instructor = teaching-only Case Studies. Do not infer confidentiality from hidden UI; #295 owns server-gated Case Study delivery.
-- `[feature:experience-role]` / `[feature:experience-role-switch]` – First-run role chooser and View-menu switch owned by `src/experienceRoleController.js`.
+- `[feature:experience-role]` / `[feature:experience-role-switch]` – Startup/View hub owned jointly by `src/startupExperienceHub.js` and `src/experienceRoleController.js`. Keep saved Intake, Student resume, and Instructor resume as separate Continue choices. `kt-experience-role-v1` is last-choice presentation state, not startup authority; only an explicit `?workspace=` capability bypasses the hub.
- `[feature:student-class-entry]` / `[feature:student-experience-notice]` – Student join/reconnect gate and connected class context owned by `src/classroomStudent.js`. A valid client-only `#join=` intent may prefill the normal class-code field through `src/classroomJoinLink.js`; it never bypasses normal admission or replaces a saved Student class session. On narrow screens the connected Class context defaults to a compact presentation with `studentClassContextToggle`; that expansion state is local presentation only and must not enter the Student resume envelope or Intake state. All classroom inputs/context are local-only and summary-excluded; class codes and workspace capabilities must never enter Intake state.
- `[feature:collaboration-presence]` / `[feature:notes-workspace]` – The Team/people and Notes surfaces share the secondary workspace dock. On narrow screens each defaults compact but remains one-action accessible. Team expansion is presentation-only and must not change collaboration state; Notes mobile expansion is presentation-only and must not rewrite the persisted desktop `notesWorkspace.open` preference. Keep the Student dock in normal flow on mobile rather than adding competing fixed overlays.
- `[feature:instructor-shell]` / `[feature:instructor-observer]` – Instructor class entry/roster and live read-only observation owned by `src/classroomInstructor.js`. The join-code panel may expose **Copy code**, **Share class**, and **Show/Hide QR**. Share class uses `src/classroomJoinLink.js`; QR uses the local `src/classroomJoinQr.js`; both may carry only the same fragment-based human-code URL and never bearer authority. The QR SVG contains only local vector modules plus accessible human-code labeling, not the raw URL in attributes. Instructor class/session controls are local-only. Observed Student snapshots may render through the existing Intake DOM, but the observer must preserve local storage around rehydration and re-project Student-owned controls read-only; never save observed Student state as the Instructor's Intake.
@@ -32,7 +32,7 @@ Preserve the order of these anchors. If you need a new section, duplicate the ex
- Immutable data such as `ROWS`, `CAUSE_FINDING_MODES`, and `STEP_DEFINITIONS` live in `src/constants.js`. Update them cautiously and ensure each change flows through summary generation and persistence.
- `collectAppState()` and `applyAppState()` coordinate the round-trip of UI state. When you add new fields, hook them into those helpers plus the serialization logic in `src/storage.js`.
- Local storage uses the key `kt-intake-full-v2`. Keep this identifier stable, but accept only the current `APP_STATE_VERSION`; pre-production historical snapshot versions are intentionally unsupported after #328.
-- Experience role is an explicit exception to Intake persistence: `kt-experience-role-v1` is owned by `src/experienceRoleController.js`.
+- Experience role is an explicit exception to Intake persistence: `kt-experience-role-v1` is owned by `src/experienceRoleController.js` and is a last-choice preference only. Startup resume detection belongs to `src/startupExperienceHub.js` and must never be serialized into Intake.
- Student classroom session/recovery are also outside Intake persistence: `kt-classroom-student-session-v1` and `kt-classroom-student-local-recovery-v1` are owned by `src/classroomStudent.js`. Never collect these keys or any classroom capability into `kt-intake-full-v2`, file exports, summaries, or templates.
- Instructor classroom resume is outside Intake persistence: `kt-classroom-instructor-session-v1` is owned by `src/classroomInstructor.js`. It may contain the raw Instructor class capability for same-device resume, so it must never enter `kt-intake-full-v2`, file exports, summaries, templates, URLs, or editable collaboration aliases.
From 1b5d1fc7104175c4c2093144ade912a1dd151b2e Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:14:32 -0400
Subject: [PATCH 31/47] Activate Student explicitly in Classroom feature
harness
---
tests/classroomStudent.feature.test.mjs | 1 +
1 file changed, 1 insertion(+)
diff --git a/tests/classroomStudent.feature.test.mjs b/tests/classroomStudent.feature.test.mjs
index df3b566..7ad83cb 100644
--- a/tests/classroomStudent.feature.test.mjs
+++ b/tests/classroomStudent.feature.test.mjs
@@ -82,6 +82,7 @@ function mount({
storage: dom.window.localStorage,
location: dom.window.location
});
+ applyExperienceRole(EXPERIENCE_ROLE_IDS.STUDENT, { persist: false, announce: false });
const calls = { connect: [], leave: [], apply: [], save: [], fetch: [], timers: [] };
const collaborationState = {
From 7b3125f549780664768a78a2d30015f9558f8181 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:14:39 -0400
Subject: [PATCH 32/47] Activate Instructor explicitly in Classroom feature
harness
---
tests/classroomInstructor.feature.test.mjs | 1 +
1 file changed, 1 insertion(+)
diff --git a/tests/classroomInstructor.feature.test.mjs b/tests/classroomInstructor.feature.test.mjs
index e1aa0d8..d130c1c 100644
--- a/tests/classroomInstructor.feature.test.mjs
+++ b/tests/classroomInstructor.feature.test.mjs
@@ -128,6 +128,7 @@ function setup(fetchImpl, {
storage: dom.window.localStorage,
location: dom.window.location
});
+ applyExperienceRole(EXPERIENCE_ROLE_IDS.INSTRUCTOR, { persist: false, announce: false });
dom.window.document.getElementById('oneLine').value = 'Local before observation';
dom.window.localStorage.setItem('sentinel', 'keep-me');
From 1cc0d08b25b96e3009262e467b721a7ddd4fc56b Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:15:11 -0400
Subject: [PATCH 33/47] Activate Standalone explicitly in resource feature
harness
---
tests/templatesRoleAvailability.feature.test.mjs | 1 +
1 file changed, 1 insertion(+)
diff --git a/tests/templatesRoleAvailability.feature.test.mjs b/tests/templatesRoleAvailability.feature.test.mjs
index c8f8aae..676939a 100644
--- a/tests/templatesRoleAvailability.feature.test.mjs
+++ b/tests/templatesRoleAvailability.feature.test.mjs
@@ -40,6 +40,7 @@ initExperienceRoleController({
storage: dom.window.localStorage,
location: dom.window.location
});
+applyExperienceRole(EXPERIENCE_ROLE_IDS.STANDALONE, { persist: false, announce: false });
const protectedProvider = {
getCatalog: () => [{
id: 'authorized-case',
From 82f3cb33b13ac4781be3014106272af8d0f8c3f0 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:15:17 -0400
Subject: [PATCH 34/47] Document explicit startup selection contract
---
src/experienceRoleController.js | 11 ++++++-----
1 file changed, 6 insertions(+), 5 deletions(-)
diff --git a/src/experienceRoleController.js b/src/experienceRoleController.js
index bd6df86..0161cd8 100644
--- a/src/experienceRoleController.js
+++ b/src/experienceRoleController.js
@@ -432,18 +432,19 @@ function bindRoleControls() {
}
/**
- * Initialize experience-role resume and first-run selection.
+ * Initialize explicit startup experience selection.
*
- * Explicit Standalone collaboration links retain Standalone routing. Otherwise
- * only a current stored preference resumes automatically; unselected contexts
- * receive the required chooser.
+ * Explicit Standalone collaboration links retain direct Standalone routing.
+ * Ordinary launches remain unselected so the startup hub can present saved
+ * Intake/Classroom contexts as separate Continue choices. The stored role
+ * preference is retained as last-choice metadata only and never auto-routes.
*
* @param {object} [options] - Dependency overrides for tests or embedded contexts.
* @param {Document} [options.documentRef=document] - Mounted document.
* @param {Window} [options.windowRef=window] - Mounted window.
* @param {Storage|null} [options.storage=localStorage] - Local preference storage.
* @param {Location|{search:string}} [options.location=window.location] - Current location.
- * @returns {string|null} Resumed/applied role, or null while first-run choice is pending.
+ * @returns {string|null} Applied explicit-workspace role, or null while startup choice is pending.
*/
export function initExperienceRoleController({
documentRef: nextDocument = typeof document !== 'undefined' ? document : null,
From 8009a2788e743745a3e426fb3be5e74595af8157 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:16:14 -0400
Subject: [PATCH 35/47] Cover failed Student resume recovery to startup
---
tests/e2e/classroom-authorization.spec.mjs | 40 ++++++++++++++++++++++
1 file changed, 40 insertions(+)
diff --git a/tests/e2e/classroom-authorization.spec.mjs b/tests/e2e/classroom-authorization.spec.mjs
index 834df0f..73cf0b2 100644
--- a/tests/e2e/classroom-authorization.spec.mjs
+++ b/tests/e2e/classroom-authorization.spec.mjs
@@ -52,6 +52,46 @@ test('well-formed non-Instructor authority cannot read an Instructor class', asy
expect(body.workspaces).toBeUndefined();
});
+test('server-rejected saved Student resume returns to startup with a clear recovery message', async ({ page }) => {
+ await startFresh(page);
+
+ await page.evaluate(key => {
+ window.localStorage.setItem(key, JSON.stringify({
+ version: 2,
+ mode: 'live',
+ class: {
+ id: 'missing-browser-class',
+ title: 'Unavailable Browser Classroom',
+ expiresAt: '2099-12-31T23:59:59.000Z'
+ },
+ participant: {
+ id: '77777777-7777-4777-8777-777777777777',
+ displayName: 'Resume Student'
+ },
+ studentSessionToken: 'q'.repeat(43),
+ assignmentRevision: 1,
+ assignment: null,
+ joinedAt: '2026-10-08T20:00:00.000Z'
+ }));
+ }, STUDENT_SESSION_STORAGE_KEY);
+
+ await page.reload();
+
+ await expect(page.locator('#experienceRoleGate')).toBeVisible();
+ await expect(page.locator('[data-startup-resume="student"]')).toContainText('Unavailable Browser Classroom');
+
+ await page.locator('[data-startup-resume="student"]').click();
+
+ await expect(page.locator('#experienceRoleGate')).toBeVisible({ timeout: 10000 });
+ await expect(page.locator('body')).toHaveAttribute('data-experience-role', 'unselected');
+ await expect(page.locator('#startupHubStatus')).toBeVisible();
+ await expect(page.locator('#startupHubStatus')).toContainText(/no longer available|current class code/i);
+ await expect(page.locator('[data-startup-resume="student"]')).toHaveCount(0);
+
+ const storedAfterFailure = await page.evaluate(key => window.localStorage.getItem(key), STUDENT_SESSION_STORAGE_KEY);
+ expect(storedAfterFailure).toBeNull();
+});
+
test('expired saved Student access is discarded before stale workspace content can resume', async ({ page }, testInfo) => {
await startFresh(page);
await page.getByRole('button', { name: /Join a class/ }).click();
From b9ed08ed7c76a4cf2de5ccc5cd6873a1c8178d03 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:16:37 -0400
Subject: [PATCH 36/47] Document startup hub capability separation
---
SECURITY.md | 3 +++
1 file changed, 3 insertions(+)
diff --git a/SECURITY.md b/SECURITY.md
index 4e73ce8..0f1f5da 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -25,6 +25,9 @@ Administration / Maintenance is a separate privileged boundary documented in `do
Same-device Student resume stores the high-entropy **Student class-session capability** under `kt-classroom-student-session-v1` together with public class/participant/current-assignment context. The human join code is discarded after admission. The current assignment-specific workspace capability is **memory-only** and must be reacquired after reload or reassignment; it must not be written into the live resume envelope or URL. #328 intentionally rejects older pre-production Student session-envelope formats. No Student resume envelope may enter Intake state, exports, summaries, templates, analytics, logs, or error telemetry.
+Startup presentation does not change these capability rules. `src/startupExperienceHub.js` may read current local resume envelopes only to decide whether to show a Continue card and to display non-secret context such as class title, participant display name, or workspace label. It must never render, copy, log, place in a URL, or otherwise disclose Student/Instructor bearer values. `kt-experience-role-v1` is last-choice metadata only and is never authorization or automatic startup authority. A safe `#join=` fragment may highlight normal Student admission but remains admission-only and cannot substitute for Student session/workspace authority.
+
+
For same-device Instructor resume, the browser retains the Instructor class capability under `kt-classroom-instructor-session-v1` together with public class metadata, the human Student join code, and the last selected public workspace ID. This credential may administer/list/observe only its represented class through Instructor classroom APIs. It must never enter Intake state, exports, summaries, templates, URLs, analytics, logs, error telemetry, or `collaboration_workspace_capabilities`.
The human Student join code and Student class-session capability must never enumerate class workspaces or edit collaboration. Student own-status returns only the represented participant's assignment. Reassignment/unassign must revoke old workspace authority before destination/current access is issued, and stale old-team tokens must fail rather than map to the new team. Cross-class assignments fail without revealing unrelated class state. Instructor roster/workspace listing is scoped to the represented class.
From 5a439cf1c17da57c135b82eaeed17d487da344b8 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:16:43 -0400
Subject: [PATCH 37/47] Freeze startup hub role-routing invariant
---
AGENTS.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/AGENTS.md b/AGENTS.md
index 3fda9ae..5bcabc1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -42,6 +42,7 @@ The Standalone / Student / Instructor program is tracked by #288. Any work touch
Classroom invariants:
- experience role (`standalone | student | instructor`) is independent from intake mode and template kind;
+- startup intent is explicit: `kt-experience-role-v1` is last-choice metadata only; substantive saved Intake, Student resume, and Instructor resume remain separate Continue contexts, and only explicit `?workspace=` Standalone authority bypasses the startup hub;
- experience/class/session state never enters `kt-intake-full-v2`, exported Intake files, summaries, or curated Intake payloads;
- Standalone remains backend-optional;
- Student/team editing reuses the existing collaboration snapshot/revision/presence engine;
From 79705e15f9fa1e6be55611433d35fe316575ff87 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:17:22 -0400
Subject: [PATCH 38/47] Reconcile Classroom lifecycle with startup hub
---
docs/classroom-architecture.md | 20 +++++++++++---------
1 file changed, 11 insertions(+), 9 deletions(-)
diff --git a/docs/classroom-architecture.md b/docs/classroom-architecture.md
index 545237e..72dafb1 100644
--- a/docs/classroom-architecture.md
+++ b/docs/classroom-architecture.md
@@ -81,18 +81,20 @@ The **Classroom Workspace** references the existing collaboration workspace rath
## Experience role lifecycle
-Slice #289 implements the role foundation with:
+Slice #289 established the role foundation and #330 now owns startup intent:
-- canonical `standalone`, `student`, and `instructor` roles in `src/experienceRoles.js`;
-- a versioned local-only `kt-experience-role-v1` preference owned by `src/experienceRoleController.js`;
-- a required first-run chooser for a genuinely new browser;
-- silent Standalone migration when an existing `kt-intake-full-v2` snapshot or existing `?workspace=` collaboration URL is present;
-- **View → Experience** as the durable route back to role selection;
-- declarative `data-experience-surface` projection rather than Intake-mode conditionals.
+- canonical `standalone`, `student`, and `instructor` roles live in `src/experienceRoles.js`;
+- a versioned local-only `kt-experience-role-v1` last-choice preference is owned by `src/experienceRoleController.js`;
+- `src/startupExperienceHub.js` independently detects substantive saved Intake, current Student resume, and current Instructor resume and renders explicit Continue cards;
+- ordinary launch remains unselected until the user chooses Continue / Work independently / Join a class / Run a class;
+- a safe `#join=` fragment highlights Join but does not bypass admission;
+- only an explicit `?workspace=` collaboration capability routes directly to Standalone;
+- **View → Experience** reopens the same hub without destroying current Intake/Classroom state;
+- declarative `data-experience-surface` projection remains independent from Intake mode.
-Returning users resume the stored role. **Start Fresh** deliberately clears Intake state without clearing the role preference.
+**Start Fresh** clears Intake state without clearing the last-choice role preference, but that preference no longer auto-routes a later launch.
-The local experience preference is **not authentication or authorization**. Student class admission and Instructor privileges remain future server-enforced capabilities (#291 onward). Role/session preferences must never alter Intake serialization.
+The local experience preference and startup hub are **not authentication or authorization**. Student class admission and Instructor privileges remain server-enforced capabilities. Resume cards may display only non-secret local context and role/session navigation must never alter Intake serialization.
### Standalone
From 8d0818cd081bdff2aee6016f32e473b3a518acfa Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Fri, 9 Oct 2026 02:40:51 -0400
Subject: [PATCH 39/47] Detect substantive Major Incident state semantically
---
src/startupExperienceHub.js | 45 ++++++++++++++++++++++++++-----------
1 file changed, 32 insertions(+), 13 deletions(-)
diff --git a/src/startupExperienceHub.js b/src/startupExperienceHub.js
index 1dfc6f4..135e009 100644
--- a/src/startupExperienceHub.js
+++ b/src/startupExperienceHub.js
@@ -8,7 +8,6 @@
* controllers by applying the corresponding experience role.
*/
-import { APP_STATE_VERSION } from './appStateVersion.js';
import {
clearInstructorSession,
isInstructorSessionExpired,
@@ -30,7 +29,6 @@ import { EXPERIENCE_ROLE_IDS } from './experienceRoles.js';
import { DEFAULT_INTAKE_MODE } from './intakeModes.js';
import { STORAGE_KEY, migrateAppState } from './storage.js';
-const BASELINE_STATE = migrateAppState({ meta: { version: APP_STATE_VERSION } });
let documentRef = null;
let windowRef = null;
@@ -87,18 +85,39 @@ function meaningfulHandover(handover) {
));
}
-function stable(value) {
- try {
- return JSON.stringify(value ?? null);
- } catch {
- return '';
- }
-}
-
function meaningfulMajorAnalysis(snapshot) {
- if (!BASELINE_STATE) return false;
- return stable(snapshot?.decisionAnalysis) !== stable(BASELINE_STATE.decisionAnalysis)
- || stable(snapshot?.potentialProblemAnalysis) !== stable(BASELINE_STATE.potentialProblemAnalysis);
+ const decision = snapshot?.decisionAnalysis || {};
+ if (anyNonEmpty([
+ decision.decision,
+ decision.options,
+ decision.selectedOption,
+ decision.delegatedOwner,
+ decision.rationale,
+ decision.timestamp
+ ])) return true;
+ if (nonEmpty(decision.ownerRole) && decision.ownerRole.trim() !== 'Application Owner') return true;
+
+ const potential = snapshot?.potentialProblemAnalysis || {};
+ const owner = potential.owner || {};
+ if (anyNonEmpty([
+ owner.name,
+ owner.category,
+ owner.subOwner,
+ owner.notes,
+ owner.lastAssignedBy,
+ owner.lastAssignedAt
+ ])) return true;
+ if (nonEmpty(owner.source) && owner.source.trim() !== 'Manual') return true;
+
+ const risk = potential.risk || {};
+ if (nonEmpty(risk.level) && risk.level.trim() !== 'None') return true;
+ if (anyNonEmpty([risk.impactIfFails, risk.prevent, risk.ifHappens])) return true;
+
+ const changeControl = potential.changeControl || {};
+ if (changeControl.required === true || nonEmpty(changeControl.rollbackPlan)) return true;
+
+ const verification = potential.verification || {};
+ return verification.required === true || nonEmpty(verification.result);
}
/**
From 3f3cfad3a3b350d90f07a60f662e5d3f7fbdf9cb Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Fri, 9 Oct 2026 02:41:13 -0400
Subject: [PATCH 40/47] Cover real blank Major Incident defaults
---
tests/startupExperienceHub.unit.test.mjs | 51 ++++++++++++++++++++++++
1 file changed, 51 insertions(+)
diff --git a/tests/startupExperienceHub.unit.test.mjs b/tests/startupExperienceHub.unit.test.mjs
index 9055b54..5a8156e 100644
--- a/tests/startupExperienceHub.unit.test.mjs
+++ b/tests/startupExperienceHub.unit.test.mjs
@@ -91,6 +91,40 @@ test('empty/default persistence noise is not a substantive saved Intake', () =>
actions: {
analysisId: 'generated-analysis-id',
items: []
+ },
+ decisionAnalysis: {
+ decision: '',
+ options: '',
+ selectedOption: '',
+ ownerRole: 'Application Owner',
+ delegatedOwner: '',
+ rationale: '',
+ timestamp: ''
+ },
+ potentialProblemAnalysis: {
+ owner: {
+ name: '',
+ category: '',
+ subOwner: '',
+ notes: '',
+ lastAssignedBy: '',
+ lastAssignedAt: '',
+ source: 'Manual'
+ },
+ risk: {
+ level: 'None',
+ impactIfFails: '',
+ prevent: '',
+ ifHappens: ''
+ },
+ changeControl: {
+ required: false,
+ rollbackPlan: ''
+ },
+ verification: {
+ required: false,
+ result: ''
+ }
}
};
@@ -116,6 +150,23 @@ test('meaningful user-authored Intake fields create a Continue candidate', () =>
assert.equal(isSubstantiveSavedIntake({
meta: { version: APP_STATE_VERSION, intakeMode: 'it' }
}), true);
+
+ assert.equal(isSubstantiveSavedIntake({
+ meta: { version: APP_STATE_VERSION },
+ decisionAnalysis: {
+ ownerRole: 'Application Owner',
+ decision: 'Choose the rollback path'
+ }
+ }), true);
+
+ assert.equal(isSubstantiveSavedIntake({
+ meta: { version: APP_STATE_VERSION },
+ potentialProblemAnalysis: {
+ risk: { level: 'High' },
+ changeControl: { required: false, rollbackPlan: '' },
+ verification: { required: false, result: '' }
+ }
+ }), true);
});
test('readSubstantiveSavedIntake accepts only current valid meaningful snapshots', () => {
From 91051b49ef8bf6b40930bee83920a4bbe8e8b7db Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Fri, 9 Oct 2026 02:55:00 -0400
Subject: [PATCH 41/47] Diagnose mobile Notes activation and explicit reload
resume
---
tests/e2e/standalone.spec.mjs | 19 +++++++++++++++++--
1 file changed, 17 insertions(+), 2 deletions(-)
diff --git a/tests/e2e/standalone.spec.mjs b/tests/e2e/standalone.spec.mjs
index 26f924b..114345e 100644
--- a/tests/e2e/standalone.spec.mjs
+++ b/tests/e2e/standalone.spec.mjs
@@ -160,11 +160,21 @@ test('mobile Standalone accepts primary Intake input and persists it', async ({
}, INTAKE_STORAGE_KEY)).toBe(problem);
const notesWorkspace = page.locator('#notesWorkspace');
+ const notesToggle = page.getByRole('button', { name: 'Open notes' });
await expect(notesWorkspace).toHaveClass(/is-collapsed/);
- await expect(page.getByRole('button', { name: 'Open notes' })).toBeVisible();
+ await expect(notesToggle).toBeVisible();
+ expect(await page.evaluate(() => window.matchMedia('(max-width: 700px)').matches)).toBe(true);
const beforeNotesToggle = await page.evaluate(key => window.localStorage.getItem(key), INTAKE_STORAGE_KEY);
+ await page.evaluate(() => {
+ window.__notesToggleClickCount = 0;
+ document.querySelector('#notesWorkspaceToggle')?.addEventListener('click', () => {
+ window.__notesToggleClickCount += 1;
+ });
+ });
- await page.getByRole('button', { name: 'Open notes' }).click();
+ await notesToggle.click();
+ expect(await page.evaluate(() => window.__notesToggleClickCount)).toBe(1);
+ await expect(notesToggle).toHaveAttribute('aria-expanded', 'true');
await expect(notesWorkspace).not.toHaveClass(/is-collapsed/);
await page.getByRole('button', { name: 'Collapse notes' }).click();
await expect(notesWorkspace).toHaveClass(/is-collapsed/);
@@ -181,6 +191,11 @@ test('mobile Standalone accepts primary Intake input and persists it', async ({
await page.reload();
+ await expect(page.locator('#experienceRoleGate')).toBeVisible();
+ await expect(page.locator('body')).toHaveAttribute('data-experience-role', 'unselected');
+ await expect(page.locator('[data-startup-resume="intake"]')).toContainText('Continue your saved Intake');
+ await page.locator('[data-startup-resume="intake"]').click();
+
await expect(page.locator('body')).toHaveAttribute('data-experience-role', 'standalone');
await expect(page.locator('#oneLine')).toHaveValue(problem);
await expect(page.locator('#notesWorkspace')).toHaveClass(/is-collapsed/);
From 13a856b44c60a5c2d755cdb347312a2c87336d81 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Fri, 9 Oct 2026 02:58:27 -0400
Subject: [PATCH 42/47] Use touch activation for mobile Notes browser coverage
---
tests/e2e/standalone.spec.mjs | 14 +++++---------
1 file changed, 5 insertions(+), 9 deletions(-)
diff --git a/tests/e2e/standalone.spec.mjs b/tests/e2e/standalone.spec.mjs
index 114345e..c0afc59 100644
--- a/tests/e2e/standalone.spec.mjs
+++ b/tests/e2e/standalone.spec.mjs
@@ -165,18 +165,14 @@ test('mobile Standalone accepts primary Intake input and persists it', async ({
await expect(notesToggle).toBeVisible();
expect(await page.evaluate(() => window.matchMedia('(max-width: 700px)').matches)).toBe(true);
const beforeNotesToggle = await page.evaluate(key => window.localStorage.getItem(key), INTAKE_STORAGE_KEY);
- await page.evaluate(() => {
- window.__notesToggleClickCount = 0;
- document.querySelector('#notesWorkspaceToggle')?.addEventListener('click', () => {
- window.__notesToggleClickCount += 1;
- });
- });
- await notesToggle.click();
- expect(await page.evaluate(() => window.__notesToggleClickCount)).toBe(1);
+ // Pixel 7 emulation should exercise the real touch path. A synthetic mouse
+ // click can lose its target when the fixed mobile dock follows visual-viewport
+ // chrome between pointer down/up, producing no DOM click event at all.
+ await notesToggle.tap();
await expect(notesToggle).toHaveAttribute('aria-expanded', 'true');
await expect(notesWorkspace).not.toHaveClass(/is-collapsed/);
- await page.getByRole('button', { name: 'Collapse notes' }).click();
+ await page.getByRole('button', { name: 'Collapse notes' }).tap();
await expect(notesWorkspace).toHaveClass(/is-collapsed/);
const afterNotesToggle = await page.evaluate(key => window.localStorage.getItem(key), INTAKE_STORAGE_KEY);
From 00035b31caba021f8214780878db675b17404db2 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Fri, 9 Oct 2026 02:58:32 -0400
Subject: [PATCH 43/47] Exercise Notes toggle button listener in mobile unit
coverage
---
tests/notesWorkspace.feature.test.mjs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/notesWorkspace.feature.test.mjs b/tests/notesWorkspace.feature.test.mjs
index 808f429..df8d16f 100644
--- a/tests/notesWorkspace.feature.test.mjs
+++ b/tests/notesWorkspace.feature.test.mjs
@@ -105,7 +105,7 @@ test('mobile Notes collapse is presentation-only and preserves the persisted des
assert.deepEqual(getNotesWorkspaceState(), before);
assert.equal(saves, 0);
- toggleNotesWorkspace();
+ document.querySelector('#notesWorkspaceToggle').click();
assert.equal(document.querySelector('#notesWorkspace').classList.contains('is-collapsed'), false);
assert.equal(document.querySelector('#notesWorkspaceToggle').getAttribute('aria-expanded'), 'true');
From cc63201e60785182ac1817669e0c965a654fc638 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Fri, 9 Oct 2026 03:02:24 -0400
Subject: [PATCH 44/47] Keep mobile Notes pointer activation targeted
---
src/notesWorkspace.js | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/src/notesWorkspace.js b/src/notesWorkspace.js
index 50b299e..0f38730 100644
--- a/src/notesWorkspace.js
+++ b/src/notesWorkspace.js
@@ -170,7 +170,14 @@ export function initNotesWorkspace({ onSave: save = () => {}, showToast: toast =
notes.push({ id: createId(), text }); input.value = ''; render(); onSave(); showToast('Note captured.');
});
document.querySelector('#notesWorkspaceInput')?.addEventListener('keydown', event => { if (event.key === 'Enter') { event.preventDefault(); document.querySelector('#notesWorkspaceAddBtn')?.click(); } });
- document.querySelector('#notesWorkspaceToggle')?.addEventListener('click', () => toggleNotesWorkspace());
+ const workspaceToggle = document.querySelector('#notesWorkspaceToggle');
+ workspaceToggle?.addEventListener('pointerdown', event => {
+ // A fixed bottom dock can shift when the mobile visual viewport changes
+ // between pointer down/up. Capture keeps normal button activation targeted
+ // to this control without changing keyboard/programmatic click semantics.
+ try { workspaceToggle.setPointerCapture?.(event.pointerId); } catch {}
+ });
+ workspaceToggle?.addEventListener('click', () => toggleNotesWorkspace());
document.addEventListener('focusin', event => { if (isEditableIntakeField(event.target)) lastFocusedField = event.target; });
document.addEventListener('dragstart', event => { if (event.target.closest?.('button,input')) { event.preventDefault(); return; } const item = event.target.closest?.('.notes-workspace__item[data-note-id]'); if (!item) return; event.dataTransfer?.setData('text/x-intake-note-id', item.dataset.noteId); event.dataTransfer?.setData('text/plain', item.querySelector('.notes-workspace__text')?.textContent || ''); event.dataTransfer.effectAllowed = 'copy'; });
document.addEventListener('dragover', event => { if (isEditableIntakeField(event.target)) { event.preventDefault(); event.dataTransfer.dropEffect = 'copy'; } });
From 9f9403024f8fd7ce03b7d5b370c847332b9c2fe8 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Fri, 9 Oct 2026 03:02:30 -0400
Subject: [PATCH 45/47] Verify click and touch activation for mobile Notes
---
tests/e2e/standalone.spec.mjs | 7 +++----
1 file changed, 3 insertions(+), 4 deletions(-)
diff --git a/tests/e2e/standalone.spec.mjs b/tests/e2e/standalone.spec.mjs
index c0afc59..0843eab 100644
--- a/tests/e2e/standalone.spec.mjs
+++ b/tests/e2e/standalone.spec.mjs
@@ -166,10 +166,9 @@ test('mobile Standalone accepts primary Intake input and persists it', async ({
expect(await page.evaluate(() => window.matchMedia('(max-width: 700px)').matches)).toBe(true);
const beforeNotesToggle = await page.evaluate(key => window.localStorage.getItem(key), INTAKE_STORAGE_KEY);
- // Pixel 7 emulation should exercise the real touch path. A synthetic mouse
- // click can lose its target when the fixed mobile dock follows visual-viewport
- // chrome between pointer down/up, producing no DOM click event at all.
- await notesToggle.tap();
+ // Pointer capture keeps activation targeted even if the fixed dock follows
+ // a mobile visual-viewport shift between pointer down/up.
+ await notesToggle.click();
await expect(notesToggle).toHaveAttribute('aria-expanded', 'true');
await expect(notesWorkspace).not.toHaveClass(/is-collapsed/);
await page.getByRole('button', { name: 'Collapse notes' }).tap();
From 289eb4236b60bbdf0d96459d70c9223600107281 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Fri, 9 Oct 2026 03:02:36 -0400
Subject: [PATCH 46/47] Cover Notes pointer capture safeguard
---
tests/notesWorkspace.feature.test.mjs | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/tests/notesWorkspace.feature.test.mjs b/tests/notesWorkspace.feature.test.mjs
index df8d16f..f54288a 100644
--- a/tests/notesWorkspace.feature.test.mjs
+++ b/tests/notesWorkspace.feature.test.mjs
@@ -120,6 +120,18 @@ test('mobile Notes collapse is presentation-only and preserves the persisted des
assert.deepEqual(getNotesWorkspaceState(), before);
});
+test('notes toggle captures pointer activation before a fixed mobile dock can shift', () => {
+ const toggle = document.querySelector('#notesWorkspaceToggle');
+ let capturedPointerId = null;
+ toggle.setPointerCapture = pointerId => { capturedPointerId = pointerId; };
+ const event = new Event('pointerdown', { bubbles: true, cancelable: true });
+ Object.defineProperty(event, 'pointerId', { value: 17 });
+
+ toggle.dispatchEvent(event);
+
+ assert.equal(capturedPointerId, 17);
+});
+
test('valid drop inserts at selection, dispatches editing events, and removes the note', () => {
const id = addNote('note');
const target = document.querySelector('#target');
From 03629ee05fa69d0a77100e173d4fd1462235bfd4 Mon Sep 17 00:00:00 2001
From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com>
Date: Fri, 9 Oct 2026 11:20:07 -0400
Subject: [PATCH 47/47] test: keep mobile Notes toggle locator stable
---
tests/e2e/standalone.spec.mjs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/e2e/standalone.spec.mjs b/tests/e2e/standalone.spec.mjs
index 0843eab..006ad7a 100644
--- a/tests/e2e/standalone.spec.mjs
+++ b/tests/e2e/standalone.spec.mjs
@@ -160,7 +160,7 @@ test('mobile Standalone accepts primary Intake input and persists it', async ({
}, INTAKE_STORAGE_KEY)).toBe(problem);
const notesWorkspace = page.locator('#notesWorkspace');
- const notesToggle = page.getByRole('button', { name: 'Open notes' });
+ const notesToggle = page.locator('#notesWorkspaceToggle');
await expect(notesWorkspace).toHaveClass(/is-collapsed/);
await expect(notesToggle).toBeVisible();
expect(await page.evaluate(() => window.matchMedia('(max-width: 700px)').matches)).toBe(true);