diff --git a/src/app/(teacher)/layout.tsx b/src/app/(teacher)/layout.tsx
index 9ed4c4b4..6c0b2a1f 100644
--- a/src/app/(teacher)/layout.tsx
+++ b/src/app/(teacher)/layout.tsx
@@ -1,9 +1,18 @@
import { Nav } from '@/components/Nav';
+/** "learning-commons" → "Learning Commons" for the nav's source indicator. */
+function activeSourceLabel(): string {
+ const id = (process.env.STANDARDS_SOURCE ?? 'learning-commons').split(',')[0]?.trim() ?? '';
+ return id
+ .split('-')
+ .map((word) => (word ? word[0].toUpperCase() + word.slice(1) : word))
+ .join(' ');
+}
+
export default function TeacherLayout({ children }: { children: React.ReactNode }) {
return (
- Pathways
+
);
diff --git a/src/app/(teacher)/roster/page.tsx b/src/app/(teacher)/roster/page.tsx
index 21cf3505..9c6b9192 100644
--- a/src/app/(teacher)/roster/page.tsx
+++ b/src/app/(teacher)/roster/page.tsx
@@ -1,7 +1,7 @@
import { Suspense } from 'react';
import { RosterPage } from '@/components/roster/RosterPage';
-export const metadata = { title: 'Class Roster' };
+export const metadata = { title: 'Class roster' };
export default function Page() {
return (
diff --git a/src/app/api/telemetry/route.ts b/src/app/api/telemetry/route.ts
index 085774b5..b1599456 100644
--- a/src/app/api/telemetry/route.ts
+++ b/src/app/api/telemetry/route.ts
@@ -42,7 +42,14 @@ export async function POST(request: Request) {
learningComponentId: event.learningComponentId,
elapsedMs: event.elapsedMs,
correct: event.correct,
- payload: truncate(event.payload),
+ // stepIndex rides inside payload — the interactions table has no
+ // column for it, and the report's per-step evidence strip reads it
+ // back out. Dropping it here is what made per-step reporting
+ // impossible before.
+ payload:
+ event.stepIndex != null
+ ? { ...truncate(event.payload), stepIndex: event.stepIndex }
+ : truncate(event.payload),
})),
);
} catch (error) {
diff --git a/src/app/demo/defend-claim/page.tsx b/src/app/demo/defend-claim/page.tsx
index 44c249fa..e37e09e8 100644
--- a/src/app/demo/defend-claim/page.tsx
+++ b/src/app/demo/defend-claim/page.tsx
@@ -118,7 +118,7 @@ export default function DefendClaimDemo() {
>
← Widget gallery
- Defend a Claim
+ Defend a claim
Take a side on a claim historians argue about, defend it from the sources, then ask for
feedback and revise. Nothing is judged until you press the button — and every reading
diff --git a/src/app/demo/draft-meter/page.tsx b/src/app/demo/draft-meter/page.tsx
index 9f7a902a..e767e54e 100644
--- a/src/app/demo/draft-meter/page.tsx
+++ b/src/app/demo/draft-meter/page.tsx
@@ -167,7 +167,7 @@ export default function DraftMeterDemo() {
>
← Widget gallery
-
Draft Meter
+ Draft meter
Question, textbox, one line. Same component and same scoring call in all four of these —
what changes is the three things each one is looking for, and those come from the standard.
diff --git a/src/app/demo/draw-the-curve/page.tsx b/src/app/demo/draw-the-curve/page.tsx
index b11cc562..81e37293 100644
--- a/src/app/demo/draw-the-curve/page.tsx
+++ b/src/app/demo/draw-the-curve/page.tsx
@@ -156,7 +156,7 @@ export default function DrawTheCurveDemo() {
>
← Widget gallery
-
Draw the Curve
+ Draw the curve
Shape the line by dragging its points, then see the real one drawn over your guess. Checked
on shape, not numbers — so a story's tension arc works the same way a motion graph does.
diff --git a/src/app/demo/find-the-flaw/page.tsx b/src/app/demo/find-the-flaw/page.tsx
index b9202290..6ac1d54c 100644
--- a/src/app/demo/find-the-flaw/page.tsx
+++ b/src/app/demo/find-the-flaw/page.tsx
@@ -151,7 +151,7 @@ export default function FindTheFlawDemo() {
>
← Widget gallery
-
Find the Flaw
+ Find the flaw
A worked example with one mistake in it. Find where it goes wrong, then say why — the same
interaction whether the work is a calculation, an experiment, an argument or an explanation.
diff --git a/src/app/demo/fraction-area-model/page.tsx b/src/app/demo/fraction-area-model/page.tsx
index c280ccbf..f90d3356 100644
--- a/src/app/demo/fraction-area-model/page.tsx
+++ b/src/app/demo/fraction-area-model/page.tsx
@@ -44,7 +44,7 @@ export default function FractionAreaModelDemo() {
>
← Widget gallery
-
Fraction Area Model
+ Fraction area model
Partition a whole into equal parts and select segments to build a target fraction.
Switch denominator choices to explore equivalent forms.
diff --git a/src/app/demo/markdown-card/page.tsx b/src/app/demo/markdown-card/page.tsx
index 2103482f..9dda7dd9 100644
--- a/src/app/demo/markdown-card/page.tsx
+++ b/src/app/demo/markdown-card/page.tsx
@@ -95,7 +95,7 @@ export default function MarkdownCardDemo() {
>
← Widget gallery
-
Markdown Card
+ Markdown card
Renders LLM-generated markdown as a reading card for reinforcement.
diff --git a/src/app/demo/narrated-card/page.tsx b/src/app/demo/narrated-card/page.tsx
index b730f23f..f7a468d7 100644
--- a/src/app/demo/narrated-card/page.tsx
+++ b/src/app/demo/narrated-card/page.tsx
@@ -47,7 +47,7 @@ export default function NarratedCardDemo() {
>
← Widget gallery
- Narrated Card
+ Narrated card
Reads content aloud sentence by sentence, highlighting each sentence as it is spoken.
diff --git a/src/app/demo/page.tsx b/src/app/demo/page.tsx
index a18c92fb..1b370ca9 100644
--- a/src/app/demo/page.tsx
+++ b/src/app/demo/page.tsx
@@ -6,28 +6,28 @@ import { WidgetThumb } from '@/components/demo/WidgetThumb';
const WIDGETS = [
{
slug: 'fraction-area-model',
- name: 'Fraction Area Model',
+ name: 'Fraction area model',
description:
'Partition a whole into equal parts and select segments to build a target fraction. Supports bar and circle representations with equivalent-fraction detection.',
tags: ['fractions', 'visual', 'manipulative'],
},
{
slug: 'swiper-flashcard',
- name: 'Swiper Flashcard',
+ name: 'Swiper flashcard',
description:
'Swipe a card up or down to answer a question or sort a statement into a category. Supports drag gestures and emits a completion event with per-card results.',
tags: ['flashcards', 'true/false', 'sorting'],
},
{
slug: 'draft-meter',
- name: 'Draft Meter',
+ name: 'Draft meter',
description:
'Write a short response and one line scores how strong the argument is, from a live model call on a debounce. Optionally supplies a source passage, so "evidence" means citing the text.',
tags: ['writing', 'argument', 'live scoring'],
},
{
slug: 'draw-the-curve',
- name: 'Draw the Curve',
+ name: 'Draw the curve',
description:
'Shape a line by dragging its points to predict how something changes, then see the real curve drawn over your guess. Checked on shape rather than numbers, so a story arc works the same way a motion graph does.',
tags: ['predict-then-reveal', 'any subject', 'chart'],
@@ -41,28 +41,28 @@ const WIDGETS = [
},
{
slug: 'writing-workshop',
- name: 'Writing Workshop',
+ name: 'Writing workshop',
description:
'Long-form writing — essay, lab report, research proposal, short story — reviewed only when the student asks, and marked up on the sentences themselves with a note on each.',
tags: ['long-form', 'on demand', 'annotation'],
},
{
slug: 'find-the-flaw',
- name: 'Find the Flaw',
+ name: 'Find the flaw',
description:
'A worked example with one deliberate mistake — a solution, an experiment, an argument, an explanation. The student finds the step where it goes wrong, then diagnoses why. Checked locally, so there is no wait.',
tags: ['error analysis', 'any subject', 'metacognition'],
},
{
slug: 'defend-claim',
- name: 'Defend a Claim',
+ name: 'Defend a claim',
description:
'Take a side on a contestable historical claim, defend it from two conflicting sources, then request feedback and revise. Every reading answers with a counterargument. Typed or dictated. History, grade 7+.',
tags: ['history', 'argument', 'voice', 'revision'],
},
{
slug: 'timeline-builder',
- name: 'Timeline Builder',
+ name: 'Timeline builder',
description:
'Drag historical events from a bank into labeled period zones on a horizontal timeline. Supports 3–5 zones with per-event correctness feedback.',
tags: ['history', 'sequencing', 'timeline'],
@@ -90,20 +90,20 @@ const WIDGETS = [
},
{
slug: 'narrated-card',
- name: 'Narrated Card',
+ name: 'Narrated card',
description:
'Reads content aloud using the browser\'s text-to-speech engine, revealing each sentence as it is spoken. Steps stack up as they complete.',
tags: ['audio', 'narration', 'accessibility'],
},
{
slug: 'step-reveal',
- name: 'Step Reveal',
+ name: 'Step reveal',
description: 'Walk through a concept or worked example one step at a time. Each step stacks up so students can follow the full chain of reasoning.',
tags: ['worked example', 'step-by-step', 'reasoning'],
},
{
slug: 'markdown-card',
- name: 'Markdown Card',
+ name: 'Markdown card',
description:
'Renders LLM-generated markdown as a clean reading card — headings, bold, bullets, blockquotes, and an optional tip callout. Used to re-teach a concept a student is struggling with.',
tags: ['reading', 'remediation', 'markdown'],
diff --git a/src/app/demo/step-reveal/page.tsx b/src/app/demo/step-reveal/page.tsx
index 8074d6d1..b58bb6f5 100644
--- a/src/app/demo/step-reveal/page.tsx
+++ b/src/app/demo/step-reveal/page.tsx
@@ -71,7 +71,7 @@ export default function StepRevealDemo() {
>
← Widget gallery
- Step Reveal
+ Step reveal
Walk through a worked example one step at a time. Each step stacks up so students can follow the full chain of reasoning.
diff --git a/src/app/demo/swiper-flashcard/page.tsx b/src/app/demo/swiper-flashcard/page.tsx
index 1c12f958..dc679d09 100644
--- a/src/app/demo/swiper-flashcard/page.tsx
+++ b/src/app/demo/swiper-flashcard/page.tsx
@@ -83,7 +83,7 @@ export default function SwiperFlashcardDemo() {
>
← Widget gallery
- Swiper Flashcard
+ Swiper flashcard
Sort each clue into the correct plate boundary type. Drag the card up or down — or use the buttons. An{' '}
onComplete{' '}
diff --git a/src/app/demo/timeline-builder/page.tsx b/src/app/demo/timeline-builder/page.tsx
index 4e21fc74..700edfa0 100644
--- a/src/app/demo/timeline-builder/page.tsx
+++ b/src/app/demo/timeline-builder/page.tsx
@@ -39,7 +39,7 @@ export default function TimelineBuilderDemo() {
>
← Widget gallery
-
Timeline Builder
+ Timeline builder
Drag events from the bank into the correct period on the timeline.
diff --git a/src/app/demo/writing-workshop/page.tsx b/src/app/demo/writing-workshop/page.tsx
index c4d5b7ea..c185def3 100644
--- a/src/app/demo/writing-workshop/page.tsx
+++ b/src/app/demo/writing-workshop/page.tsx
@@ -118,7 +118,7 @@ export default function WritingWorkshopDemo() {
>
← Widget gallery
- Writing Workshop
+ Writing workshop
Long-form writing, marked up when you ask. Nothing watches while you draft — then
the read comes back on the sentences themselves, underlined where they work and where they
diff --git a/src/app/games/page.tsx b/src/app/games/page.tsx
index e526af72..c24bda3b 100644
--- a/src/app/games/page.tsx
+++ b/src/app/games/page.tsx
@@ -11,14 +11,14 @@ type GameType = 'menu' | 'memory' | 'scramble' | 'pacman';
const GAMES = [
{
id: 'memory' as const,
- name: 'Memory Match',
+ name: 'Memory match',
emoji: '🎴',
description: 'Find matching pairs',
component: MemoryMatch,
},
{
id: 'scramble' as const,
- name: 'Word Scramble',
+ name: 'Word scramble',
emoji: '🔤',
description: 'Unscramble the letters',
component: WordScramble,
diff --git a/src/app/globals.css b/src/app/globals.css
index 960ba3a9..2e35fb73 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -7,9 +7,10 @@
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
- --font-sans: var(--font-geist-sans);
- --font-mono: var(--font-geist-mono);
- --font-heading: var(--font-sans);
+ --font-sans: var(--font-plex-sans);
+ --font-mono: var(--font-plex-mono);
+ --font-heading: var(--font-archivo);
+ --font-serif: var(--font-source-serif);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -44,6 +45,21 @@
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
+ /* Practice Pathways design tokens — the handoff palette, beyond shadcn's roles. */
+ --color-sunk: var(--sunk);
+ --color-track: var(--track);
+ --color-ink: var(--foreground);
+ --color-ink-2: var(--ink-2);
+ --color-ink-3: var(--muted-foreground);
+ --color-brand-fill: var(--brand-fill);
+ --color-brand-fill-hover: var(--brand-fill-hover);
+ --color-brand-press: var(--brand-press);
+ --color-brand-text: var(--primary-tint-foreground);
+ --color-verified: var(--success);
+ --color-verified-tint: var(--verified-tint);
+ --color-verified-edge: var(--verified-edge);
+ --color-warning-tint: var(--warning-tint);
+ --color-warning-edge: var(--warning-edge);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
@@ -54,138 +70,185 @@
}
/*
- * `.light-surface` re-declares this same palette for one subtree. The student
- * surfaces (/learn and a shared pathway) hard-code pastel light chrome — a
- * violet/pink gradient and white cards, with no dark variants — but the widgets
- * they host are token-themed. Without this, a student in dark mode gets
- * `--foreground` near-white on those hard-coded white cards, i.e. invisible
- * feedback text, and `bg-card` chips render near-black.
+ * Practice Pathways palette: warm graphite ink + one highlighter, semantic
+ * colours that never double as decoration, warm-biased neutrals, and light and
+ * dark designed as siblings. Every text/surface pair below was checked against
+ * WCAG 2.2 AA (4.5:1 body, 3:1 large/UI) — the ratios live in the design
+ * handoff; re-check any substitution.
+ *
+ * Two rules that are not stylistic:
+ * 1. The highlighter (--brand-fill) is a marker, never a surface: as a fill it
+ * always carries a 1px ink border, and it never appears in chip/status form.
+ * 2. Semantic colours always ship with an icon and a word; colour alone never
+ * conveys verification state.
+ *
+ * `.light-surface` still re-declares the light palette for the student
+ * subtree; it is scheduled for deletion in the walkthrough redesign slice,
+ * when /learn's hard-coded pastel chrome becomes token-themed.
*/
:root,
.light-surface {
- --background: oklch(1 0 0);
- --foreground: oklch(0.145 0 0);
- --card: oklch(1 0 0);
- --card-foreground: oklch(0.145 0 0);
- --popover: oklch(1 0 0);
- --popover-foreground: oklch(0.145 0 0);
- /*
- * The one confident brand hue. The base shadcn theme this builds on ships
- * pure greyscale; this app has two faces — a teacher-facing builder and a
- * student-facing /learn — and they need to read as the same product.
- * /learn already has a violet identity (its gradient background, its mic
- * button), so primary is built around that hue rather than introducing a
- * third color language. Chosen for contrast, not just vibe: 6.31:1 against
- * --primary-foreground, comfortably past the 4.5:1 text floor everything
- * else in this file holds to.
- */
- --primary: oklch(0.5 0.2 292);
- --primary-foreground: oklch(0.985 0 0);
- /* For brand-hued text sitting on a light primary-tinted surface (a badge,
- not a solid button) — identical to --primary in light mode, where it
- already clears 4.5:1; dark mode needs its own lighter value below. */
- --primary-tint-foreground: var(--primary);
- --secondary: oklch(0.97 0 0);
- --secondary-foreground: oklch(0.205 0 0);
- --muted: oklch(0.97 0 0);
- --muted-foreground: oklch(0.556 0 0);
- --accent: oklch(0.97 0 0);
- --accent-foreground: oklch(0.205 0 0);
- --destructive: oklch(0.577 0.245 27.325);
- /* Widget feedback and manipulative fills. The base theme's palette is
- greyscale, so these are the only chromatic tokens: correct,
- needs-another-look, and the "student selected this part" fill shared
- by every manipulative. */
- /* Lightness is set so feedback text clears WCAG AA (4.5:1) against --card,
- matching the shipped --destructive. The obvious emerald/amber sit at
- ~3.5:1 and fail — this is student-facing copy, so it has to hold. */
- --success: oklch(0.528 0.145 163.225);
- --warning: oklch(0.56 0.153 70.08);
- /* Selected vs unselected parts differ only by fill, so the pair has to clear
- the 3:1 non-text contrast bar (WCAG 1.4.11) on its own. */
+ --background: #fcfaf7; /* paper */
+ --foreground: #1f1915; /* ink */
+ --ink-2: #58514c;
+ --card: #ffffff;
+ --card-foreground: #1f1915;
+ --popover: #ffffff;
+ --popover-foreground: #1f1915;
+ --sunk: #f6f3ef;
+ --track: #e8e4dd; /* progress tracks — one step deeper than sunk */
+ /* Primary action = the highlighter with ink text (12.33:1). The mandatory
+ 1px ink border is applied by the button styles, not the token. */
+ --primary: #e3df41;
+ --primary-foreground: #1f1915;
+ --brand-fill: #e3df41;
+ --brand-fill-hover: #f2ef63;
+ --brand-press: #8e8b0f;
+ /* Brand-coloured text on light surfaces (7.62:1). */
+ --primary-tint-foreground: #595600;
+ --secondary: #f6f3ef;
+ --secondary-foreground: #1f1915;
+ --muted: #f6f3ef;
+ --muted-foreground: #736c66; /* ink-3, 5.18:1 on card */
+ --accent: #f6f3ef;
+ --accent-foreground: #1f1915;
+ --destructive: #bd093f; /* 6.41:1 on card */
+ --success: #1c6d26; /* verified, 6.45:1 on card */
+ --verified-tint: #dbf3db;
+ --verified-edge: #b6e3b8;
+ --warning: #775400; /* unverified / needs review, 6.87:1 on card */
+ --warning-tint: #fcebc7;
+ --warning-edge: #eed49a;
+ /* Manipulative selection fill — kept from the previous system; its hue
+ (237°) sits safely outside the brand/warning band the guest hues avoid. */
--selected: oklch(0.639 0.169 237.323);
- --selected-foreground: oklch(0.985 0 0);
- --border: oklch(0.922 0 0);
- --input: oklch(0.922 0 0);
- /* Focus rings pick up the brand hue too, at low chroma — a warm glow
- instead of a flat grey outline, without competing with --primary itself. */
- --ring: oklch(0.708 0.08 292);
- --chart-1: oklch(0.87 0 0);
- --chart-2: oklch(0.556 0 0);
- --chart-3: oklch(0.439 0 0);
- --chart-4: oklch(0.371 0 0);
- --chart-5: oklch(0.269 0 0);
- /* Rounder than the base theme's default (10px), short of /learn's explicit
- rounded-2xl/3xl treatments on its own bespoke elements — the "same
- warmth, calmer register" the builder and /learn are meant to share.
- Radius alone never affects text contrast, so this is safe to raise
- without re-checking any of the ratios documented above. */
- --radius: 0.875rem;
- --sidebar: oklch(0.985 0 0);
- --sidebar-foreground: oklch(0.145 0 0);
- --sidebar-primary: oklch(0.205 0 0);
- --sidebar-primary-foreground: oklch(0.985 0 0);
- --sidebar-accent: oklch(0.97 0 0);
- --sidebar-accent-foreground: oklch(0.205 0 0);
- --sidebar-border: oklch(0.922 0 0);
- --sidebar-ring: oklch(0.708 0 0);
+ --selected-foreground: #ffffff;
+ --border: #e1ddd8; /* rule — decorative separation only */
+ --input: #e1ddd8;
+ /* Focus is always visible and always ink: the highlighter is too
+ low-contrast (1.41:1) to be a focus ring. */
+ --ring: #1f1915;
+ --chart-1: #e1ddd8;
+ --chart-2: #736c66;
+ --chart-3: #58514c;
+ --chart-4: #3d3731;
+ --chart-5: #1f1915;
+ /* Teacher register is square. The student register raises this via
+ `.register-student` below — the only systematic difference between the
+ two registers. */
+ --radius: 0rem;
+ --sidebar: #fcfaf7;
+ --sidebar-foreground: #1f1915;
+ --sidebar-primary: #1f1915;
+ --sidebar-primary-foreground: #fcfaf7;
+ --sidebar-accent: #f6f3ef;
+ --sidebar-accent-foreground: #1f1915;
+ --sidebar-border: #e1ddd8;
+ --sidebar-ring: #1f1915;
+ /* Purpose tags for PathwayPlan.steps[].purpose — fills and AA-checked text. */
+ --purpose-activate-bg: #fcebc7;
+ --purpose-activate-fg: #775400;
+ --purpose-model-bg: #e5e9f6;
+ --purpose-model-fg: #33477f;
+ --purpose-practice-bg: #dbf3db;
+ --purpose-practice-fg: #1c6d26;
+ --purpose-check-bg: #f6e3ea;
+ --purpose-check-fg: #9b1745;
}
.dark {
- --background: oklch(0.145 0 0);
- --foreground: oklch(0.985 0 0);
- --card: oklch(0.205 0 0);
- --card-foreground: oklch(0.985 0 0);
- --popover: oklch(0.205 0 0);
- --popover-foreground: oklch(0.985 0 0);
- /*
- * The base theme's dark mode default inverts to a near-white button with
- * dark text — a convention for a *monochrome* primary. A hued one reads
- * better the conventional way: a rich, visible fill with light text on
- * top. Lightened
- * from the light-mode value to stay legible against a near-black page
- * (3.63:1 against --card) while still clearing 4.5:1 against its own
- * --primary-foreground text.
- */
- --primary: oklch(0.565 0.19 292);
- --primary-foreground: oklch(0.985 0 0);
- /* --primary itself is tuned to sit under light text as a button fill
- (3.63:1 against --card is fine for a large filled area, but text this
- small needs 4.5:1) — too dark to also work as small text on a dark,
- lightly-tinted card. Lightened until it clears 4.5:1 in that role. */
- --primary-tint-foreground: oklch(0.65 0.16 292);
- --secondary: oklch(0.269 0 0);
- --secondary-foreground: oklch(0.985 0 0);
- --muted: oklch(0.269 0 0);
- --muted-foreground: oklch(0.708 0 0);
- --accent: oklch(0.269 0 0);
- --accent-foreground: oklch(0.985 0 0);
- --destructive: oklch(0.704 0.191 22.216);
- --success: oklch(0.765 0.177 163.223);
- --warning: oklch(0.828 0.189 84.429);
+ --background: #13100d; /* ground — warm charcoal, not an inverted document */
+ --foreground: #f5f3f1;
+ --ink-2: #bbb7b2;
+ --card: #1f1b18;
+ --card-foreground: #f5f3f1;
+ --popover: #1f1b18;
+ --popover-foreground: #f5f3f1;
+ --sunk: #292623; /* card-2 */
+ --track: #393430;
+ /* The highlighter is identical in both themes; its text flips to the dark
+ ground (13.45:1) so "ink on the marker" stays true as a sibling rule. */
+ --primary: #e3df41;
+ --primary-foreground: #13100d;
+ --brand-fill: #e3df41;
+ --brand-fill-hover: #f2ef63;
+ --brand-press: #797600;
+ --primary-tint-foreground: #dcd848; /* brand text, 11.37:1 on card */
+ --secondary: #292623;
+ --secondary-foreground: #f5f3f1;
+ --muted: #292623;
+ --muted-foreground: #98938d; /* 5.60:1 on card */
+ --accent: #292623;
+ --accent-foreground: #f5f3f1;
+ --destructive: #f65b72; /* 5.42:1 on card */
+ --success: #70d482; /* 9.36:1 on card */
+ --verified-tint: #16361c;
+ --verified-edge: #2c5c34;
+ --warning: #e7ba54; /* 9.42:1 on card */
+ --warning-tint: #3a2b07;
+ --warning-edge: #6e5310;
--selected: oklch(0.746 0.16 232.661);
- --selected-foreground: oklch(0.205 0 0);
- --border: oklch(1 0 0 / 10%);
- --input: oklch(1 0 0 / 15%);
- --ring: oklch(0.556 0.08 292);
- --chart-1: oklch(0.87 0 0);
- --chart-2: oklch(0.556 0 0);
- --chart-3: oklch(0.439 0 0);
- --chart-4: oklch(0.371 0 0);
- --chart-5: oklch(0.269 0 0);
- --sidebar: oklch(0.205 0 0);
- --sidebar-foreground: oklch(0.985 0 0);
- --sidebar-primary: oklch(0.488 0.243 264.376);
- --sidebar-primary-foreground: oklch(0.985 0 0);
- --sidebar-accent: oklch(0.269 0 0);
- --sidebar-accent-foreground: oklch(0.985 0 0);
- --sidebar-border: oklch(1 0 0 / 10%);
- --sidebar-ring: oklch(0.556 0 0);
+ --selected-foreground: #13100d;
+ --border: #393430;
+ --input: #393430;
+ --ring: #f5f3f1;
+ --chart-1: #393430;
+ --chart-2: #98938d;
+ --chart-3: #bbb7b2;
+ --chart-4: #d8d4d0;
+ --chart-5: #f5f3f1;
+ --sidebar: #13100d;
+ --sidebar-foreground: #f5f3f1;
+ --sidebar-primary: #f5f3f1;
+ --sidebar-primary-foreground: #13100d;
+ --sidebar-accent: #292623;
+ --sidebar-accent-foreground: #f5f3f1;
+ --sidebar-border: #393430;
+ --sidebar-ring: #f5f3f1;
+ --purpose-activate-bg: #3a2b07;
+ --purpose-activate-fg: #e7ba54;
+ --purpose-model-bg: #232b40;
+ --purpose-model-fg: #a9bcf0;
+ --purpose-practice-bg: #16361c;
+ --purpose-practice-fg: #70d482;
+ --purpose-check-bg: #3c1220;
+ --purpose-check-fg: #f2a2bc;
+}
+
+/*
+ * The student register: same palette, same type, same semantics — rounded.
+ * Cards 18–22px, inner elements 10–16px. Applied on the student subtree
+ * (/learn, shared pathways) alongside the register's own press-shadow
+ * button treatment.
+ */
+.register-student {
+ --radius: 1.25rem;
+}
+
+/*
+ * Guest hue: each generated activity carries one model-chosen hue
+ * (--activity-h, a number), rendered at fixed lightness/chroma so contrast
+ * cannot move (any hue: 5.6–7.1:1 text-on-card light, 8.0–9.0:1 dark). The
+ * hue appears only on the activity's own skin — frame border, header fill,
+ * active drop target, kind label, score dial — never on chrome, buttons,
+ * chips, or states.
+ */
+.activity-skin {
+ --activity-text: oklch(0.48 0.14 var(--activity-h, 220));
+ --activity-fill: oklch(0.62 0.16 var(--activity-h, 220));
+ --activity-tint: oklch(0.95 0.04 var(--activity-h, 220));
+ --activity-edge: oklch(0.85 0.06 var(--activity-h, 220));
+}
+.dark .activity-skin {
+ --activity-text: oklch(0.78 0.13 var(--activity-h, 220));
+ --activity-fill: oklch(0.7 0.15 var(--activity-h, 220));
+ --activity-tint: oklch(0.3 0.05 var(--activity-h, 220));
+ --activity-edge: oklch(0.42 0.11 var(--activity-h, 220));
}
@layer base {
* {
- @apply border-border outline-ring/50;
+ @apply border-border;
}
body {
@apply bg-background text-foreground;
@@ -193,4 +256,31 @@
html {
@apply font-sans;
}
-}
\ No newline at end of file
+ /* Focus is always visible: 3px ink, offset 2 — on everything interactive. */
+ :focus-visible {
+ outline: 3px solid var(--ring);
+ outline-offset: 2px;
+ }
+ h1, h2, h3, h4 {
+ font-family: var(--font-heading), var(--font-plex-sans), sans-serif;
+ font-stretch: 112%;
+ }
+}
+
+@keyframes caret-blink {
+ 0%, 45% { opacity: 1; }
+ 50%, 95% { opacity: 0; }
+}
+.caret-blink { animation: caret-blink 1.1s steps(1) infinite; }
+@media (prefers-reduced-motion: reduce) {
+ .caret-blink { animation: none; }
+}
+
+@keyframes segment-grow {
+ from { width: 4%; }
+ to { width: 92%; }
+}
+.segment-grow { animation: segment-grow 9s cubic-bezier(0.2, 0.6, 0.3, 1) forwards; }
+@media (prefers-reduced-motion: reduce) {
+ .segment-grow { animation: none; width: 100%; }
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 0e0a4d44..7ef65530 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,45 +1,58 @@
import type { Metadata } from "next";
-import { Geist, Geist_Mono, Lexend } from "next/font/google";
+import { Archivo, IBM_Plex_Mono, IBM_Plex_Sans, Source_Serif_4 } from "next/font/google";
import "./globals.css";
import { cn } from "@/lib/utils";
import { THEME_SCRIPT } from "@/lib/theme";
-const geistSans = Geist({
- variable: "--font-geist-sans",
+/**
+ * The Practice Pathways type system, per the design handoff:
+ * Archivo carries headlines, UI labels and every numeral (its width axis is
+ * loaded so headlines can stretch 106–118%); IBM Plex Sans carries body copy
+ * and tables; IBM Plex Mono carries standard codes, timings and micro-labels;
+ * Source Serif 4 exists for student reading passages only — remediation
+ * prose, activity prompts, the student's own draft.
+ */
+const archivo = Archivo({
+ variable: "--font-archivo",
subsets: ["latin"],
+ axes: ["wdth"],
});
-const geistMono = Geist_Mono({
- variable: "--font-geist-mono",
+const plexSans = IBM_Plex_Sans({
+ variable: "--font-plex-sans",
subsets: ["latin"],
+ weight: ["400", "500", "600", "700"],
});
-/**
- * Registered globally (fonts load once, here) but applied only locally on
- * the pathway builder's own headings via `font-[family-name:var(--font-lexend)]`
- * — not wired into the shared `--font-heading` token, which the Pathways
- * dashboard, Roster, Upload, Nav, and the Crossword widget all still use.
- * Lexend specifically: it's a typeface engineered and studied for reading
- * proficiency, including K-12 classroom trials — an unusually literal fit
- * for a tool that builds reading/learning pathways, not a default pick.
- */
-const lexend = Lexend({
- variable: "--font-lexend",
+const plexMono = IBM_Plex_Mono({
+ variable: "--font-plex-mono",
+ subsets: ["latin"],
+ weight: ["400", "500", "600"],
+});
+
+const sourceSerif = Source_Serif_4({
+ variable: "--font-source-serif",
subsets: ["latin"],
});
export const metadata: Metadata = {
- title: "Topic to student pathway",
+ title: "Practice Pathways",
description:
- "Turn a topic into a standards-grounded learning pathway with an interactive widget.",
+ "Turn a topic into a standards-verified learning pathway of interactive activities.",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
diff --git a/src/app/learn/page.tsx b/src/app/learn/page.tsx
index c606e354..18dd6178 100644
--- a/src/app/learn/page.tsx
+++ b/src/app/learn/page.tsx
@@ -138,8 +138,8 @@ export default function LearnPage() {
});
return (
-
-
+
+
{!session && (
- What do you want to{' '}
-
- learn?
-
+ What do you want to learn?
)}
@@ -171,21 +168,21 @@ export default function LearnPage() {
? { repeat: Infinity, duration: 1.6 }
: { type: 'spring', stiffness: 300, damping: 14 }
}
- className="flex size-36 flex-col items-center justify-center gap-1 rounded-full bg-gradient-to-br from-violet-500 to-pink-500 text-white shadow-[0_8px_0_0_#6d28d9] transition-transform active:translate-y-1 active:shadow-[0_3px_0_0_#6d28d9] disabled:opacity-50"
+ className="flex size-36 flex-col items-center justify-center gap-1 rounded-full border-2 border-foreground bg-brand-fill text-foreground shadow-[0_8px_0_0_var(--brand-press)] transition-transform active:translate-y-1 active:shadow-[0_3px_0_0_var(--brand-press)] disabled:opacity-50 motion-reduce:transition-none"
>
- {busy ? '✨' : voice.listening ? '👂' : '🎤'}
+ {busy ? '…' : voice.listening ? '●' : '🎤'}
{busy ? 'Building' : voice.listening ? 'Listening' : 'Tap & talk'}
- {busy &&
{BUILDING_LINES[line]}
}
+ {busy &&
{BUILDING_LINES[line]}
}
{!busy && voice.interim && (
-
“{voice.interim}”
+
“{voice.interim}”
)}
{!busy && (error || voice.error) && (
-
🙃 {voice.error ?? error}
+
⚠ {voice.error ?? error}
)}
@@ -193,15 +190,15 @@ export default function LearnPage() {
- Did you say:
- “{voiceCapture}”
+ Did you say:
+ “{voiceCapture}”
void build(voiceCapture)}
- className="flex-1 rounded-xl bg-emerald-500 py-3 font-black text-white shadow-[0_4px_0_0_#047857] active:translate-y-1 active:shadow-[0_2px_0_0_#047857]"
+ className="flex-1 rounded-xl border border-foreground bg-brand-fill py-3 font-heading font-black text-foreground shadow-[0_4px_0_0_var(--brand-press)] active:translate-y-1 active:shadow-[0_2px_0_0_var(--brand-press)]"
>
✓ Yes, let's go!
@@ -232,12 +229,12 @@ export default function LearnPage() {
onChange={(event) => setTyped(event.target.value)}
placeholder={voice.supported ? '…or type it here' : 'Type what you want to learn'}
maxLength={200}
- className="min-w-0 flex-1 rounded-2xl border-4 border-violet-200 bg-white px-5 py-3 font-semibold outline-none placeholder:text-violet-300 focus:border-violet-400"
+ className="min-w-0 flex-1 rounded-2xl border border-border bg-card px-5 py-3 font-semibold placeholder:text-muted-foreground focus:border-foreground"
/>
Go!
diff --git a/src/components/Nav.tsx b/src/components/Nav.tsx
index 5162f244..3a5905b1 100644
--- a/src/components/Nav.tsx
+++ b/src/components/Nav.tsx
@@ -5,33 +5,44 @@ import { usePathname } from 'next/navigation';
import { ThemeToggle } from '@/components/pathway/ThemeToggle';
const LINKS = [
- { href: '/', label: 'Pathway Builder' },
+ { href: '/', label: 'Builder' },
{ href: '/pathways', label: 'Pathways' },
{ href: '/roster', label: 'Roster' },
{ href: '/games', label: 'Games' },
];
-export function Nav() {
+/**
+ * Teacher-register chrome: flat card surface, 1px rule, square. The brand
+ * mark is the highlighter as a marker — a small filled square that always
+ * carries its 1px ink border (the fill is 1.41:1 on its own and never
+ * appears un-bordered). The active nav item is underlined by an inset
+ * brand-fill bar rather than a filled pill.
+ */
+export function Nav({ sourceLabel }: { sourceLabel?: string }) {
const pathname = usePathname();
return (
-
-
-
- Pathways
-
+
+
+
+
+
Practice Pathways
+
-
+
{LINKS.map(({ href, label }) => {
const active = href === '/' ? pathname === '/' : pathname.startsWith(href);
return (
{label}
@@ -40,7 +51,12 @@ export function Nav() {
})}
-
+
+ {sourceLabel && (
+
+ ✓ {sourceLabel}
+
+ )}
diff --git a/src/components/PathwayBuilder.tsx b/src/components/PathwayBuilder.tsx
index 092260d1..b1146fc6 100644
--- a/src/components/PathwayBuilder.tsx
+++ b/src/components/PathwayBuilder.tsx
@@ -5,12 +5,13 @@ import { AnimatePresence, motion } from "motion/react";
import { Plus } from "lucide-react";
import { ActivityTrail } from "@/components/pathway/ActivityTrail";
+import { BuildNarrative } from "@/components/pathway/BuildNarrative";
import {
LessonPlanUpload,
type LessonPlanPick,
} from "@/components/pathway/LessonPlanUpload";
import { PathwayCompletionStrip, PathwayDocument } from "@/components/pathway/PathwayDocument";
-import { AssignToStudents } from "@/components/roster/AssignToStudents";
+import { PlanRail } from "@/components/pathway/PlanRail";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
@@ -92,11 +93,9 @@ export function PathwayBuilder() {
const streaming = state.status === "streaming";
const started = state.status !== "idle";
- // Topic is the one thing this page asks first — it's the variable a
- // teacher is actively deciding day to day; grade is usually a fixed fact
- // about them, so it (and the submit action) only earns screen space once
- // there's actually a topic to attach it to. Mirrors `/learn`'s one-question
- // focus rather than presenting every field as equally important at once.
+ // The card shows everything it asks up front (design 1c): topic, grade,
+ // and the primary are always visible. `engaged` only decides when the
+ // example chips step aside for a topic the teacher is actually typing.
const engaged = started || Boolean(topic.trim());
function runSubmit() {
@@ -128,32 +127,40 @@ export function PathwayBuilder() {
}
return (
-
-
- {!started && (
-
-
+ {/* The ink plane: the hero is one input, floating on ink. Literal ink in
+ both themes — the plane is the same object day and night, which is
+ why the card inside scopes itself `.light-surface`. */}
+ {!started && (
+
+
+ {/* Quiet by design (1c): a mono overline and one grey sub-line —
+ no headline. The white card is the hero. */}
+
- Turn a topic into a lesson{" "}
-
- students can do
-
- .
-
-
+ New pathway
+
+
Name what you’re teaching. We find the standard it maps to
- in the Learning Commons knowledge graph, then build a pathway from
- its verified learning components — with interactive activities
- your students work through.
+ in the standards graph, then build a pathway from its verified
+ learning components.
- )}
+
+ )}
-
);
diff --git a/src/components/games/MemoryMatch.tsx b/src/components/games/MemoryMatch.tsx
index 6471fa7b..888aa766 100644
--- a/src/components/games/MemoryMatch.tsx
+++ b/src/components/games/MemoryMatch.tsx
@@ -93,7 +93,7 @@ export function MemoryMatch({ onComplete }: { onComplete?: () => void }) {
return (
-
Memory Match
+
Memory match
Moves: {moves} | Matched: {matchedPairs}/{EMOJI_PAIRS.length}
diff --git a/src/components/games/WordScramble.tsx b/src/components/games/WordScramble.tsx
index 0bdf7b60..43dc7650 100644
--- a/src/components/games/WordScramble.tsx
+++ b/src/components/games/WordScramble.tsx
@@ -109,7 +109,7 @@ export function WordScramble({ onComplete }: { onComplete?: () => void }) {
return (
-
Word Scramble
+
Word scramble
Score: {score} | Word {currentWordIndex + 1}/{WORDS.length}
diff --git a/src/components/pathway/ActivityFrame.tsx b/src/components/pathway/ActivityFrame.tsx
new file mode 100644
index 00000000..40242925
--- /dev/null
+++ b/src/components/pathway/ActivityFrame.tsx
@@ -0,0 +1,88 @@
+'use client';
+
+import type { CSSProperties, ReactNode } from 'react';
+
+/**
+ * The chrome that wraps every activity kind (design 1h): a 2px border in the
+ * activity's edge hue, a header bar on its tint with a fill-hue marker bar,
+ * the title, and a state slot. The body belongs entirely to the activity —
+ * it never draws its own header or picks its own radius.
+ *
+ * The guest hue renders through `.activity-skin` (globals.css): four derived
+ * colors from one hue at fixed lightness/chroma, so any hue keeps AA
+ * contrast. The hue appears only on this frame and the activity's own skin,
+ * never on page chrome.
+ *
+ * Hue source: the design calls for a model-chosen hue on the widget spec.
+ * Until that field ships, the hue derives deterministically from the kind —
+ * stable across renders, mapped around the 85–130° band the brand highlighter
+ * and warning amber reserve.
+ */
+const RESERVED_START = 85;
+const RESERVED_END = 130;
+
+function safeHue(x: number): number {
+ const span = 360 - (RESERVED_END - RESERVED_START);
+ let v = (x - RESERVED_END) % span;
+ if (v < 0) v += span;
+ return (RESERVED_END + v) % 360;
+}
+
+export function hueForActivity(kind: string, explicit?: number): number {
+ if (typeof explicit === 'number' && Number.isFinite(explicit)) return safeHue(explicit);
+ let h = 0x811c9dc5;
+ for (let i = 0; i < kind.length; i += 1) {
+ h ^= kind.charCodeAt(i);
+ h = Math.imul(h, 0x01000193);
+ }
+ return safeHue((h >>> 0) % 360);
+}
+
+export function kindLabel(kind: string): string {
+ return kind.replace(/-/g, ' ');
+}
+
+export function ActivityFrame({
+ kind,
+ title,
+ hue,
+ state,
+ children,
+}: {
+ kind: string;
+ title: string;
+ /** Model-chosen hue when the spec carries one; falls back to a stable per-kind hue. */
+ hue?: number;
+ /** Right-hand header slot — completion state, a review badge. */
+ state?: ReactNode;
+ children: ReactNode;
+}) {
+ const activityHue = hueForActivity(kind, hue);
+
+ return (
+
+
+
+ {title}
+
+ {kindLabel(kind)}
+
+ {state}
+
+
{children}
+
+ );
+}
diff --git a/src/components/pathway/ActivityTrail.tsx b/src/components/pathway/ActivityTrail.tsx
index 669857e5..638ee54f 100644
--- a/src/components/pathway/ActivityTrail.tsx
+++ b/src/components/pathway/ActivityTrail.tsx
@@ -1,34 +1,20 @@
'use client';
-import { Check, ChevronDown, Minus, X } from 'lucide-react';
+import { ChevronDown } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import { useEffect, useState } from 'react';
-import { Badge } from '@/components/ui/badge';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { cn } from '@/lib/utils';
import { STAGES } from '@/lib/pathway/events';
-import type { StageId } from '@/lib/pathway/events';
import type { PathwayState, StageStatus } from '@/lib/pathway/use-pathway-stream';
-/** A small warmth accent on top of each stage's real copy — not a substitute for it. */
-const STAGE_EMOJI: Record
= {
- propose: '🔍',
- verify: '✅',
- graph: '🕸️',
- plan: '🧠',
- widget: '🛠️',
-};
-
/**
- * The run's own progress, shown as work happens rather than as a spinner.
- *
- * A slim segmented strip is the primary, always-visible view — it doesn't
- * compete with the document forming below it for attention. The full stage
- * list (candidate codes, verdicts) a teacher doesn't need mid-wait moves
- * behind "How this was built", collapsed by default even while streaming —
- * demoted, not removed, the same "provenance stays reachable, not upfront"
- * rule `DocumentHeader`'s "Why this standard" already follows.
+ * The run's own progress, shown as work happens rather than as a spinner —
+ * the in-progress state is the design. A square segmented strip is the
+ * always-visible summary; the stage rows and the graph's verdicts sit in a
+ * collapsible that opens itself during a run (the trust beat should be seen)
+ * and stays reachable afterwards.
*/
export function ActivityTrail({ state }: { state: PathwayState }) {
const streaming = state.status === 'streaming';
@@ -43,20 +29,21 @@ export function ActivityTrail({ state }: { state: PathwayState }) {
: state.status === 'error'
? 'Stopped'
: 'Built the pathway';
- const emoji = streaming ? STAGE_EMOJI[activeStage?.id ?? 'propose'] : state.status === 'error' ? '🙃' : '🎉';
return (
-
+
{STAGES.map((stage) => (
-
+
@@ -75,9 +62,18 @@ export function ActivityTrail({ state }: { state: PathwayState }) {
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.2 }}
- className="flex-1 text-sm font-medium text-violet-700 dark:text-violet-300"
+ className={cn(
+ 'flex-1 text-sm font-semibold',
+ streaming
+ ? 'text-brand-text'
+ : state.status === 'error'
+ ? 'text-destructive'
+ : 'text-verified',
+ )}
>
- {emoji} {headline}
+ {!streaming && state.status !== 'error' && ✓ }
+ {state.status === 'error' && ⚠ }
+ {headline}
{!streaming && rejected > 0 && (
{' '}
@@ -88,13 +84,15 @@ export function ActivityTrail({ state }: { state: PathwayState }) {
-
+ {/* While streaming, the two-column BuildNarrative owns the stage rows
+ and verdicts; this disclosure is the after-the-fact provenance. */}
+
How this was built
-
+
{STAGES.map((stage) => {
const entry = state.stages[stage.id];
const isVerify = stage.id === 'verify';
@@ -102,33 +100,47 @@ export function ActivityTrail({ state }: { state: PathwayState }) {
return (
-
+
{stage.label}
- {entry.detail && {entry.detail} }
+
+ {entry.detail ??
+ (entry.status === 'skipped'
+ ? 'Skipped — there is no anchor to walk'
+ : entry.status === 'pending'
+ ? 'waiting'
+ : null)}
+
{showCandidates && (
-
- {state.candidates.map((candidate) => (
-
- ))}
+
+
+ The graph votes
+
+
+ {state.candidates.map((candidate) => (
+
+ ))}
+
)}
@@ -144,59 +156,58 @@ export function ActivityTrail({ state }: { state: PathwayState }) {
/**
* A code the model proposed and the graph's answer. Undefined means the graph
- * has not been asked yet — the resolution loop short-circuits on the first hit,
- * so trailing codes stay unchecked and should not read as rejected.
+ * has not been asked yet — the resolution loop short-circuits on the first
+ * hit, so trailing codes stay unchecked and must not read as rejected.
+ * Rejections stay on screen: provenance, not embarrassment.
*/
function Verdict({ code, verdict }: { code: string; verdict: boolean | undefined }) {
if (verdict === undefined) {
return (
-
+
{code}
-
+
);
}
return verdict ? (
-
-
- {code}
-
+
+ ✓ {code}
+
) : (
-
-
- {code}
-
+
+ ✗ {code}
+
);
}
-function StatusDot({ status }: { status: StageStatus }) {
- const base = 'mt-1 flex size-3.5 shrink-0 items-center justify-center rounded-full';
+/** 19px square stage markers: verified tint when done, pulsing highlighter while active, rule outline while pending. */
+function StatusMarker({ status }: { status: StageStatus }) {
+ const base = 'mt-0.5 flex size-[19px] shrink-0 items-center justify-center text-[11px]';
if (status === 'done') {
return (
-
-
+
+ ✓
);
}
if (status === 'skipped') {
return (
-
-
+
+ –
);
}
if (status === 'active') {
return (
-
-
-
-
+
);
}
- return (
-
- );
+ return ;
}
/**
diff --git a/src/components/pathway/BuildNarrative.tsx b/src/components/pathway/BuildNarrative.tsx
new file mode 100644
index 00000000..1e54bc21
--- /dev/null
+++ b/src/components/pathway/BuildNarrative.tsx
@@ -0,0 +1,272 @@
+'use client';
+
+import { motion } from 'motion/react';
+import type { CSSProperties } from 'react';
+
+import { hueForActivity, kindLabel } from '@/components/pathway/ActivityFrame';
+import { STAGES } from '@/lib/pathway/events';
+import type { PathwayState, StageStatus } from '@/lib/pathway/use-pathway-stream';
+import { cn } from '@/lib/utils';
+
+/**
+ * The two-column build narrative (design 1c): the in-progress state is the
+ * design. Left column, the five pipeline stages and the graph's verdicts as
+ * rows that settle and stay. Right column, the anchor card sliding in and
+ * plan steps appearing one at a time, each with an activity cell in its
+ * guest hue that reads "configuring…" until the widget arrives.
+ *
+ * Rendered only while a run streams — once it finishes, the plan document
+ * below is the artifact and this narrative retires; provenance stays
+ * reachable through the document's own disclosure.
+ */
+
+/** How each completion behaviour reads in the activity cell, in words. */
+const COMPLETION_COPY: Record = {
+ 'markdown-card': 'advances itself',
+ flashcard: 'advances itself',
+ 'step-reveal': 'advances itself',
+ 'narrated-card': 'advances itself',
+ 'swiper-flashcard': 'advances itself',
+ 'drag-sort': 'advances itself',
+ 'drag-categorize': 'advances itself',
+ 'timeline-builder': 'advances itself',
+ 'fraction-area-model': 'open-ended',
+ 'draft-meter': 'open-ended',
+ crossword: 'open-ended',
+};
+
+function humanName(kind: string): string {
+ const label = kindLabel(kind);
+ return label[0].toUpperCase() + label.slice(1);
+}
+
+function completionCopy(kind: string): string {
+ return COMPLETION_COPY[kind] ?? 'signals done';
+}
+
+/** Static classes — Tailwind only compiles class names it can see. */
+const PURPOSE_TAG: Record = {
+ activate: 'bg-(--purpose-activate-bg) text-(--purpose-activate-fg)',
+ model: 'bg-(--purpose-model-bg) text-(--purpose-model-fg)',
+ practice: 'bg-(--purpose-practice-bg) text-(--purpose-practice-fg)',
+ check: 'bg-(--purpose-check-bg) text-(--purpose-check-fg)',
+};
+
+export function BuildNarrative({ state }: { state: PathwayState }) {
+ if (state.status !== 'streaming') return null;
+
+ const anchorCode = state.anchor?.standard.code ?? null;
+ const steps = state.plan?.steps ?? [];
+
+ return (
+
+ {/* Left: the stages, and the trust beat. */}
+
+
+ {STAGES.map((stage) => {
+ const entry = state.stages[stage.id];
+ return (
+
+
+
+
+ {stage.label}
+
+
+ {entry.status === 'active'
+ ? stage.active
+ : (entry.detail ??
+ (entry.status === 'skipped'
+ ? 'Skipped — there is no anchor to walk'
+ : entry.status === 'pending'
+ ? 'waiting'
+ : ''))}
+
+
+
+ );
+ })}
+
+
+ {state.candidates.length > 0 && (
+
+
+ The graph votes
+
+
+ {state.candidates.map((candidate) => {
+ const verdict = state.verdicts[candidate.statementCode];
+ const isAnchor = candidate.statementCode === anchorCode;
+ if (verdict === undefined) {
+ return (
+
+ {candidate.statementCode}
+
+ );
+ }
+ return (
+
+
+ {verdict ? '✓' : '✗'} {candidate.statementCode}
+
+
+ {verdict
+ ? isAnchor
+ ? `${state.anchor?.standard.sourceLabel ?? 'verified'} · anchor`
+ : 'verified · companion'
+ : 'no such code'}
+
+
+ );
+ })}
+
+
+ )}
+
+
+ {/* Right: the pathway forming. */}
+
+ {state.anchor ? (
+
+
+ {state.anchor.standard.verified ? (
+
+ ✓ {state.anchor.standard.code}
+
+ ) : (
+
+ ⚠ no standard matched
+
+ )}
+
+ {state.anchor.standard.sourceLabel}
+
+
+ {state.anchor.standard.description}
+
+ {state.anchor.learningComponents.length > 0 && (
+
+
+ Breaks down into
+
+
+ {state.anchor.learningComponents.slice(0, 4).map((component) => (
+ · {component.description}
+ ))}
+
+
+ )}
+
+ {state.anchor.prerequisites.length > 0 && (
+
+ Builds on {state.anchor.prerequisites.map((p) => p.code).join(', ')}
+
+ )}
+
+ ) : (
+
+ Waiting for the graph…
+
+ )}
+
+ {steps.length > 0 && (
+
+ {steps.map((step, index) => {
+ if (!step?.title) return null;
+ const widget = state.stepWidgets[index];
+ const widgetKind = step.widgetKind ?? '';
+ const hue = hueForActivity(widgetKind || `step-${index}`);
+ return (
+
+
+ {step.purpose && (
+
+ {step.purpose}
+
+ )}
+
{step.title}
+
+
+ {widget ? (
+ <>
+
+ {humanName(widgetKind || 'activity')}
+
+ {completionCopy(widgetKind)}
+ >
+ ) : (
+
+ configuring…
+
+ )}
+
+
+ );
+ })}
+
+ )}
+
+
+ );
+}
+
+function Marker({ status }: { status: StageStatus }) {
+ const base = 'mt-0.5 flex size-[19px] shrink-0 items-center justify-center text-[11px]';
+ if (status === 'done')
+ return ✓ ;
+ if (status === 'skipped')
+ return – ;
+ if (status === 'active')
+ return ;
+ return ;
+}
diff --git a/src/components/pathway/PathwayDocument.tsx b/src/components/pathway/PathwayDocument.tsx
index d64448c2..6584ad47 100644
--- a/src/components/pathway/PathwayDocument.tsx
+++ b/src/components/pathway/PathwayDocument.tsx
@@ -19,6 +19,7 @@ import { Badge } from '@/components/ui/badge';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Skeleton } from '@/components/ui/skeleton';
import { Textarea } from '@/components/ui/textarea';
+import { ActivityFrame } from '@/components/pathway/ActivityFrame';
import { WidgetRenderer } from '@/components/widgets/registry';
import { plainMath } from '@/lib/learning-commons/format';
import type { Anchor, DeepPartial } from '@/lib/pathway/events';
@@ -32,14 +33,11 @@ type RegenerateStep = (anchor: Anchor, plan: PathwayPlan, stepIndex: number) =>
type EditPlan = (plan: PathwayPlan) => void;
/**
- * Each step purpose gets its own icon and stage color — the same violet/
- * pink/amber/emerald family `/learn` builds its whole identity from — so the
- * four kinds read as distinct at a glance and a teacher scanning the list
- * sees a step's category from its border alone, not just a small label.
- * Raw Tailwind palette classes rather than the token system, on purpose:
- * this is the same "local, literal color" approach `/learn` and
- * `PathwayWalkthrough` already use, deliberately separate from the app's
- * quieter shadcn token layer.
+ * Each step purpose gets its own icon and stage color from the design
+ * system's purpose-tag tokens (globals.css) — AA-checked fill/text pairs in
+ * both themes — so the four kinds read as distinct at a glance and a teacher
+ * scanning the list sees a step's category from its border alone, not just a
+ * small label.
*/
const PURPOSE_META: Record<
string,
@@ -48,30 +46,30 @@ const PURPOSE_META: Record<
activate: {
label: 'Activate',
Icon: Sparkles,
- border: 'border-pink-200 dark:border-pink-900',
- tint: 'bg-pink-100 dark:bg-pink-950/50',
- icon: 'text-pink-500 dark:text-pink-400',
+ border: 'border-(--purpose-activate-fg)/25',
+ tint: 'bg-(--purpose-activate-bg)',
+ icon: 'text-(--purpose-activate-fg)',
},
model: {
label: 'Model',
Icon: BookOpen,
- border: 'border-violet-200 dark:border-violet-900',
- tint: 'bg-violet-100 dark:bg-violet-950/50',
- icon: 'text-violet-500 dark:text-violet-400',
+ border: 'border-(--purpose-model-fg)/25',
+ tint: 'bg-(--purpose-model-bg)',
+ icon: 'text-(--purpose-model-fg)',
},
practice: {
label: 'Practice',
Icon: Repeat,
- border: 'border-amber-200 dark:border-amber-900',
- tint: 'bg-amber-100 dark:bg-amber-950/50',
- icon: 'text-amber-600 dark:text-amber-400',
+ border: 'border-(--purpose-practice-fg)/25',
+ tint: 'bg-(--purpose-practice-bg)',
+ icon: 'text-(--purpose-practice-fg)',
},
check: {
label: 'Check',
Icon: CircleCheck,
- border: 'border-emerald-200 dark:border-emerald-900',
- tint: 'bg-emerald-100 dark:bg-emerald-950/50',
- icon: 'text-emerald-600 dark:text-emerald-400',
+ border: 'border-(--purpose-check-fg)/25',
+ tint: 'bg-(--purpose-check-bg)',
+ icon: 'text-(--purpose-check-fg)',
},
};
@@ -82,10 +80,10 @@ const PURPOSE_META: Record<
* the card) carry the hue, so a step's category reads once, not three times.
*/
const WAYPOINT_META: Record = {
- activate: { dot: 'bg-pink-400' },
- model: { dot: 'bg-violet-400' },
- practice: { dot: 'bg-amber-400' },
- check: { dot: 'bg-emerald-400' },
+ activate: { dot: 'bg-(--purpose-activate-fg)' },
+ model: { dot: 'bg-(--purpose-model-fg)' },
+ practice: { dot: 'bg-(--purpose-practice-fg)' },
+ check: { dot: 'bg-(--purpose-check-fg)' },
};
/** A preview cycle of the four purposes' waypoint dots, for the skeleton — real steps aren't known yet. */
@@ -117,6 +115,12 @@ const WIDGET_KIND_LABEL: Record = {
type PartialStep = DeepPartial['steps'] extends (infer S)[] | undefined ? S : never;
+function widgetKindOfSpec(widget: unknown): string | null {
+ return widget && typeof widget === 'object' && 'kind' in widget && typeof widget.kind === 'string'
+ ? widget.kind
+ : null;
+}
+
/**
* The generated pathway, as the artifact a teacher actually reads.
*
@@ -258,11 +262,11 @@ export function PathwayDocument({
{plan?.outcomes?.map((outcome, index) => (
@@ -448,6 +452,7 @@ export function PathwayCompletionStrip({ state }: { state: PathwayState }) {
const stepCount = state.plan?.steps?.length ?? 0;
const code = state.anchor?.standard.verified ? state.anchor.standard.code : null;
+ const rejectedCount = Object.values(state.verdicts).filter((ok) => !ok).length;
const elapsedMs = state.startedAt !== null && state.finishedAt !== null ? state.finishedAt - state.startedAt : null;
const summary = [
@@ -463,10 +468,10 @@ export function PathwayCompletionStrip({ state }: { state: PathwayState }) {
initial={{ opacity: 0, y: 20, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ type: 'spring', stiffness: 280, damping: 24 }}
- className="mt-6 rounded-3xl border-3 border-emerald-200 bg-gradient-to-br from-emerald-50 via-white to-amber-50 p-5 dark:border-emerald-900 dark:from-emerald-950/40 dark:via-transparent dark:to-amber-950/10"
+ className="mt-6 rounded-3xl border border-verified-edge bg-verified-tint p-5"
>
-
+
{CONFETTI.map((p, i) => (
Your pathway is ready! 🎉
{summary && {summary}
}
+
+ Nothing is assigned yet.
+ {rejectedCount > 0 &&
+ ` ${rejectedCount} rejected code${rejectedCount === 1 ? ' is' : 's are'} kept on the session record.`}
+
-
+
@@ -497,7 +507,7 @@ export function PathwayCompletionStrip({ state }: { state: PathwayState }) {
/** One placeholder outcome row — shared by the full pre-anchor skeleton and the real document's own outcomes section while `plan` is still in flight. */
function OutcomeSkeletonRow() {
return (
-
+
@@ -588,7 +598,7 @@ function DocumentHeader({
{standard.verified ? (
<>
-
+
{standard.code}
@@ -691,7 +701,12 @@ const StepCard = memo(function StepCard({
// Quiet on purpose: the waypoint dot and the pill below already say which
// kind of step this is — a colored border on top of both just repeats
// the same signal a third time and reads as noise, not information.
-
+
{hasWidget ? (
-
+
+
+
) : pending ? (
a - b);
+ const isInjectedStep = (index: number) => injectedIndexList.includes(index);
+ const originalStepIndex = (index: number) =>
+ index - injectedIndexList.filter((i) => i < index).length;
+
+ // Same ref idiom as currentStepRef: advanceStep runs from event handlers,
+ // so reading the latest injected slots through a ref keeps its identity
+ // stable without depending on a per-render derived array.
+ const injectedIndexRef = useRef(injectedIndexList);
+ useEffect(() => { injectedIndexRef.current = injectedIndexList; });
+
const advanceStep = useCallback(() => {
setViewingStep(null);
- setStars((n) => n + 1);
+ // Injected remediation steps cost no star (design 1g) — help, not a
+ // penalty, and the star total still matches the pathway's own length.
+ if (!injectedIndexRef.current.includes(currentStepRef.current)) {
+ setStars((n) => n + 1);
+ }
telemetry.flush();
setCurrentStep((n) => {
const next = n + 1;
@@ -200,30 +226,116 @@ export function PathwayWalkthrough({
return (
-
-
- {session.standardCode ?? '✨ exploring'}
+ {/* Design 1g header: three things only — a 30px circular exit, one
+ continuous 14px progress bar, and the star count. State, topic, and
+ the standard live in the quiet line beneath. */}
+
+
+ ✕
+
+ {totalSteps > 1 ? (
+
+
+ {injectedIndexList.map((index) => (
+
+ ))}
+ {/* Invisible per-step buttons keep tap-to-review under the
+ continuous bar. */}
+
+ {Array.from({ length: totalSteps }, (_, index) => {
+ const isDone = index < currentStep;
+ const isCurrent = index === currentStep;
+ const isViewing = index === viewingStep;
+ const stepTitle = isInjectedStep(index)
+ ? 'Extra practice'
+ : (session.steps[originalStepIndex(index)]?.title ?? `Activity ${index + 1}`);
+ return (
+ setViewingStep(isViewing ? null : index)
+ : isCurrent ? () => setViewingStep(null)
+ : undefined
+ }
+ className={`h-full min-w-0 flex-1 ${!finished && (isDone || isCurrent) ? 'cursor-pointer' : 'cursor-default'} ${isViewing ? 'bg-verified/30' : 'bg-transparent'}`}
+ />
+ );
+ })}
+
+
+ ) : (
+
+ )}
+
+
+ ★
+
+ {stars}
+
+
+
+ {/* The quiet line: state on the left; topic, standard, and the break
+ link on the right. */}
+
+
+ {finished
+ ? 'All done'
+ : isReviewing
+ ? 'Looking back at a finished activity'
+ : isInjectedStep(currentStep)
+ ? 'Just added · extra practice'
+ : `Activity ${Math.min(currentStep + 1, totalSteps)} of ${totalSteps}`}
-
+ Take a break
+
+
{!finished && (
@@ -235,135 +347,83 @@ export function PathwayWalkthrough({
>
{session.bigIdea}
- {totalSteps > 1 && (
-
-
- {Array.from({ length: totalSteps }, (_, index) => {
- const isDone = index < currentStep;
- const isCurrent = index === currentStep;
- const isViewing = index === viewingStep;
- const isNewlyInjected = index === lastInjectedAt;
- // Map the absolute index back to an original step title, accounting
- // for injected slots shifting the originals forward.
- const injectedIndices = Object.keys(injectedWidgets).map(Number).sort((a, b) => a - b);
- const originalIndex = index - injectedIndices.filter((i) => i < index).length;
- const isInjected = injectedIndices.includes(index);
- const stepTitle = isInjected
- ? 'Extra practice'
- : (session.steps[originalIndex]?.title ?? `Activity ${index + 1}`);
- const tooltipText = (isDone || isCurrent) ? stepTitle : null;
- return (
-
- {tooltipText && (
-
- {tooltipText}
-
- )}
-
setViewingStep(isViewing ? null : index)
- : isCurrent ? () => setViewingStep(null)
- : undefined
- }
- // Entry: new segments pop in from scale 0
- initial={{ scaleX: 0, opacity: 0 }}
- animate={{
- scaleX: 1,
- opacity: 1,
- // Newly injected segment gets an attention pulse
- scale: isNewlyInjected ? [1, 1.15, 0.95, 1.05, 1] : 1,
- }}
- transition={isNewlyInjected
- ? { duration: 0.5, ease: 'easeOut', scale: { duration: 0.6, delay: 0.15 } }
- : { type: 'spring', stiffness: 400, damping: 30 }
- }
- className={[
- 'relative w-full rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400',
- isViewing
- ? 'h-3 bg-emerald-400/70 shadow-[0_0_0_3px_rgba(52,211,153,0.6)] cursor-pointer'
- : isCurrent
- ? isReviewing
- ? 'h-3 bg-violet-400 cursor-pointer'
- : 'h-3 bg-violet-400 shadow-[0_0_0_3px_rgba(139,92,246,0.5)] cursor-pointer'
- : isDone
- ? 'h-3 bg-emerald-400/70 cursor-pointer hover:bg-emerald-400'
- : 'h-3 bg-white/25 cursor-default',
- '',
- 'transition-[height,background-color] duration-300',
- ].join(' ')}
- >
- {/* Completion sweep — fills left-to-right when a step is done */}
- {isDone && !isViewing && (
-
- )}
- {/* Dot on whichever step is currently displayed */}
- {index === displayStep && (
-
-
-
- )}
-
-
- );
- })}
-
-
- )}
-
{isReviewing && (
setViewingStep(null)}
- className="self-start rounded-xl border-2 border-violet-200 bg-white/80 px-3 py-1 text-sm font-bold text-violet-600 hover:bg-violet-50"
+ className="self-start rounded-xl border border-border bg-card px-3 py-1 text-sm font-bold text-ink-2 hover:border-foreground hover:text-foreground"
>
← Back to current
)}
{currentWidget ? (
-
-
-
-
+
+
+ ✓ done
+
+ ) : isInjectedStep(displayStep) ? (
+
+ just added
+
+ ) : undefined
+ }
+ >
+
+
+
+
+
+ {!isReviewing && isInjectedStep(displayStep) && (
+
+ This one doesn’t cost you a star. Your pathway is waiting
+ exactly where you left it.
+
+ )}
{/* External button for widgets that fire onComplete silently (no internal CTA),
- and for the three widgets that never fire onComplete at all. */}
+ and for the three widgets that never fire onComplete at all. The reason a
+ button is live or waiting is stated, not implied. */}
{!isReviewing && !HAS_OWN_CTA.has(currentKind ?? '') && (
-
- {currentStep + 1 === totalSteps ? "I'm done! 🎉" : 'Next activity →'}
-
+
+
+ {currentStep + 1 === totalSteps ? "I'm done!" : 'Next activity →'}
+
+
+ {ALWAYS_ENABLED.has(currentKind ?? '')
+ ? 'No finish line on this one — move on whenever you want.'
+ : widgetDone
+ ? 'Nice — ready when you are.'
+ : 'Finish the activity to keep going.'}
+
+
)}
) : (
-
- ✨ Building this activity…
+
+ Building this activity…
)}
@@ -376,8 +436,13 @@ export function PathwayWalkthrough({
transition={{ type: 'spring', stiffness: 280, damping: 26 }}
className="flex w-full flex-col items-center gap-5"
>
-
🎉
-
+
+ ★
+
+
All done — {totalSteps} activities, {stars} stars!
@@ -387,14 +452,14 @@ export function PathwayWalkthrough({
type="button"
onClick={onRestart.another}
disabled={onRestart.busy}
- className="rounded-2xl bg-amber-400 px-7 py-3 font-black text-amber-950 shadow-[0_5px_0_0_#b45309] active:translate-y-1 active:shadow-[0_2px_0_0_#b45309] disabled:opacity-50"
+ className="rounded-2xl border border-foreground bg-brand-fill px-7 py-3 font-heading font-black text-foreground shadow-[0_5px_0_0_var(--brand-press)] transition-all hover:bg-brand-fill-hover active:translate-y-0.5 active:shadow-[0_2px_0_0_var(--brand-press)] disabled:opacity-50 motion-reduce:transition-none"
>
- {onRestart.busy ? 'Building…' : 'Another one! 🚀'}
+ {onRestart.busy ? 'Building…' : 'Another one!'}
New topic
diff --git a/src/components/pathway/PlanRail.tsx b/src/components/pathway/PlanRail.tsx
new file mode 100644
index 00000000..9639bbfc
--- /dev/null
+++ b/src/components/pathway/PlanRail.tsx
@@ -0,0 +1,99 @@
+'use client';
+
+import { AssignToStudents } from '@/components/roster/AssignToStudents';
+import type { PathwayPlan } from '@/lib/pathway/schema';
+import type { PathwayState } from '@/lib/pathway/use-pathway-stream';
+
+/**
+ * The plan review rail (design 1d): assignment first, then provenance —
+ * the anchor, its companions, and the codes the graph rejected, kept as a
+ * record — then coverage per outcome with an honest note where it runs thin.
+ * Rendered only once a run is done; the document owns the left column.
+ */
+export function PlanRail({ state, gradeHint }: { state: PathwayState; gradeHint?: string }) {
+ if (state.status !== 'done' || !state.anchor) return null;
+
+ const plan = state.plan as PathwayPlan | null;
+ const rejected = Object.entries(state.verdicts)
+ .filter(([, ok]) => !ok)
+ .map(([code]) => code);
+
+ return (
+
+
+
+
+
+ Provenance
+
+
+ {state.anchor.standard.verified ? (
+
+ ✓ {state.anchor.standard.code}
+
+ ) : (
+
+ ⚠ no standard matched
+
+ )}
+ {state.anchor.companions.map((companion) => (
+
+ {companion.code}
+
+ ))}
+
+ {rejected.length > 0 && (
+
+ Rejected by the graph:{' '}
+ {rejected.map((code) => (
+
+ {code}
+
+ ))}
+ Kept on the session record.
+
+ )}
+
+
+ {plan && plan.outcomes.length > 0 && (
+
+
+ Coverage by outcome
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/pathway/SharedPathwayView.tsx b/src/components/pathway/SharedPathwayView.tsx
index 7f4efe6b..d772a219 100644
--- a/src/components/pathway/SharedPathwayView.tsx
+++ b/src/components/pathway/SharedPathwayView.tsx
@@ -40,13 +40,13 @@ export function SharedPathwayView({ session }: { session: WalkthroughSession })
}, [session.sessionId, studentId]);
return (
-
-
- {session.topic}
+
+
+ {session.topic}
-
+
Build your own pathway →
diff --git a/src/components/pathways/PathwayPreview.tsx b/src/components/pathways/PathwayPreview.tsx
index 5cb8acd4..34c461ec 100644
--- a/src/components/pathways/PathwayPreview.tsx
+++ b/src/components/pathways/PathwayPreview.tsx
@@ -259,10 +259,10 @@ const PURPOSE_LABEL: Record
= {
};
const PURPOSE_COLOR: Record = {
- activate: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
- model: 'bg-violet-100 text-violet-800 dark:bg-violet-900/40 dark:text-violet-300',
- practice: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
- check: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
+ activate: 'bg-(--purpose-activate-bg) text-(--purpose-activate-fg)',
+ model: 'bg-(--purpose-model-bg) text-(--purpose-model-fg)',
+ practice: 'bg-(--purpose-practice-bg) text-(--purpose-practice-fg)',
+ check: 'bg-(--purpose-check-bg) text-(--purpose-check-fg)',
};
export type PathwayPreviewData = {
diff --git a/src/components/pathways/PathwaysDashboard.tsx b/src/components/pathways/PathwaysDashboard.tsx
index 9475a2b9..46f00a22 100644
--- a/src/components/pathways/PathwaysDashboard.tsx
+++ b/src/components/pathways/PathwaysDashboard.tsx
@@ -1,75 +1,190 @@
'use client';
import Link from 'next/link';
+import { Paperclip } from 'lucide-react';
+import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import type { SessionSummary } from '@/lib/storage/types';
function formatDate(iso: string) {
- return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
+ return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
-function GradePill({ grade }: { grade: string | null }) {
- if (!grade) return null;
+/**
+ * Verification chips carry an icon and a word — colour alone never conveys
+ * state. `EXPLORATION` is the pipeline's honest sentinel for "nothing
+ * verified"; it gets the warning treatment, stated plainly.
+ */
+function StandardChip({ code }: { code: string | null }) {
+ const unverified = !code || code === 'EXPLORATION';
+ if (unverified) {
+ return (
+
+ ⚠ no standard matched
+
+ );
+ }
return (
-
- Grade {grade}
+
+ ✓ {code}
);
}
-function StatBox({ label, value }: { label: string; value: number }) {
+const GRADES = ['K', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'];
+
+/**
+ * The quick-create bar: one ink-bordered strip whose primary is the
+ * highlighter with its mandatory ink border. Submitting hands off to the
+ * builder, which already accepts ?topic=&grade=.
+ */
+function QuickCreate({ recentTopics }: { recentTopics: string[] }) {
+ const router = useRouter();
+ const [topic, setTopic] = useState('');
+ const [grade, setGrade] = useState('');
+ const [focused, setFocused] = useState(false);
+
+ function build() {
+ const params = new URLSearchParams();
+ if (topic.trim()) params.set('topic', topic.trim());
+ if (grade) params.set('grade', grade);
+ router.push(params.size ? `/?${params}` : '/');
+ }
+
return (
-
-
{value}
-
{label}
+
+
{
+ event.preventDefault();
+ build();
+ }}
+ className="flex items-stretch border border-foreground bg-card"
+ >
+
+ {/* The bar invites typing before it is touched: a blinking caret
+ sits ahead of the placeholder until focus brings the real one. */}
+ {!topic && !focused && (
+
+ )}
+ setTopic(event.target.value)}
+ onFocus={() => setFocused(true)}
+ onBlur={() => setFocused(false)}
+ placeholder="What should your students learn next?"
+ aria-label="Pathway topic"
+ className="w-full bg-transparent px-3.5 py-2.5 pl-4.75 text-[15px] outline-none placeholder:text-muted-foreground"
+ />
+
+
+
+
+ setGrade(event.target.value)}
+ aria-label="Grade"
+ className="border-l border-border bg-transparent px-2.5 text-[12.5px] text-ink-2 outline-none"
+ >
+ Grade
+ {GRADES.map((g) => (
+
+ {g}
+
+ ))}
+
+
+ Build pathway →
+
+
+ {recentTopics.length > 0 && (
+
+
+ Reuse
+
+ {recentTopics.map((t) => (
+
+ {t}
+
+ ))}
+
+ )}
);
}
-function SessionCard({ session }: { session: SessionSummary }) {
+const ROW_GRID =
+ 'grid grid-cols-[minmax(0,1fr)_122px_96px_108px_128px_20px] items-center gap-4';
+
+function SessionRow({ session }: { session: SessionSummary }) {
+ const completion =
+ session.openCount > 0 ? Math.round((session.completionCount / session.openCount) * 100) : null;
+
return (
-
-
-
- {session.topic}
-
-
- {session.standardCode && (
- {session.standardCode}
- )}
-
- {formatDate(session.createdAt)}
-
-
-
→
-
-
-
-
-
- {session.openCount > 0 && (
-
-
- {Math.round((session.completionCount / session.openCount) * 100)}%
+
+ {session.topic}
+
+
+ {session.gradeHint && (
+ Grade {session.gradeHint}
+ )}
+ {session.stepCount > 0 && (
+
+ {session.stepCount} steps · {session.activityKinds.length} activity type
+ {session.activityKinds.length === 1 ? '' : 's'}
- completion
-
- )}
-
+ )}
+
+
+
{formatDate(session.createdAt)}
+
{session.openCount}
+
+ {session.completionCount}
+
+ {completion === null ? (
+
Needs your review
+ ) : (
+
+ {completion}%
+
+
+
+
+ )}
+
+ →
+
);
}
function LoadingSkeleton() {
return (
-
- {[...Array(3)].map((_, i) => (
-
+
+ {[...Array(4)].map((_, i) => (
+
))}
);
@@ -89,31 +204,55 @@ export function PathwaysDashboard() {
.catch(() => setError('Failed to load pathways.'));
}, []);
- if (error) {
- return
{error}
;
- }
+ const today = new Date().toLocaleDateString(undefined, {
+ weekday: 'long',
+ month: 'long',
+ day: 'numeric',
+ });
- if (!sessions) return
;
+ const recentTopics = [...new Set((sessions ?? []).map((s) => s.topic))].slice(0, 3);
- if (sessions.length === 0) {
- return (
-
-
No pathways yet. Build one from the Pathway Builder.
-
- Go to Pathway Builder
-
+ return (
+
+
+
Your pathways
+
+ {today}
+
- );
- }
- return (
-
- {sessions.map((s) => (
-
- ))}
+
+
+
+
+ Pathway
+ Built
+ Opens
+ Completed
+ Completion
+
+
+
+ {error && (
+
+ ⚠ {error}
+
+ )}
+
+ {!error && !sessions &&
}
+
+ {sessions && sessions.length === 0 && (
+
+
+ No pathways yet — build the first one from the bar above.
+
+
+ )}
+
+ {sessions && sessions.map((s) =>
)}
+
);
}
diff --git a/src/components/pathways/SessionReport.tsx b/src/components/pathways/SessionReport.tsx
index a58b5643..fc0965b1 100644
--- a/src/components/pathways/SessionReport.tsx
+++ b/src/components/pathways/SessionReport.tsx
@@ -50,7 +50,7 @@ function StatusBadge({ completed }: { completed: boolean }) {
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300'
}`}
>
- {completed ? 'Completed' : 'In Progress'}
+ {completed ? 'Completed' : 'In progress'}
);
}
diff --git a/src/components/pathways/SessionReportPage.tsx b/src/components/pathways/SessionReportPage.tsx
index 413fae9b..257a55cf 100644
--- a/src/components/pathways/SessionReportPage.tsx
+++ b/src/components/pathways/SessionReportPage.tsx
@@ -5,8 +5,7 @@ import { useEffect, useState } from 'react';
import { PathwayPreview, type PathwayPreviewData } from '@/components/pathways/PathwayPreview';
import type { PathwayPlan } from '@/lib/pathway/schema';
-import { widgetSpec } from '@/lib/pathway/schema';
-import type { SessionStudentRow } from '@/lib/storage/types';
+import type { SessionStudentRow, StepOutcome } from '@/lib/storage/types';
// ---------------------------------------------------------------------------
// Types
@@ -54,12 +53,119 @@ function AccuracyBar({ correct, attempts }: { correct: number; attempts: number
);
}
+/**
+ * The per-step evidence strip (design 1e): one cell per plan step. Verified
+ * fill = first try, warning edge = needed attempts, error = still wrong,
+ * sunk = not reached (which also covers events recorded before stepIndex was
+ * persisted — the strip never guesses). Each cell carries its word in the
+ * tooltip and for screen readers.
+ */
+const STRIP_CELL: Record
= {
+ 'first-try': { className: 'bg-verified', label: 'first try' },
+ attempts: { className: 'border border-warning-edge bg-warning-tint', label: 'needed attempts' },
+ wrong: { className: 'bg-destructive', label: 'still wrong' },
+ unreached: { className: 'bg-sunk', label: 'not reached' },
+};
+
+function StepStrip({ strip }: { strip: StepOutcome[] }) {
+ if (strip.length === 0) return — ;
+ return (
+
+ {strip.map((outcome, index) => (
+
+ ))}
+
+ );
+}
+
+export function StepStripLegend() {
+ return (
+
+ {(Object.keys(STRIP_CELL) as StepOutcome[]).map((outcome) => (
+
+
+ {STRIP_CELL[outcome].label}
+
+ ))}
+
+ );
+}
+
+/**
+ * The report's four figures (design 1e): opens, completed, completion %, and
+ * remediations — the last in the warning colour, because it is the number
+ * that asks for the teacher's attention. Remediations are counted as widgets
+ * the server injected beyond the plan's own steps.
+ */
+function ReportFigures({
+ rows,
+ childSessions,
+}: {
+ rows: SessionStudentRow[] | null;
+ childSessions: ChildSession[] | null;
+}) {
+ const opens = rows?.length ?? null;
+ const completed = rows ? rows.filter((row) => row.completed).length : null;
+ const completion =
+ opens != null && completed != null && opens > 0
+ ? `${Math.round((completed / opens) * 100)}%`
+ : null;
+ const remediations = childSessions
+ ? childSessions.reduce(
+ (sum, child) =>
+ sum +
+ Math.max(0, Object.keys(child.stepWidgets).length - (child.plan?.steps.length ?? 0)),
+ 0,
+ )
+ : null;
+
+ const figures: { label: string; value: string; warning?: boolean }[] = [
+ { label: 'Opens', value: opens != null ? String(opens) : '—' },
+ { label: 'Completed', value: completed != null ? String(completed) : '—' },
+ { label: 'Completion', value: completion ?? '—' },
+ {
+ label: 'Remediations',
+ value: remediations != null ? String(remediations) : '—',
+ warning: remediations != null && remediations > 0,
+ },
+ ];
+
+ return (
+
+ {figures.map((figure) => (
+
+
+ {figure.label}
+
+
+ {figure.warning && ⚠ }
+ {figure.value}
+
+
+ ))}
+
+ );
+}
+
function StatusBadge({ completed }: { completed: boolean }) {
return (
-
- {completed ? 'Completed' : 'In Progress'}
+
+ {completed ? '✓ Completed' : 'In progress'}
);
}
@@ -81,11 +187,15 @@ function PerformanceTable({ rows, children }: { rows: SessionStudentRow[]; child
return (
+
+
+
Student
Status
+ Steps
Accuracy
Attempts
Hints
@@ -103,6 +213,7 @@ function PerformanceTable({ rows, children }: { rows: SessionStudentRow[]; child
: {row.studentId.slice(0, 12)}… }
+
{row.attempts}
{row.hintsUsed || '—'}
@@ -196,11 +307,9 @@ function TabBar({ tabs, active, onChange }: { tabs: string[]; active: string; on
export function SessionReportPage({
sessionId,
- topic,
parentPreview,
}: {
sessionId: string;
- topic: string;
parentPreview: PathwayPreviewData | null;
}) {
const [tab, setTab] = useState<'Performance' | 'Students' | 'Preview'>('Performance');
@@ -237,6 +346,7 @@ export function SessionReportPage({
return (
+
setTab(t as typeof tab)} />
{tab === 'Performance' && (
diff --git a/src/components/roster/AddStudentModal.tsx b/src/components/roster/AddStudentModal.tsx
index 0b4478e2..aca16650 100644
--- a/src/components/roster/AddStudentModal.tsx
+++ b/src/components/roster/AddStudentModal.tsx
@@ -122,7 +122,7 @@ export function AddStudentModal({ existing, onClose, onSaved }: Props) {
>
- {existing ? 'Edit Student' : 'Add Student'}
+ {existing ? 'Edit student' : 'Add student'}
✕
@@ -242,7 +242,7 @@ export function AddStudentModal({ existing, onClose, onSaved }: Props) {
Cancel
- {saving ? 'Saving…' : existing ? 'Save Changes' : 'Add Student'}
+ {saving ? 'Saving…' : existing ? 'Save changes' : 'Add student'}
diff --git a/src/components/roster/RosterPage.tsx b/src/components/roster/RosterPage.tsx
index 32ba5420..60c849f8 100644
--- a/src/components/roster/RosterPage.tsx
+++ b/src/components/roster/RosterPage.tsx
@@ -35,12 +35,12 @@ export function RosterPage() {
-
Class Roster
+ Class roster
{!loading && (
{students.length} student{students.length !== 1 ? 's' : ''}
)}
-
setAddOpen(true)}>+ Add Student
+
setAddOpen(true)}>+ Add student
{loading ? (
@@ -55,7 +55,7 @@ export function RosterPage() {
No students yet. Add your first student to start building personalized pathways.
-
setAddOpen(true)}>Add First Student
+
setAddOpen(true)}>Add first student
) : (
diff --git a/src/components/widgets/DraftMeter.tsx b/src/components/widgets/DraftMeter.tsx
index 384cc935..1f5ba717 100644
--- a/src/components/widgets/DraftMeter.tsx
+++ b/src/components/widgets/DraftMeter.tsx
@@ -283,7 +283,7 @@ export function DraftMeter({ spec }: { spec: DraftMeterSpec }) {
aria-labelledby={questionId}
placeholder={spec.placeholder}
rows={3}
- className="mt-4 block min-h-[92px] w-full resize-y rounded-lg border border-input bg-transparent p-3.5 text-sm leading-[1.6] outline-none transition-colors placeholder:text-muted-foreground focus:border-ring focus:ring-1 focus:ring-ring"
+ className="mt-4 block min-h-[92px] w-full resize-y rounded-lg border border-input bg-transparent p-3.5 font-serif text-[15px] leading-[1.65] outline-none transition-colors placeholder:text-muted-foreground focus:border-ring focus:ring-1 focus:ring-ring"
/>
diff --git a/src/components/widgets/MarkdownCard.tsx b/src/components/widgets/MarkdownCard.tsx
index daf29960..2cb13fff 100644
--- a/src/components/widgets/MarkdownCard.tsx
+++ b/src/components/widgets/MarkdownCard.tsx
@@ -11,10 +11,12 @@ type Props = { spec: MarkdownCardSpec; onComplete?: (correct: boolean) => void }
export function MarkdownCard({ spec, onComplete }: Props) {
return (
-
-
{spec.title}
-
-
+ {/* No card chrome or header of its own — the ActivityFrame around every
+ render site already draws both (design 1h). The body is the reading. */}
+
+ {/* Student reading passages are the one Source Serif surface:
+ 17px/1.65 per the type spec. UI chrome around them stays sans. */}
+
) : (
diff --git a/src/lib/storage/memory.ts b/src/lib/storage/memory.ts
index b26181c7..87565c9a 100644
--- a/src/lib/storage/memory.ts
+++ b/src/lib/storage/memory.ts
@@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto';
import { SEED_STUDENTS } from '@/lib/roster/seed';
import type { Assignment, RosterStudent } from '@/lib/roster/types';
import { EMPTY_PROFILE, type StudentProfile } from '@/lib/student/schema';
+import { buildStepStrip } from '@/lib/storage/types';
import type {
InteractionEvent,
MasteryRollupRow,
@@ -281,6 +282,8 @@ export const memoryStorageAdapter: StorageAdapter = {
const childIds = childrenByParent.get(s.id) ?? [];
const childCompletions = childIds.reduce((sum, cid) => sum + (sessions.get(cid)?.completionCount ?? 0), 0);
const childOpens = childIds.reduce((sum, cid) => sum + [...sessionOpens].filter((k) => k.startsWith(`${cid}:`)).length, 0);
+ const steps =
+ ((s.plan as { steps?: { widgetKind?: string }[] } | null)?.steps ?? []).filter(Boolean);
return {
id: s.id,
topic: s.topic,
@@ -289,6 +292,8 @@ export const memoryStorageAdapter: StorageAdapter = {
openCount: [...sessionOpens].filter((k) => k.startsWith(`${s.id}:`)).length + childOpens,
completionCount: s.completionCount + childCompletions,
createdAt: s.createdAt ?? new Date().toISOString(),
+ stepCount: steps.length,
+ activityKinds: [...new Set(steps.map((step) => step.widgetKind).filter((k): k is string => Boolean(k)))],
};
});
},
@@ -322,14 +327,21 @@ export const memoryStorageAdapter: StorageAdapter = {
const byStudent = new Map;
}>();
for (const event of interactions) {
if (!relevantSessionIds.has(event.sessionId)) continue;
- const row = byStudent.get(event.studentId) ?? { attempts: 0, correctCount: 0, hintsUsed: 0, elapsedMs: [], lastSeenAt: new Date(0).toISOString() };
+ const row = byStudent.get(event.studentId) ?? { attempts: 0, correctCount: 0, hintsUsed: 0, elapsedMs: [], lastSeenAt: new Date(0).toISOString(), perStep: new Map() };
if (event.correct !== null) row.attempts += 1;
if (event.correct === true) row.correctCount += 1;
if (event.eventType === 'hint_requested') row.hintsUsed += 1;
+ if (event.eventType === 'widget_completed' && typeof event.payload?.stepIndex === 'number') {
+ const cell = row.perStep.get(event.payload.stepIndex) ?? { right: false, wrong: false };
+ if (event.correct === true) cell.right = true;
+ if (event.correct === false) cell.wrong = true;
+ row.perStep.set(event.payload.stepIndex, cell);
+ }
row.elapsedMs.push(event.elapsedMs);
row.lastSeenAt = new Date().toISOString();
byStudent.set(event.studentId, row);
@@ -339,13 +351,13 @@ export const memoryStorageAdapter: StorageAdapter = {
for (const a of childAssignments) {
const childSession = sessions.get(a.sessionId);
if (childSession && !byStudent.has(childSession.studentId)) {
- byStudent.set(childSession.studentId, { attempts: 0, correctCount: 0, hintsUsed: 0, elapsedMs: [], lastSeenAt: childSession.createdAt ?? new Date().toISOString() });
+ byStudent.set(childSession.studentId, { attempts: 0, correctCount: 0, hintsUsed: 0, elapsedMs: [], lastSeenAt: childSession.createdAt ?? new Date().toISOString(), perStep: new Map() });
}
}
// Include the parent session's own anon student (the teacher who built it).
if (sessionObj && !byStudent.has(sessionObj.studentId)) {
- byStudent.set(sessionObj.studentId, { attempts: 0, correctCount: 0, hintsUsed: 0, elapsedMs: [], lastSeenAt: sessionObj.createdAt ?? new Date().toISOString() });
+ byStudent.set(sessionObj.studentId, { attempts: 0, correctCount: 0, hintsUsed: 0, elapsedMs: [], lastSeenAt: sessionObj.createdAt ?? new Date().toISOString(), perStep: new Map() });
}
return [...byStudent.entries()].map(([studentId, row]) => {
@@ -363,6 +375,7 @@ export const memoryStorageAdapter: StorageAdapter = {
completed: row.attempts >= (sessionObj?.plan?.steps?.length ?? 0) && row.attempts > 0,
medianElapsedMs: median,
lastSeenAt: row.lastSeenAt,
+ stepStrip: buildStepStrip(sessionObj?.plan?.steps?.length ?? 0, row.perStep),
};
});
},
diff --git a/src/lib/storage/supabase.ts b/src/lib/storage/supabase.ts
index 4ca92e87..9c462834 100644
--- a/src/lib/storage/supabase.ts
+++ b/src/lib/storage/supabase.ts
@@ -1,6 +1,7 @@
import { supabaseAdmin, supabaseConfigured } from '@/lib/supabase/client';
import type { Assignment, RosterStudent } from '@/lib/roster/types';
import { EMPTY_PROFILE, studentProfile, type StudentProfile } from '@/lib/student/schema';
+import { buildStepStrip } from '@/lib/storage/types';
import type {
InteractionEvent,
MasteryRollupRow,
@@ -358,7 +359,7 @@ export const supabaseStorageAdapter: StorageAdapter = {
let query = supabaseAdmin()
.from('pathway_sessions')
- .select('id, topic, standard_code, grade_hint, completion_count, created_at, session_opens(count)')
+ .select('id, topic, standard_code, grade_hint, completion_count, created_at, steps:plan->steps, session_opens(count)')
.order('created_at', { ascending: false })
.limit(limit);
const childIds = [...childIdSet];
@@ -395,6 +396,7 @@ export const supabaseStorageAdapter: StorageAdapter = {
const kids = childrenByParent.get(pid) ?? [];
const childCompletionsTotal = kids.reduce((s, cid) => s + (childCompletionById.get(cid) ?? 0), 0);
const childOpensTotal = kids.reduce((s, cid) => s + (childOpenCount.get(cid) ?? 0), 0);
+ const steps = (Array.isArray(row.steps) ? row.steps : []) as { widgetKind?: string }[];
return {
id: pid,
topic: String(row.topic),
@@ -403,6 +405,8 @@ export const supabaseStorageAdapter: StorageAdapter = {
openCount: Number((row.session_opens as unknown as { count: number }[])?.[0]?.count ?? 0) + childOpensTotal,
completionCount: Number(row.completion_count ?? 0) + childCompletionsTotal,
createdAt: String(row.created_at),
+ stepCount: steps.length,
+ activityKinds: [...new Set(steps.map((step) => step.widgetKind).filter((k): k is string => Boolean(k)))],
};
});
},
@@ -454,7 +458,7 @@ export const supabaseStorageAdapter: StorageAdapter = {
const allSessionIds = [sessionId, ...childSessionIds];
const { data: interactionRows, error: interactionError } = await supabaseAdmin()
.from('interactions')
- .select('student_id, event_type, correct, elapsed_ms')
+ .select('student_id, event_type, correct, elapsed_ms, stepIndex:payload->stepIndex')
.in('session_id', allSessionIds);
// Every number in the report comes from these rows, so a failure here is a
// wrong report rather than an empty one.
@@ -465,14 +469,24 @@ export const supabaseStorageAdapter: StorageAdapter = {
const byStudent = new Map;
}>();
for (const row of interactionRows ?? []) {
const sid = String(row.student_id);
- const entry = byStudent.get(sid) ?? { attempts: 0, correctCount: 0, hintsUsed: 0, elapsedMs: [], lastSeenAt: new Date(0).toISOString() };
+ const entry = byStudent.get(sid) ?? { attempts: 0, correctCount: 0, hintsUsed: 0, elapsedMs: [], lastSeenAt: new Date(0).toISOString(), perStep: new Map() };
if (row.correct !== null) entry.attempts += 1;
if (row.correct === true) entry.correctCount += 1;
if (row.event_type === 'hint_requested') entry.hintsUsed += 1;
+ if (row.event_type === 'widget_completed' && row.stepIndex != null) {
+ const stepIndex = Number(row.stepIndex);
+ if (Number.isFinite(stepIndex)) {
+ const cell = entry.perStep.get(stepIndex) ?? { right: false, wrong: false };
+ if (row.correct === true) cell.right = true;
+ if (row.correct === false) cell.wrong = true;
+ entry.perStep.set(stepIndex, cell);
+ }
+ }
if (row.elapsed_ms != null) entry.elapsedMs.push(Number(row.elapsed_ms));
entry.lastSeenAt = new Date().toISOString();
byStudent.set(sid, entry);
@@ -481,13 +495,13 @@ export const supabaseStorageAdapter: StorageAdapter = {
// Include roster students from child assignments even with no interactions yet.
for (const [anonId] of anonToRoster) {
if (!byStudent.has(anonId)) {
- byStudent.set(anonId, { attempts: 0, correctCount: 0, hintsUsed: 0, elapsedMs: [], lastSeenAt: new Date().toISOString() });
+ byStudent.set(anonId, { attempts: 0, correctCount: 0, hintsUsed: 0, elapsedMs: [], lastSeenAt: new Date().toISOString(), perStep: new Map() });
}
}
// Include session owner even with no interactions.
if (sessionRow && !byStudent.has(String(sessionRow.student_id))) {
- byStudent.set(String(sessionRow.student_id), { attempts: 0, correctCount: 0, hintsUsed: 0, elapsedMs: [], lastSeenAt: new Date().toISOString() });
+ byStudent.set(String(sessionRow.student_id), { attempts: 0, correctCount: 0, hintsUsed: 0, elapsedMs: [], lastSeenAt: new Date().toISOString(), perStep: new Map() });
}
const stepCount = (sessionRow?.plan as { steps?: unknown[] } | null)?.steps?.length ?? 0;
@@ -507,6 +521,7 @@ export const supabaseStorageAdapter: StorageAdapter = {
completed: stepCount > 0 && row.attempts >= stepCount,
medianElapsedMs: median,
lastSeenAt: row.lastSeenAt,
+ stepStrip: buildStepStrip(stepCount, row.perStep),
};
});
},
diff --git a/src/lib/storage/types.ts b/src/lib/storage/types.ts
index 424278b9..d33a5f97 100644
--- a/src/lib/storage/types.ts
+++ b/src/lib/storage/types.ts
@@ -62,8 +62,32 @@ export type SessionSummary = {
openCount: number;
completionCount: number;
createdAt: string;
+ /** Steps in the plan — 0 when the plan never finished. */
+ stepCount: number;
+ /** Distinct widget kinds across the plan's steps, for the dashboard's chip line. */
+ activityKinds: string[];
};
+/**
+ * One cell of the report's per-step evidence strip: what happened at that
+ * step, in one word. 'unreached' covers both "never got there" and events
+ * recorded before stepIndex was persisted — the strip never guesses.
+ */
+export type StepOutcome = 'first-try' | 'attempts' | 'wrong' | 'unreached';
+
+/** Fold per-step completion evidence into strip cells. Pure; shared by both adapters. */
+export function buildStepStrip(
+ stepCount: number,
+ perStep: Map,
+): StepOutcome[] {
+ return Array.from({ length: stepCount }, (_, index) => {
+ const cell = perStep.get(index);
+ if (!cell) return 'unreached';
+ if (cell.right) return cell.wrong ? 'attempts' : 'first-try';
+ return 'wrong';
+ });
+}
+
/** Per-student performance row for the session report view. */
export type SessionStudentRow = {
studentId: string;
@@ -76,6 +100,8 @@ export type SessionStudentRow = {
completed: boolean;
medianElapsedMs: number | null;
lastSeenAt: string;
+ /** One outcome per plan step — the report's evidence strip. */
+ stepStrip: StepOutcome[];
};
export interface StorageAdapter {