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; 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 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. 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. | 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. 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 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 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 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. | 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 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. diff --git a/index.html b/index.html index 7f7916f..3d58658 100644 --- a/index.html +++ b/index.html @@ -255,32 +255,47 @@

Mode

>
KT Intake -

How are you using Intake?

-

Choose the experience that fits this work. You can change it later from the View menu without changing your Intake data.

+

What would you like to do?

+

Continue existing work on this device, or choose a different way to use Intake. Nothing is cleared just by opening this screen.

-
+ + +
+
+ Start / Join +

Start or join something else

+
+ +
+
Inspect and clean server-side class or collaboration data with a separate Admin access key.
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(); 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; }; 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; }; diff --git a/src/experienceRoleController.js b/src/experienceRoleController.js index 66242cc..0161cd8 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. * @@ -264,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; } @@ -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 }); } @@ -411,24 +432,27 @@ 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, 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 +460,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 +478,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; } 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.' }) ]); 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'; } }); diff --git a/src/startupExperienceHub.js b/src/startupExperienceHub.js new file mode 100644 index 0000000..135e009 --- /dev/null +++ b/src/startupExperienceHub.js @@ -0,0 +1,459 @@ +/** + * @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 { + 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'; + + +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 meaningfulMajorAnalysis(snapshot) { + 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); +} + +/** + * 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'); // data-persistence="local-only" data-summary="exclude" + 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 + }; +} diff --git a/styles.css b/styles.css index f9b4d6a..500a71f 100644 --- a/styles.css +++ b/styles.css @@ -328,6 +328,28 @@ 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;} +@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; @@ -404,6 +426,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;} 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'); 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 = { diff --git a/tests/e2e/classroom-authorization.spec.mjs b/tests/e2e/classroom-authorization.spec.mjs index fe543ed..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(); @@ -82,12 +122,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'); }); 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); 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(); diff --git a/tests/e2e/intake.smoke.spec.mjs b/tests/e2e/intake.smoke.spec.mjs index 00fced5..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: /Teach 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(); diff --git a/tests/e2e/standalone.spec.mjs b/tests/e2e/standalone.spec.mjs index 31e5aff..006ad7a 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); @@ -155,13 +160,18 @@ test('mobile Standalone accepts primary Intake input and persists it', async ({ }, INTAKE_STORAGE_KEY)).toBe(problem); const notesWorkspace = page.locator('#notesWorkspace'); + const notesToggle = page.locator('#notesWorkspaceToggle'); 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.getByRole('button', { name: 'Open notes' }).click(); + // 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' }).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); @@ -176,6 +186,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/); 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 }); 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)); 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', () => { diff --git a/tests/notesWorkspace.feature.test.mjs b/tests/notesWorkspace.feature.test.mjs index 808f429..f54288a 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'); @@ -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'); 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); +}); diff --git a/tests/startupExperienceHub.unit.test.mjs b/tests/startupExperienceHub.unit.test.mjs new file mode 100644 index 0000000..5a8156e --- /dev/null +++ b/tests/startupExperienceHub.unit.test.mjs @@ -0,0 +1,236 @@ +/** + * 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: [] + }, + 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: '' + } + } + }; + + 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); + + 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', () => { + 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'); +}); 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',