feat(student): add curriculum dashboard and lab launch/reset/recreate flow

StudentDashboard.jsx — "Up Next" curriculum view + Launch Lab flow, in one
component since they share state (environment status affects what curriculum
actions make sense).

- Renders GET /api/curriculum/path as-is: the existing RootNode path
  (Foundation -> Workloads -> Networking -> Configuration -> Troubleshooting
  -> Real Ops -> Capstone), no new structure invented. Capstone stays the
  locked placeholder from the earlier slice.
- Launch Lab -> POST /api/environment/launch, polls status every 2.5s while
  provisioning/resetting, transitions through none -> provisioning -> running
  -> error with a distinct UI state for each (EnvironmentPanel). On running,
  navigates to launch_path with the student's own id as ?sid= — a
  defense-in-depth ownership check the Gateway verifies on that specific
  navigation (see the gateway commit).
- One "Recreate Lab" control, calling POST /api/environment/reset — no raw
  destroy-without-recreate exposed to students, matching "don't expose raw
  destroy/admin controls beyond what is safe for a student." A visible note
  states progress is saved separately and survives.

Platform.module.css reuses the existing design tokens from index.css
(--green, --surface, --text, etc.) so this reads as the same product as the
Lab UI, not a bolted-on separate app.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-08-18 11:16:35 -05:00
co-authored by Claude Sonnet 5
parent 5e4f5518fd
commit fb2945069c
2 changed files with 320 additions and 0 deletions
+161
View File
@@ -0,0 +1,161 @@
/* Platform Shell styling — reuses the existing design tokens from index.css (--bg, --surface,
--green, --text, etc.) so the entry/dashboard screens feel like the same product as the Lab
UI, not a bolted-on separate app. */
.entryWrap {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
font-family: var(--sans);
color: var(--text);
}
.entryCard {
width: 380px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 2rem;
}
.brand {
font-family: var(--mono);
font-size: 1.3rem;
font-weight: 600;
color: var(--green);
display: flex;
align-items: center;
gap: 0.5rem;
}
.brandIcon { font-size: 1.4rem; }
.tagline { color: var(--text-2); margin: 0.5rem 0 0.25rem; font-size: 0.9rem; }
.pilotNote { color: var(--text-3); font-size: 0.75rem; margin-bottom: 1.25rem; }
.label { display: block; font-size: 0.8rem; color: var(--text-2); margin: 0.9rem 0 0.3rem; }
.select {
width: 100%;
padding: 0.55rem 0.6rem;
background: var(--surface2);
border: 1px solid var(--border2);
border-radius: var(--radius);
color: var(--text);
font-family: var(--sans);
font-size: 0.9rem;
}
.primaryBtn {
margin-top: 1.25rem;
width: 100%;
padding: 0.65rem 1rem;
background: var(--green);
color: #05130a;
border: none;
border-radius: var(--radius);
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
text-align: center;
text-decoration: none;
display: inline-block;
}
.primaryBtn:disabled { opacity: 0.5; cursor: not-allowed; }
.secondaryBtn {
padding: 0.65rem 1rem;
background: transparent;
color: var(--text);
border: 1px solid var(--border2);
border-radius: var(--radius);
font-size: 0.9rem;
cursor: pointer;
}
.secondaryBtn:disabled { opacity: 0.5; cursor: not-allowed; }
.linkBtn {
background: none;
border: none;
color: var(--text-2);
font-size: 0.85rem;
cursor: pointer;
text-decoration: underline;
}
.errorBox {
background: var(--red-dim);
color: var(--red);
border: 1px solid var(--red);
border-radius: var(--radius);
padding: 0.6rem 0.8rem;
font-size: 0.85rem;
margin-bottom: 1rem;
}
/* ── Dashboard ─────────────────────────────────────────────────────────── */
.dashWrap {
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: var(--sans);
padding: 0 0 3rem;
}
.dashHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.1rem 2rem;
border-bottom: 1px solid var(--border);
}
.headerRight { display: flex; align-items: center; gap: 1rem; }
.studentName { font-weight: 600; }
.card {
max-width: 760px;
margin: 1.5rem auto 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.5rem;
}
.card h2 { font-size: 1rem; margin-bottom: 1rem; color: var(--text); }
.muted { color: var(--text-2); font-size: 0.9rem; }
.stageRow { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.75rem; }
.stagePill {
display: flex; align-items: center; gap: 0.4rem;
padding: 0.4rem 0.7rem;
background: var(--surface2);
border: 1px solid var(--border2);
border-radius: 999px;
font-size: 0.8rem;
color: var(--text-2);
}
.stagePillActive { border-color: var(--green); color: var(--text); }
.stagePillLocked { opacity: 0.5; }
.stageCount { color: var(--text-3); font-family: var(--mono); font-size: 0.75rem; }
.nextUp { font-size: 0.9rem; color: var(--text); }
.statusLine { display: flex; align-items: center; gap: 0.5rem; font-size: 0.9rem; margin-bottom: 0.9rem; }
.btnRow { display: flex; gap: 0.75rem; }
.hint { color: var(--text-3); font-size: 0.78rem; margin-top: 0.75rem; }
.dotOk, .dotErr { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.dotOk { background: var(--green); }
.dotErr { background: var(--red); }
.spinner {
width: 12px; height: 12px;
border: 2px solid var(--border2);
border-top-color: var(--green);
border-radius: 50%;
display: inline-block;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }