Build an Interruptible Teaching Loop
Build a local React teaching interface with interruptible XState workflows.
Introduction
30 Second Summary
A live explanation can feel smooth until a learner interrupts halfway through. Without one clear coordinator, the voice may pause while the visual keeps changing.
In this project, you will build a local teaching interface where Luna narrates a short lesson while revealing an Excalidraw diagram one element at a time. Typed XState events coordinate interruption across a React dashboard.
What You'll Build
You demo a Luna dashboard where narration builds a labelled teaching diagram until Interrupt freezes the lesson on its current scene.
By the end of this project, you'll have:
- Coordinated lesson playback where each narration segment reveals the next diagram element.
- An inspectable runtime where the active pedagogical state appears beside a compact recent-event log.
- An atomic interruption flow that freezes the lesson while preserving the current canvas scene.
- Secret Mission: Send an invalid event while Luna is idle to prove the machine rejects impossible transitions.
Are there any prerequisites?
This project assumes experience with state machines plus an existing Node.js, npm, and Visual Studio Code setup. Your Node.js version must satisfy Vite's requirement of 20.19+ or 22.12+.
Before We Start
Before the hands-on work begins, define the small teaching loop you are building. This keeps the focus on why narration and drawing must pause together during an interruption.
Scaffold the Local Prototype
Luna's teaching loop needs a small browser runtime before you can make its narration and drawing interruptible.
The Vite scaffold gives you a local React interface with TypeScript. Your existing Node.js setup can generate it through npm.
In this step, get ready to:
- Scaffold the cognicanvas project with the React TypeScript template.
- Install the project dependencies.
- Confirm the React starter page runs in your browser.
Create the Vite project
The scaffold creates the project structure that every later step builds on. Starting from your Desktop gives the new folder a predictable location.
Why Vite for this prototype?
This prototype runs entirely in the browser. Vite keeps the runtime focused on the teaching workflow.
Next.js includes server behavior and routing features that this prototype does not use. Those features belong in a project with a server event gateway.
- Open a terminal from your Linux application menu.
- Move to your Desktop by running this command:
cd ~/Desktop
What does this command do?
The command moves your terminal into the Desktop directory. The terminal prompt should now show that location.
The generator pauses for a project name and template choices. These selections produce the required React TypeScript scaffold.
What will Vite ask?
- Enter cognicanvas when Vite asks for the project name.
- Choose React when Vite asks for the framework.
- Choose the TypeScript template identified by react-ts.
- Start Vite's interactive project generator by running this command:
npm create vite@latest
What does this command create?
- The generator creates a cognicanvas directory on your Desktop.
- The react-ts template adds the React source files and TypeScript configuration.
- The generated package.json declares the scaffold dependencies and Vite scripts.
You should see the terminal report that the project was created in cognicanvas.
Did the scaffold stop early?
Confirm that your Node.js version satisfies Vite's requirement of 20.19+ or 22.12+. An unsupported version can stop the generator.
Check your internet connection if npm cannot download the generator.
Help me diagnose why the Vite React TypeScript scaffold did not create my cognicanvas project.
Install the scaffold dependencies
The generated package.json describes the packages required by the starter project. Installing them creates the local node_modules directory.
- Move into the generated cognicanvas directory by running this command:
cd cognicanvas
What does this command do?
The command makes cognicanvas the terminal's current directory. Every remaining command in this step now applies to that project.
Your terminal prompt should now end with cognicanvas.
- Install the dependencies declared by the scaffold by running this command:
npm install
What does this command install?
The command reads package.json and downloads its declared dependencies into node_modules.
The project foundation is ready. Your node_modules directory now holds the scaffold dependencies.
Did npm fail to install the dependencies?
Confirm that the terminal prompt ends with cognicanvas. Running the command from another directory prevents npm from finding this project's manifest.
Check your internet connection if the package download cannot complete.
Help me fix the dependency installation in my cognicanvas project.
Add orchestration and canvas packages
XState provides the finite-state workflow that controls Luna's legal transitions. Excalidraw provides the visual teaching surface.
- Install the XState runtime and its React integration by running this command:
npm install xstate @xstate/react
What does this command add?
- The xstate package provides the state machine runtime.
- The @xstate/react package connects that runtime to React components.
- Both packages are added to package.json.
The terminal should return to its prompt without an installation error. Your project now has its orchestration dependencies.
Could npm not add XState?
Keep the terminal inside the cognicanvas directory. Confirm that the earlier dependency installation completed before retrying.
Help me diagnose why the XState packages did not install in my Vite project.
- Install React DOM and the Excalidraw package by running this command:
npm install react react-dom @excalidraw/excalidraw
What does this command add?
- React DOM connects the React interface to the browser document.
- The @excalidraw/excalidraw package adds the canvas component used for Luna's visual lesson.
- The dependency manifest records React and the canvas packages together.
The terminal should return to its prompt without an installation error. The project now has every dependency required by the later teaching interface.
Could npm not add Excalidraw?
Confirm that the first npm install completed successfully. A partial scaffold dependency installation can block later packages.
Help me diagnose why the React and Excalidraw packages did not install.
The development server keeps this terminal occupied while it serves the project. Keep the process running so the local page remains available.
Before you start the server, do you expect the generated React interface or Luna's finished teaching dashboard to appear?
- Start the local Vite development server by running this command:
npx vite
What does this command start?
The command starts Vite's development server from the current cognicanvas directory. The terminal output includes the local address http://localhost:5173.
- Leave the Vite server process running in the terminal.
- Visit http://localhost:5173 in your browser.
That's your first visible win. You should see the generated React starter page served from the cognicanvas project.
Does the starter page fail to load?
Confirm that the terminal still shows the running Vite process. Closing that process stops the local page.
Check that your browser is using http://localhost:5173 exactly.
Help me diagnose why the Vite React starter page is not loading locally.
✔️ Awesome, I've got everything!
Your local React runtime is live. Keep the Vite terminal running for the next step.
ⓧ I'd like to double check the final state
- Confirm the cognicanvas directory contains a Vite React TypeScript project created with the react-ts template.
- Confirm your terminal is inside the cognicanvas directory.
- Confirm the node_modules directory contains the installed dependencies.
- Confirm package.json includes react, react-dom, xstate, @xstate/react, and @excalidraw/excalidraw.
- Confirm the Vite development server is running at http://localhost:5173.
- Confirm the browser displays the generated React starter page.
Your local prototype runtime is ready. Next, you'll replace the starter interface with Luna's teaching canvas.
Build Luna's Teaching Canvas
Your React starter already proves that Vite can serve the project in your browser. Luna now needs a visible teaching surface before her orchestration has anything to control.
This step turns the starter page into a dashboard with a live Excalidraw canvas. A converted rectangle gives you an observable first scene for the lesson.
In this step, get ready to:
- Replace the starter styles with the CogniCanvas visual foundation.
- Render Luna's temporary dashboard shell.
- Display a view-only canvas containing a labelled learner-goal box.
Reset the global styles
The global CSS establishes predictable sizing across the page. It also gives every dashboard element the same font foundation.
- Select src/index.css in the Visual Studio Code Explorer sidebar.
- Replace its contents with this global style layer:
:root {
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
color: #0f172a;
background: #f8fafc;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
body {
min-width: 320px;
min-height: 100vh;
margin: 0;
}
button,
input,
textarea {
font: inherit;
}
What does this style layer do?
- The root styles set the system font stack used across the dashboard.
- The universal selector makes borders and padding part of each element's declared size.
- The body rules remove the starter margin so the dashboard can fill the browser window.
- Save src/index.css.
- Return to the browser running http://localhost:5173.
The starter page now uses the pale background with its outer browser margin removed.
Still seeing the original page spacing?
Confirm that you edited src/index.css inside the cognicanvas folder. Check that the file is saved.
If the browser stopped updating, confirm that the existing Vite terminal still shows the development server as active.
Help me find why my Vite page is not applying src/index.css.
✔️ The global styles changed
Your page now has the global typography and sizing foundation needed by the dashboard.
ⓧ I'd like to double check the full code
:root {
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
color: #0f172a;
background: #f8fafc;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
body {
min-width: 320px;
min-height: 100vh;
margin: 0;
}
button,
input,
textarea {
font: inherit;
}
Render the temporary dashboard shell
Excalidraw converts a compact element description into the full scene data its canvas needs. The temporary shell uses that conversion to render one rectangle labelled Learner goal.
- Select src/App.tsx in the Visual Studio Code Explorer sidebar.
- Replace its contents with this temporary dashboard shell:
import {
Excalidraw,
convertToExcalidrawElements,
} from '@excalidraw/excalidraw'
import '@excalidraw/excalidraw/index.css'
import './App.css'
const LESSON_SEGMENTS = [
{
element: {
type: 'rectangle' as const, id: 'learner-goal', x: 40, y: 90,
width: 220, height: 100,
backgroundColor: '#dbeafe', fillStyle: 'solid' as const,
label: { text: 'Learner goal' },
},
},
]
function App() {
const elements = convertToExcalidrawElements(LESSON_SEGMENTS.map(({ element }) => element))
return (<main className="app-shell">
<header className="hero"><div><p className="eyebrow">CogniCanvas local prototype</p><h1>Luna's interruptible teaching loop</h1><p className="subtitle">Simulated narration and canvas events, coordinated by XState.</p></div><div className="status-card"><span>Page 1</span><strong>idle</strong></div></header>
<section className="stage-rail" aria-label="Pedagogical states"><div className="stage active">idle</div><div className="stage">teaching_topic</div><div className="stage">checkpoint_prompt</div><div className="stage">doubt_resolution</div><div className="stage">resume_or_paginate</div></section>
<section className="workspace">
<aside className="control-panel"><div><p className="panel-label">Luna says</p><p className="narration">Ready to teach a local lesson.</p></div><div className="progress-block"><span>Visible elements</span><strong>1/4</strong></div><div className="controls"><button>Start lesson</button><button className="warning">Interrupt</button><button className="secondary">Next page</button><button className="ghost">Reset</button></div><div><p className="panel-label">Recent events</p><ol className="event-log"><li>machine:idle</li></ol></div></aside>
<div className="canvas-card"><Excalidraw initialData={{ elements }} viewModeEnabled /></div>
</section>
</main>)
}
export default App
How does the canvas shell work?
- The element skeleton describes the rectangle's position, size, fill colour, identifier, and label.
- The convertToExcalidrawElements() call turns that skeleton into an Excalidraw scene element.
- The initialData property gives the converted element to the canvas when it mounts.
- The viewModeEnabled property keeps this teaching surface view-only.
- Save src/App.tsx.
- Return to the browser at http://localhost:5173.
You can now see Luna's heading, pedagogical state labels, controls, and event-log text. The canvas gains its visible height in the next style layer.
Seeing a blank page?
Check that the Excalidraw import uses @excalidraw/excalidraw. Confirm that the stylesheet import ends with index.css.
Compare the braces around LESSON_SEGMENTS with the reference code. A missing closing brace prevents the React component from compiling.
Help me diagnose why the temporary Excalidraw dashboard is blank.
✔️ The dashboard shell renders
The temporary React shell now exposes the controls and states that the teaching workflow will eventually drive.
ⓧ I'd like to double check the full code
import {
Excalidraw,
convertToExcalidrawElements,
} from '@excalidraw/excalidraw'
import '@excalidraw/excalidraw/index.css'
import './App.css'
const LESSON_SEGMENTS = [
{
element: {
type: 'rectangle' as const, id: 'learner-goal', x: 40, y: 90,
width: 220, height: 100,
backgroundColor: '#dbeafe', fillStyle: 'solid' as const,
label: { text: 'Learner goal' },
},
},
]
function App() {
const elements = convertToExcalidrawElements(LESSON_SEGMENTS.map(({ element }) => element))
return (<main className="app-shell">
<header className="hero"><div><p className="eyebrow">CogniCanvas local prototype</p><h1>Luna's interruptible teaching loop</h1><p className="subtitle">Simulated narration and canvas events, coordinated by XState.</p></div><div className="status-card"><span>Page 1</span><strong>idle</strong></div></header>
<section className="stage-rail" aria-label="Pedagogical states"><div className="stage active">idle</div><div className="stage">teaching_topic</div><div className="stage">checkpoint_prompt</div><div className="stage">doubt_resolution</div><div className="stage">resume_or_paginate</div></section>
<section className="workspace">
<aside className="control-panel"><div><p className="panel-label">Luna says</p><p className="narration">Ready to teach a local lesson.</p></div><div className="progress-block"><span>Visible elements</span><strong>1/4</strong></div><div className="controls"><button>Start lesson</button><button className="warning">Interrupt</button><button className="secondary">Next page</button><button className="ghost">Reset</button></div><div><p className="panel-label">Recent events</p><ol className="event-log"><li>machine:idle</li></ol></div></aside>
<div className="canvas-card"><Excalidraw initialData={{ elements }} viewModeEnabled /></div>
</section>
</main>)
}
export default App
Build the dashboard layout and verify the canvas
Excalidraw fills the width and height of its containing block. The dashboard therefore needs a fixed non-zero canvas height before the converted rectangle can appear.
- Select src/App.css in the Visual Studio Code Explorer sidebar.
- Replace the starter styles with this page foundation:
#root {
min-height: 100vh;
}
.app-shell {
min-height: 100vh;
padding: 28px;
color: #0f172a;
background:
radial-gradient(circle at top left, #ede9fe 0, transparent 34%),
#f8fafc;
}
.hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
max-width: 1280px;
margin: 0 auto 20px;
}
What does the page foundation control?
The shell fills the viewport with a pale background and a purple glow. The hero keeps Luna's title beside the current-state card.
- Save src/App.css.
- Refresh the browser.
The dashboard now fills the window. Luna's title sits inside a centred content area.
Is the starter layout still centred?
Remove every starter rule from src/App.css before adding this block. A leftover starter #root rule can constrain the new shell.
Help me remove the conflicting Vite starter styles from src/App.css.
- Add the dashboard typography below the existing .hero block:
.eyebrow,
.panel-label {
margin: 0 0 8px;
color: #7c3aed;
font-size: 0.76rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
h1 {
margin: 0;
font-size: clamp(2rem, 4vw, 3.6rem);
line-height: 1;
}
.subtitle {
margin: 12px 0 0;
color: #475569;
}
How does the typography create hierarchy?
The small uppercase labels separate interface sections. The responsive heading gives Luna's lesson title the strongest visual weight.
- Save src/App.css.
- Refresh the browser.
The title is now larger. The eyebrow and panel labels appear as compact purple text.
Is the heading still small?
Confirm that the selector is written as h1. Check that the new block sits outside the closing brace of .hero.
Help me find why the CogniCanvas heading styles are not applying.
- Add the current-state card below the .subtitle block:
.status-card {
min-width: 190px;
padding: 14px 18px;
border: 1px solid #ddd6fe;
border-radius: 16px;
background: rgba(255, 255, 255, 0.86);
box-shadow: 0 12px 28px rgba(76, 29, 149, 0.09);
}
.status-card span,
.status-card strong {
display: block;
}
.status-card span {
color: #64748b;
font-size: 0.82rem;
}
.status-card strong {
margin-top: 5px;
color: #6d28d9;
}
What does the state card show?
The card separates the page number from Luna's active state. Its stronger state value makes the orchestration status easy to scan.
- Save src/App.css.
- Refresh the browser.
The page number and idle state now sit inside a rounded card in the hero.
Is the state card missing?
Confirm that the header contains an element with className="status-card". Check that the selector begins with a full stop.
Help me connect the status-card JSX to its CSS selector.
- Add the pedagogical state rail below the .status-card strong block:
.stage-rail {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
max-width: 1280px;
margin: 0 auto 16px;
}
.stage {
overflow: hidden;
padding: 10px;
border: 1px solid #e2e8f0;
border-radius: 10px;
color: #64748b;
background: #ffffff;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.75rem;
text-align: center;
text-overflow: ellipsis;
}
Why use a state rail?
The rail keeps all five pedagogical states visible at once. This makes future transitions observable without opening development tools.
- Save src/App.css.
- Refresh the browser.
The five state names now form a single row of bordered cells.
Are the states stacked vertically?
Confirm that .stage-rail uses a grid with five columns. Check that each state remains inside the same rail section.
Help me fix the five-column pedagogical state rail.
- Add the active state and workspace layout below the .stage block:
.stage.active {
border-color: #7c3aed;
color: #ffffff;
background: #7c3aed;
box-shadow: 0 8px 18px rgba(124, 58, 237, 0.22);
}
.workspace {
display: grid;
grid-template-columns: 300px minmax(0, 1fr);
gap: 16px;
max-width: 1280px;
margin: 0 auto;
}
.control-panel,
.canvas-card {
border: 1px solid #e2e8f0;
border-radius: 18px;
background: #ffffff;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
}
How is the workspace divided?
The workspace reserves a fixed column for Luna's controls. The remaining width belongs to the teaching canvas.
- Save src/App.css.
- Refresh the browser.
The idle state is now purple. The control panel moves into the left column beside the canvas card.
Is the workspace still one column?
Check that .workspace contains both the control panel and canvas card. Confirm that its grid declaration has two columns.
Help me fix the two-column CogniCanvas workspace.
- Add the control-panel spacing below the shared card styles:
.control-panel {
display: flex;
flex-direction: column;
gap: 22px;
padding: 20px;
}
.narration {
min-height: 88px;
margin: 0;
color: #334155;
font-size: 1.05rem;
line-height: 1.55;
}
.progress-block {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
border-radius: 12px;
background: #f1f5f9;
}
What does the control panel prioritise?
The panel gives narration enough height to remain readable as its text changes. The progress block keeps the visible-element count separate from the controls.
- Save src/App.css.
- Refresh the browser.
Luna's narration now has clear spacing. The visible-element count sits inside a pale progress row.
Is the control panel cramped?
Confirm that .control-panel has padding and a vertical gap. Check that the narration uses the narration class.
Help me fix the spacing inside the CogniCanvas control panel.
- Add the control styles below the .progress-block block:
.controls {
display: grid;
gap: 9px;
}
button {
padding: 11px 14px;
border: 0;
border-radius: 10px;
color: #ffffff;
background: #7c3aed;
font: inherit;
font-weight: 750;
cursor: pointer;
}
button:hover {
filter: brightness(1.06);
}
button.warning {
background: #dc2626;
}
button.secondary {
background: #0f766e;
}
Why do the controls use distinct colours?
The default purple marks the primary path. Red separates interruption from routine progress. Teal distinguishes pagination as an alternative path.
- Save src/App.css.
- Refresh the browser.
The controls now form a vertical stack with distinct colours for interruption and pagination.
Are the buttons still plain?
Confirm that the controls remain inside the element with className="controls". Check the specialised class names on the red and teal buttons.
Help me match the CogniCanvas button classes to their CSS selectors.
- Add the event log and fixed canvas height below the secondary-button block:
button.ghost {
border: 1px solid #cbd5e1;
color: #334155;
background: #ffffff;
}
.event-log {
display: grid;
gap: 6px;
margin: 0;
padding-left: 22px;
color: #475569;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.76rem;
}
.canvas-card {
height: 610px;
min-width: 0;
overflow: hidden;
}
Why does the canvas need a fixed height?
Excalidraw takes its dimensions from the containing block. The 610px height gives the canvas a real drawing area while the hidden overflow keeps it inside the rounded card.
- Save src/App.css.
- Refresh the browser.
The canvas now fills a large white card. You can see a blue rectangle labelled Learner goal inside it.
Is the canvas empty or collapsed?
Confirm that .canvas-card has a height of 610px. Check that Excalidraw remains inside the matching canvas-card element.
If the card has height but no rectangle, compare the initialData property with the converted elements value in src/App.tsx.
Help me diagnose why my fixed-height Excalidraw canvas does not show the Learner goal rectangle.
- Add the responsive layout below the .canvas-card block:
@media (max-width: 850px) {
.hero {
display: grid;
}
.stage-rail {
grid-template-columns: 1fr 1fr;
}
.workspace {
grid-template-columns: 1fr;
}
.canvas-card {
height: 520px;
}
}
How does the narrow layout adapt?
The hero and workspace collapse into single-column layouts on narrow screens. The state rail uses two columns so its labels remain readable.
- Save src/App.css.
- Narrow the browser window below 850px wide.
The control panel now moves above the canvas. The learner-goal rectangle remains visible inside the shorter canvas card.
Does the narrow layout overflow?
Confirm that the media rule closes after the canvas height declaration. Check that .workspace changes to one column inside that rule.
Help me fix overflow in the responsive CogniCanvas layout.
Before the final check, do you expect the canvas to contain zero elements or one element?
- Restore the browser to its normal width.
- Refresh http://localhost:5173.
You should see the styled Luna dashboard with one blue rectangle labelled Learner goal. The canvas remains view-only inside its fixed-height card.
That is the first visible teaching surface complete. Luna now has a scene that future events can reveal and preserve.
✔️ Awesome, I've got everything!
Your dashboard now has global styles, an inspectable teaching layout, and a non-empty Excalidraw scene.
ⓧ I'd like to double check the full code
#root {
min-height: 100vh;
}
.app-shell {
min-height: 100vh;
padding: 28px;
color: #0f172a;
background:
radial-gradient(circle at top left, #ede9fe 0, transparent 34%),
#f8fafc;
}
.hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
max-width: 1280px;
margin: 0 auto 20px;
}
.eyebrow,
.panel-label {
margin: 0 0 8px;
color: #7c3aed;
font-size: 0.76rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
h1 {
margin: 0;
font-size: clamp(2rem, 4vw, 3.6rem);
line-height: 1;
}
.subtitle {
margin: 12px 0 0;
color: #475569;
}
.status-card {
min-width: 190px;
padding: 14px 18px;
border: 1px solid #ddd6fe;
border-radius: 16px;
background: rgba(255, 255, 255, 0.86);
box-shadow: 0 12px 28px rgba(76, 29, 149, 0.09);
}
.status-card span,
.status-card strong {
display: block;
}
.status-card span {
color: #64748b;
font-size: 0.82rem;
}
.status-card strong {
margin-top: 5px;
color: #6d28d9;
}
.stage-rail {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
max-width: 1280px;
margin: 0 auto 16px;
}
.stage {
overflow: hidden;
padding: 10px;
border: 1px solid #e2e8f0;
border-radius: 10px;
color: #64748b;
background: #ffffff;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.75rem;
text-align: center;
text-overflow: ellipsis;
}
.stage.active {
border-color: #7c3aed;
color: #ffffff;
background: #7c3aed;
box-shadow: 0 8px 18px rgba(124, 58, 237, 0.22);
}
.workspace {
display: grid;
grid-template-columns: 300px minmax(0, 1fr);
gap: 16px;
max-width: 1280px;
margin: 0 auto;
}
.control-panel,
.canvas-card {
border: 1px solid #e2e8f0;
border-radius: 18px;
background: #ffffff;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
}
.control-panel {
display: flex;
flex-direction: column;
gap: 22px;
padding: 20px;
}
.narration {
min-height: 88px;
margin: 0;
color: #334155;
font-size: 1.05rem;
line-height: 1.55;
}
.progress-block {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
border-radius: 12px;
background: #f1f5f9;
}
.controls {
display: grid;
gap: 9px;
}
button {
padding: 11px 14px;
border: 0;
border-radius: 10px;
color: #ffffff;
background: #7c3aed;
font: inherit;
font-weight: 750;
cursor: pointer;
}
button:hover {
filter: brightness(1.06);
}
button.warning {
background: #dc2626;
}
button.secondary {
background: #0f766e;
}
button.ghost {
border: 1px solid #cbd5e1;
color: #334155;
background: #ffffff;
}
.event-log {
display: grid;
gap: 6px;
margin: 0;
padding-left: 22px;
color: #475569;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.76rem;
}
.canvas-card {
height: 610px;
min-width: 0;
overflow: hidden;
}
@media (max-width: 850px) {
.hero {
display: grid;
}
.stage-rail {
grid-template-columns: 1fr 1fr;
}
.workspace {
grid-template-columns: 1fr;
}
.canvas-card {
height: 520px;
}
}
Your teaching surface is live. Next, you'll connect its controls to a typed XState machine so Luna can move only through legal pedagogical states.
Encode Luna's Teaching States
Your live canvas gives Luna a visible teaching surface. React flags can show controls. They do not define which events are legal in each pedagogical state.
A finite-state machine makes those rules explicit. In this step, XState connects each legal event to one visible state transition.
In this step, get ready to:
- Define Luna's typed context and legal events.
- Connect the lesson machine to the dashboard.
- Verify the legal interruption and resume path.
Define the typed lesson machine
The machine holds Luna's lesson data in one context object. Its event union limits each transition to a known event shape.
Why XState for this workflow?
React's useReducer centralizes state updates. XState also describes the active finite state and the legal transitions from that state.
Those transition rules make interruption paths inspectable. The machine becomes the authority for what Luna can do next.
- In the Visual Studio Code Explorer sidebar from earlier, select the New File control.
- Enter src/lessonMachine.ts as the file name.
- Select the second tab below to reveal the complete typed machine.
- Copy the code into src/lessonMachine.ts.
✔️ Awesome, I've got everything!
Your new file defines Luna's context, events, actions, and five pedagogical states.
ⓧ I'd like to double check the full code
import { assign, setup } from 'xstate'
type LessonContext = {
page: number
visibleElementCount: number
narration: string
eventLog: string[]
}
type LessonEvent =
| { type: 'START' }
| { type: 'STREAM_TICK'; narration: string; totalSegments: number }
| { type: 'LESSON_COMPLETE' }
| { type: 'INTERRUPT' }
| { type: 'CHECKPOINT_ACCEPT' }
| { type: 'RESOLVE' }
| { type: 'RESUME' }
| { type: 'NEXT_PAGE' }
| { type: 'RESET' }
const pushLog = (context: LessonContext, message: string) =>
[message, ...context.eventLog].slice(0, 7)
const initialContext: LessonContext = {
page: 1,
visibleElementCount: 0,
narration: 'Ready to teach a local lesson.',
eventLog: ['machine:idle'],
}
export const lessonMachine = setup({
types: {
context: {} as LessonContext,
events: {} as LessonEvent,
},
actions: {
beginLesson: assign({
narration: () => 'Luna is preparing the first explanation.',
eventLog: ({ context }) => pushLog(context, 'event:START'),
}),
revealSegment: assign(({ context, event }) => {
if (event.type !== 'STREAM_TICK') {
return {}
}
const visibleElementCount = Math.min(
context.visibleElementCount + 1,
event.totalSegments,
)
return {
visibleElementCount,
narration: event.narration,
eventLog: pushLog(context, `stream:segment-${visibleElementCount}`),
}
}),
reachCheckpoint: assign({
narration: () => 'Checkpoint: does this sequence make sense?',
eventLog: ({ context }) => pushLog(context, 'state:checkpoint_prompt'),
}),
captureInterruption: assign({
narration: () => 'Paused. Luna is listening to the learner.',
eventLog: ({ context }) => pushLog(context, 'event:INTERRUPT'),
}),
acceptCheckpoint: assign({
narration: () => 'Checkpoint accepted. Choose what happens next.',
eventLog: ({ context }) => pushLog(context, 'event:CHECKPOINT_ACCEPT'),
}),
resolveDoubt: assign({
narration: () => 'Doubt resolved. Resume this page or open the next one.',
eventLog: ({ context }) => pushLog(context, 'event:RESOLVE'),
}),
resumeLesson: assign({
narration: () => 'Resuming from the preserved canvas.',
eventLog: ({ context }) => pushLog(context, 'event:RESUME'),
}),
prepareNextPage: assign({
page: ({ context }) => context.page + 1,
visibleElementCount: () => 0,
narration: () => 'A fresh page is ready.',
eventLog: ({ context }) => pushLog(context, 'event:NEXT_PAGE'),
}),
resetLesson: assign({
page: () => initialContext.page,
visibleElementCount: () => initialContext.visibleElementCount,
narration: () => initialContext.narration,
eventLog: () => initialContext.eventLog,
}),
},
}).createMachine({
id: 'lunaLesson',
initial: 'idle',
context: initialContext,
on: {
RESET: {
target: '.idle',
actions: { type: 'resetLesson' },
},
},
states: {
idle: {
on: {
START: {
target: 'teaching_topic',
actions: { type: 'beginLesson' },
},
},
},
teaching_topic: {
on: {
STREAM_TICK: {
actions: { type: 'revealSegment' },
},
LESSON_COMPLETE: {
target: 'checkpoint_prompt',
actions: { type: 'reachCheckpoint' },
},
INTERRUPT: {
target: 'doubt_resolution',
actions: { type: 'captureInterruption' },
},
},
},
checkpoint_prompt: {
on: {
CHECKPOINT_ACCEPT: {
target: 'resume_or_paginate',
actions: { type: 'acceptCheckpoint' },
},
INTERRUPT: {
target: 'doubt_resolution',
actions: { type: 'captureInterruption' },
},
},
},
doubt_resolution: {
on: {
RESOLVE: {
target: 'resume_or_paginate',
actions: { type: 'resolveDoubt' },
},
},
},
resume_or_paginate: {
on: {
RESUME: {
target: 'teaching_topic',
actions: { type: 'resumeLesson' },
},
NEXT_PAGE: {
target: 'teaching_topic',
actions: { type: 'prepareNextPage' },
},
},
},
},
})
What does this machine define?
- LessonContext keeps the page, visible-element count, narration, and recent event log together.
- LessonEvent gives each accepted event a specific TypeScript shape.
- assign(...) actions update the context when a legal transition runs.
- idle, teaching_topic, checkpoint_prompt, doubt_resolution, and resume_or_paginate define Luna's five pedagogical states.
- Save src/lessonMachine.ts.
- Confirm the Visual Studio Code Explorer lists lessonMachine.ts inside src.
Seeing TypeScript errors in the machine?
Check that every event name uses the same capitalization shown in the file. Confirm that LessonContext contains all four context fields.
Help me compare my typed XState machine with the expected context, events, actions, and states.
Connect the machine to the dashboard
The machine becomes useful when the interface reads its current snapshot. The controls can then appear only where their events are legal.
- Return to src/App.tsx in the editor.
- Select the second tab below to reveal the cumulative dashboard file.
- Replace the contents of src/App.tsx with that code.
✔️ Awesome, I've got everything!
Your dashboard now reads its state from lessonMachine and sends events through state-aware controls.
ⓧ I'd like to double check the full code
import { useMachine } from '@xstate/react'
import { Excalidraw } from '@excalidraw/excalidraw'
import '@excalidraw/excalidraw/index.css'
import { lessonMachine } from './lessonMachine'
import './App.css'
const STAGES = [
'idle',
'teaching_topic',
'checkpoint_prompt',
'doubt_resolution',
'resume_or_paginate',
] as const
function App() {
const [snapshot, send] = useMachine(lessonMachine)
const isTeaching = snapshot.matches('teaching_topic')
const visibleElementCount = snapshot.context.visibleElementCount
const canInterrupt =
snapshot.matches('teaching_topic') ||
snapshot.matches('checkpoint_prompt')
return (
<main className="app-shell">
<header className="hero">
<div>
<p className="eyebrow">CogniCanvas local prototype</p>
<h1>Luna's interruptible teaching loop</h1>
<p className="subtitle">
Simulated narration and canvas events, coordinated by XState.
</p>
</div>
<div className="status-card">
<span>Page {snapshot.context.page}</span>
<strong>{String(snapshot.value)}</strong>
</div>
</header>
<section className="stage-rail" aria-label="Pedagogical states">
{STAGES.map((stage) => (
<div
className={snapshot.matches(stage) ? 'stage active' : 'stage'}
key={stage}
>
{stage}
</div>
))}
</section>
<section className="workspace">
<aside className="control-panel">
<div>
<p className="panel-label">Luna says</p>
<p className="narration">{snapshot.context.narration}</p>
</div>
<div className="progress-block">
<span>Visible elements</span>
<strong>{visibleElementCount}</strong>
</div>
<div className="controls">
{snapshot.matches('idle') && (
<button onClick={() => send({ type: 'START' })}>
Start lesson
</button>
)}
{canInterrupt && (
<button
className="warning"
onClick={() => send({ type: 'INTERRUPT' })}
>
Interrupt
</button>
)}
{snapshot.matches('checkpoint_prompt') && (
<button onClick={() => send({ type: 'CHECKPOINT_ACCEPT' })}>
Accept checkpoint
</button>
)}
{snapshot.matches('doubt_resolution') && (
<button onClick={() => send({ type: 'RESOLVE' })}>
Resolve doubt
</button>
)}
{snapshot.matches('resume_or_paginate') && (
<>
<button onClick={() => send({ type: 'RESUME' })}>
Resume page
</button>
<button
className="secondary"
onClick={() => send({ type: 'NEXT_PAGE' })}
>
Next page
</button>
</>
)}
<button
className="ghost"
onClick={() => send({ type: 'RESET' })}
>
Reset
</button>
</div>
<div>
<p className="panel-label">Recent events</p>
<ol className="event-log">
{snapshot.context.eventLog.map((event, index) => (
<li key={`${event}-${index}`}>{event}</li>
))}
</ol>
</div>
</aside>
<div className="canvas-card">
<Excalidraw viewModeEnabled />
</div>
</section>
</main>
)
}
export default App
How does the dashboard follow the machine?
- useMachine(lessonMachine) returns the current snapshot and the send function.
- snapshot.matches(...) checks which pedagogical state is active.
- STAGES provides the five entries in the visible state rail.
- canInterrupt keeps the interruption control inside the two states that accept INTERRUPT.
- send(...) delivers each button event to the machine.
- Save src/App.tsx.
- Return to the browser tab at http://localhost:5173.
- Confirm the dashboard shows a five-stage rail above the workspace.
The active idle stage is highlighted. The control panel shows Start lesson and Reset.
Dashboard missing the state rail?
Confirm that App.tsx imports lessonMachine from ./lessonMachine. Check that the STAGES array sits above App().
Help me diagnose why my React dashboard is not rendering the XState stage rail or state-aware controls.
Verify the legal interruption path
A state-derived UI changes its available controls with the machine snapshot. Each click below proves that the next event is legal in the current state.
Before you test the controls, which state do you expect after each event in the interruption path?
- Confirm idle is highlighted in the state rail.
- Click Start lesson.
The active state moves to teaching_topic. Luna's narration changes to Luna is preparing the first explanation.
- Click Interrupt.
The active state moves to doubt_resolution. The recent event log shows event:INTERRUPT.
- Click Resolve doubt.
The active state moves to resume_or_paginate. The dashboard now offers the resume and pagination controls.
- Click Resume page.
The active state returns to teaching_topic. The event log shows event:RESUME as its newest entry.
- Click Reset.
The dashboard returns to idle with page 1 and the original narration. That's the workflow backbone working: Luna now follows explicit legal paths through interruption and recovery.
Control missing after a transition?
Check which stage is highlighted before looking for the next control. Each conditional button appears only in the state that accepts its event.
Help me trace a missing CogniCanvas control through the active XState state and its legal events.
Luna's legal paths are now visible in the interface. Next, you'll stream narration and drawing to expose a timing race during interruption.
Expose the Streaming Race
Your XState machine defines which teaching events are legal. However, a state transition cannot automatically cancel browser work scheduled outside the machine.
Separate React effects now schedule narration and drawing. You will interrupt the lesson after its first element to test whether both schedules honor the same transition.
In this step, get ready to:
- Add four local lesson segments with narration and drawing data.
- Schedule narration and drawing with separate timers.
- Expose the post-interrupt drawing race.
Add the lesson segments
Each entry in LESSON_SEGMENTS pairs one narration update with one Excalidraw element skeleton. The shared order gives both streams the same lesson sequence.
- Switch back to src/App.tsx in the editor from earlier.
- Find the closing ] as const line below STAGES.
- Add the first two lesson segments below STAGES by pasting this code:
const LESSON_SEGMENTS = [ // Pair each narration line with one canvas skeleton.
{
narration: 'Start with the learner goal as the input to the teaching loop.',
element: {
type: 'rectangle' as const,
id: 'learner-goal',
x: 40,
y: 90,
width: 220,
height: 100,
backgroundColor: '#dbeafe',
fillStyle: 'solid' as const,
label: { text: 'Learner goal' },
},
},
{
narration: 'Luna adapts the explanation before producing the next output.',
element: {
type: 'arrow' as const,
id: 'adapt-arrow',
x: 275,
y: 140,
width: 150,
height: 0,
strokeColor: '#7c3aed',
strokeWidth: 2,
label: { text: 'adapt' },
},
},
]
How is each segment structured?
- The narration value holds the sentence Luna presents for that segment.
- The element value describes the visual that appears with the sentence.
- The stable learner-goal and adapt-arrow identifiers let Excalidraw preserve those elements across scene updates.
- Save src/App.tsx.
- Return to the dashboard in your browser.
You should still see the state rail and control panel without a red error overlay.
Seeing a syntax error?
Confirm that LESSON_SEGMENTS sits outside the App() function. Check that both element objects close before the final array bracket.
Ask for help with the segment structure.
- Find the final ] in LESSON_SEGMENTS.
- Add the remaining two segments immediately above that bracket by pasting this code:
// Finish the lesson with Luna's response and its checkpoint.
{
narration: 'The response combines words and a visual on the same page.',
element: {
type: 'rectangle' as const,
id: 'luna-response',
x: 440,
y: 90,
width: 220,
height: 100,
backgroundColor: '#ede9fe',
fillStyle: 'solid' as const,
label: { text: 'Luna response' },
},
},
{
narration: 'A checkpoint turns the explanation into an interactive loop.',
element: {
type: 'text' as const,
id: 'checkpoint-label',
x: 210,
y: 250,
text: 'Checkpoint: continue, interrupt, or paginate?',
fontSize: 24,
strokeColor: '#0f172a',
},
},
What completes the lesson?
- The luna-response rectangle completes the visual flow from the learner to Luna.
- The checkpoint-label text turns the finished explanation into a decision point.
- The array order determines the reveal order for the canvas.
- Save src/App.tsx.
- Return to the dashboard in your browser.
You should see the existing dashboard reload with its five-state rail intact.
Dashboard stopped compiling?
Check that the second segment still ends with a comma. Confirm that the new objects remain inside the LESSON_SEGMENTS array.
Ask for help checking the completed array.
Schedule the two streams
Narration progress already belongs to the machine context. Drawing progress now needs a local counter so its timer can run on a separate schedule.
- In src/App.tsx find this line:
const visibleElementCount = snapshot.context.visibleElementCount
- Replace the machine-derived counter with this local drawing counter:
const [visibleElementCount, setVisibleElementCount] = useState(0)
- Add the narration scheduler below the new drawing counter by pasting this code:
useEffect(() => {
if (!isTeaching) {
return
}
if (snapshot.context.visibleElementCount >= LESSON_SEGMENTS.length) {
// Move to the checkpoint after every narration segment runs.
const completionTimer = window.setTimeout(() => {
send({ type: 'LESSON_COMPLETE' })
}, 700)
return () => window.clearTimeout(completionTimer)
}
// Advance narration from the machine's current segment count.
const streamTimer = window.setTimeout(() => {
const segment = LESSON_SEGMENTS[snapshot.context.visibleElementCount]
send({
type: 'STREAM_TICK',
narration: segment.narration,
totalSegments: LESSON_SEGMENTS.length,
})
}, 700)
return () => window.clearTimeout(streamTimer)
}, [isTeaching, send, snapshot.context.visibleElementCount])
How does narration advance?
- The effect schedules one STREAM_TICK while teaching_topic is active.
- The event carries the next narration sentence into the machine.
- The returned cleanup cancels the pending narration timer when the effect stops.
- The completion timer sends LESSON_COMPLETE after all four narration segments.
- Save src/App.tsx.
- Select Reset in the control panel.
- Select Start lesson.
You should see Luna's narration change every moment. The recent event log should add entries beginning with stream:segment-.
Narration staying on the opening message?
Confirm that the effect appears inside App() after isTeaching is defined. Check that snapshot.context.visibleElementCount appears in the dependency array.
Ask for help tracing the narration timer.
- Add the drawing scheduler immediately below the narration effect by pasting this code:
useEffect(() => {
if (!isTeaching) {
return
}
// Advance the canvas on its own schedule.
window.setTimeout(() => {
setVisibleElementCount((count) =>
Math.min(count + 1, LESSON_SEGMENTS.length),
)
}, 900)
}, [isTeaching, visibleElementCount])
How does drawing advance?
This effect schedules drawing progress independently while teaching_topic is active. The visible counter now follows its own browser schedule.
The Math.min(...) boundary keeps the count within the four available lesson segments.
- Save src/App.tsx.
- Refresh http://localhost:5173 in your browser.
- Select Start lesson.
You should see the visible-element counter rise from 0/4 toward 4/4.
Counter stuck at zero?
Confirm that visibleElementCount comes from useState(0). Check that the drawing effect calls setVisibleElementCount.
Ask for help tracing the drawing schedule.
- Replace the temporary scene-update effect from the earlier canvas shell with this counter-driven effect:
useEffect(() => {
if (!excalidrawAPI) {
return
}
// Rebuild the visible scene from the drawing counter.
const skeletons = LESSON_SEGMENTS.slice(0, visibleElementCount).map(
({ element }) => element,
)
const elements = convertToExcalidrawElements(skeletons, {
regenerateIds: false,
})
excalidrawAPI.updateScene({
elements,
appState: { viewBackgroundColor: '#f8fafc' },
})
}, [excalidrawAPI, visibleElementCount])
How does the scene follow the counter?
- The slice(...) call selects every lesson skeleton up to the current drawing count.
- The conversion keeps the supplied element identifiers stable.
- The updateScene(...) call replaces the canvas with the currently visible elements.
- Save src/App.tsx.
- Refresh http://localhost:5173 in your browser.
- Select Start lesson.
You should see the learner-goal rectangle appear first. The arrow and Luna response should follow before the checkpoint text.
Counter moves but the canvas stays empty?
Confirm that the scene effect uses the same local visibleElementCount as the counter. Check that the Excalidraw API callback still saves the API with setExcalidrawAPI.
Ask for help checking the scene update.
✔️ Awesome, I've got everything!
Your four lesson segments now feed separate narration and drawing schedules. Save src/App.tsx before testing the interruption.
ⓧ I'd like to double check the full code
import { useEffect, useState } from 'react'
import { useMachine } from '@xstate/react'
import {
Excalidraw,
convertToExcalidrawElements,
} from '@excalidraw/excalidraw'
import type { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types'
import '@excalidraw/excalidraw/index.css'
import { lessonMachine } from './lessonMachine'
import './App.css'
const STAGES = [
'idle',
'teaching_topic',
'checkpoint_prompt',
'doubt_resolution',
'resume_or_paginate',
] as const
const LESSON_SEGMENTS = [ // Pair each narration line with one canvas skeleton.
{
narration: 'Start with the learner goal as the input to the teaching loop.',
element: {
type: 'rectangle' as const,
id: 'learner-goal',
x: 40,
y: 90,
width: 220,
height: 100,
backgroundColor: '#dbeafe',
fillStyle: 'solid' as const,
label: { text: 'Learner goal' },
},
},
{
narration: 'Luna adapts the explanation before producing the next output.',
element: {
type: 'arrow' as const,
id: 'adapt-arrow',
x: 275,
y: 140,
width: 150,
height: 0,
strokeColor: '#7c3aed',
strokeWidth: 2,
label: { text: 'adapt' },
},
},
// Finish the lesson with Luna's response and its checkpoint.
{
narration: 'The response combines words and a visual on the same page.',
element: {
type: 'rectangle' as const,
id: 'luna-response',
x: 440,
y: 90,
width: 220,
height: 100,
backgroundColor: '#ede9fe',
fillStyle: 'solid' as const,
label: { text: 'Luna response' },
},
},
{
narration: 'A checkpoint turns the explanation into an interactive loop.',
element: {
type: 'text' as const,
id: 'checkpoint-label',
x: 210,
y: 250,
text: 'Checkpoint: continue, interrupt, or paginate?',
fontSize: 24,
strokeColor: '#0f172a',
},
},
]
function App() {
const [snapshot, send] = useMachine(lessonMachine)
const [excalidrawAPI, setExcalidrawAPI] =
useState<ExcalidrawImperativeAPI | null>(null)
const isTeaching = snapshot.matches('teaching_topic')
const [visibleElementCount, setVisibleElementCount] = useState(0)
useEffect(() => {
if (!isTeaching) {
return
}
if (snapshot.context.visibleElementCount >= LESSON_SEGMENTS.length) {
// Move to the checkpoint after every narration segment runs.
const completionTimer = window.setTimeout(() => {
send({ type: 'LESSON_COMPLETE' })
}, 700)
return () => window.clearTimeout(completionTimer)
}
// Advance narration from the machine's current segment count.
const streamTimer = window.setTimeout(() => {
const segment = LESSON_SEGMENTS[snapshot.context.visibleElementCount]
send({
type: 'STREAM_TICK',
narration: segment.narration,
totalSegments: LESSON_SEGMENTS.length,
})
}, 700)
return () => window.clearTimeout(streamTimer)
}, [isTeaching, send, snapshot.context.visibleElementCount])
useEffect(() => {
if (!isTeaching) {
return
}
// Advance the canvas on its own schedule.
window.setTimeout(() => {
setVisibleElementCount((count) =>
Math.min(count + 1, LESSON_SEGMENTS.length),
)
}, 900)
}, [isTeaching, visibleElementCount])
useEffect(() => {
if (!excalidrawAPI) {
return
}
// Rebuild the visible scene from the drawing counter.
const skeletons = LESSON_SEGMENTS.slice(0, visibleElementCount).map(
({ element }) => element,
)
const elements = convertToExcalidrawElements(skeletons, {
regenerateIds: false,
})
excalidrawAPI.updateScene({
elements,
appState: { viewBackgroundColor: '#f8fafc' },
})
}, [excalidrawAPI, visibleElementCount])
const canInterrupt =
snapshot.matches('teaching_topic') ||
snapshot.matches('checkpoint_prompt')
return (
<main className="app-shell">
<header className="hero">
<div>
<p className="eyebrow">CogniCanvas local prototype</p>
<h1>Luna's interruptible teaching loop</h1>
<p className="subtitle">
Simulated narration and canvas events, coordinated by XState.
</p>
</div>
<div className="status-card">
<span>Page {snapshot.context.page}</span>
<strong>{String(snapshot.value)}</strong>
</div>
</header>
<section className="stage-rail" aria-label="Pedagogical states">
{STAGES.map((stage) => (
<div
className={snapshot.matches(stage) ? 'stage active' : 'stage'}
key={stage}
>
{stage}
</div>
))}
</section>
<section className="workspace">
<aside className="control-panel">
<div>
<p className="panel-label">Luna says</p>
<p className="narration">{snapshot.context.narration}</p>
</div>
<div className="progress-block">
<span>Visible elements</span>
<strong>
{visibleElementCount}/{LESSON_SEGMENTS.length}
</strong>
</div>
<div className="controls">
{snapshot.matches('idle') && (
<button onClick={() => send({ type: 'START' })}>
Start lesson
</button>
)}
{canInterrupt && (
<button
className="warning"
onClick={() => send({ type: 'INTERRUPT' })}
>
Interrupt
</button>
)}
{snapshot.matches('checkpoint_prompt') && (
<button onClick={() => send({ type: 'CHECKPOINT_ACCEPT' })}>
Accept checkpoint
</button>
)}
{snapshot.matches('doubt_resolution') && (
<button onClick={() => send({ type: 'RESOLVE' })}>
Resolve doubt
</button>
)}
{snapshot.matches('resume_or_paginate') && (
<>
<button onClick={() => send({ type: 'RESUME' })}>
Resume page
</button>
<button
className="secondary"
onClick={() => send({ type: 'NEXT_PAGE' })}
>
Next page
</button>
</>
)}
<button
className="ghost"
onClick={() => send({ type: 'RESET' })}
>
Reset
</button>
</div>
<div>
<p className="panel-label">Recent events</p>
<ol className="event-log">
{snapshot.context.eventLog.map((event, index) => (
<li key={`${event}-${index}`}>{event}</li>
))}
</ol>
</div>
</aside>
<div className="canvas-card">
<Excalidraw
excalidrawAPI={(api) => setExcalidrawAPI(api)}
viewModeEnabled
/>
</div>
</section>
</main>
)
}
export default App
Trigger the post-interrupt race
The state rail and event log now let you compare machine state with browser activity. A mismatch between them reveals work that escaped the transition.
- Refresh http://localhost:5173 to reset both schedules.
- Select Start lesson.
- Watch the visible-element counter until it reaches 1/4.
Before you interrupt, do you think both schedules will stop when XState leaves teaching_topic?
- Select Interrupt immediately after the first element appears.
- Wait for at least two seconds.
- Compare the active state with the visible-element counter.
- Compare Luna's narration with the canvas.
- Inspect the newest entry in the recent event log.
You should see doubt_resolution remain active while Luna's narration stays paused. At least one later canvas element should still appear.
The newest machine event remains event:INTERRUPT while the drawing counter increases. You found the race: the state changed correctly, but scheduled drawing work escaped the interruption.
Why did the streams diverge?
The narration effect returns a cleanup function. Leaving teaching_topic cancels its pending timer.
The drawing effect schedules its own timer without returning cleanup. That pending callback can update the local drawing counter after the machine enters doubt_resolution.
Did the race disappear too quickly?
Refresh the page before each attempt. Select Interrupt as soon as the counter first shows 1/4.
Ask for help reproducing the timing window.
You have reproduced the coordination failure with visible evidence. Next, you will replace both schedules with one state-owned stream that interruption can cancel atomically.
Make Interruption Atomic
Your previous test exposed the timer race. Luna entered doubt_resolution, but an orphaned drawing timer could still reveal another element.
Now one state-owned React effect advances both outputs through a typed event. Cleanup runs when XState leaves teaching_topic, which preserves the current Excalidraw scene without allowing more work.
In this step, get ready to:
- Replace the independent timers with one state-bound coordinator.
- Prove that effect cleanup freezes the preserved canvas scene.
- Replay the resume, pagination, checkpoint, interruption, and reset paths.
Coordinate both outputs with one effect
A single STREAM_TICK advances narration and the visible element count together. The active machine state now owns the lifetime of every scheduled update.
- Switch back to src/App.tsx in Visual Studio Code.
- Locate the separate narration and drawing timer effects.
- Replace both timer effects with this coordinated effect:
useEffect(() => {
if (!isTeaching) {
return
}
if (visibleElementCount >= LESSON_SEGMENTS.length) {
const completionTimer = window.setTimeout(() => {
send({ type: 'LESSON_COMPLETE' })
}, 700)
return () => window.clearTimeout(completionTimer)
}
const streamTimer = window.setTimeout(() => {
const segment = LESSON_SEGMENTS[visibleElementCount]
send({
type: 'STREAM_TICK',
narration: segment.narration,
totalSegments: LESSON_SEGMENTS.length,
})
}, 900)
return () => window.clearTimeout(streamTimer)
}, [isTeaching, send, visibleElementCount])
What does this effect coordinate?
- The isTeaching guard prevents scheduling outside teaching_topic.
- The stream timer sends one STREAM_TICK with the next narration segment.
- The machine action increases visibleElementCount during that same event.
- Each returned cleanup function cancels pending work when the effect reruns or the teaching state ends.
- Save src/App.tsx.
Before you test the fix, do you expect the visible element count to change after Luna leaves teaching_topic?
- Return to http://localhost:5173 in your browser.
- Click Reset.
- Click Start lesson.
- Wait until the visible element counter reaches 1/4.
- Click Interrupt.
- Wait at least two seconds.
You should still see doubt_resolution after the wait. The visible element counter should remain at 1/4.
Does another element still appear?
Confirm that you removed both independent timer effects from src/App.tsx. Check that the stream timer returns its window.clearTimeout cleanup.
Make sure the dependency list contains isTeaching, send, and visibleElementCount.
Help me inspect my state-bound timer effect.
Preserve the current scene
Interruption should freeze the lesson at its current point. The scene effect rebuilds the canvas from visibleElementCount, so the revealed elements remain while the count stays fixed.
- Locate the scene update effect in src/App.tsx.
- Replace the scene update effect with this version:
useEffect(() => {
if (!excalidrawAPI) {
return
}
const skeletons = LESSON_SEGMENTS.slice(0, visibleElementCount).map(
({ element }) => element,
)
const elements = convertToExcalidrawElements(skeletons, {
regenerateIds: false,
})
excalidrawAPI.updateScene({
elements,
appState: { viewBackgroundColor: '#f8fafc' },
})
}, [excalidrawAPI, visibleElementCount])
How does the scene stay intact?
- The array slice selects every lesson element that Luna has already revealed.
- The regenerateIds: false option keeps each supplied element ID stable.
- The scene update redraws the selected elements against the #f8fafc background.
- An interruption leaves visibleElementCount unchanged, so the same scene remains visible.
- Save src/App.tsx.
- Click Reset in the browser.
- Click Start lesson.
- Wait until the visible element counter reaches 2/4.
- Click Interrupt.
- Click Resolve doubt.
- Click Resume page.
The preserved scene continues from 2/4. Luna eventually reaches checkpoint_prompt with all four elements visible.
Does the canvas restart after resuming?
Check that resumeLesson does not reset visibleElementCount. Only prepareNextPage and resetLesson should clear the count.
Help me trace why my preserved scene resets.
Replay every completion path
The coordinator now protects interruption. The remaining controls prove that the same machine can resume preserved work or deliberately start a fresh page.
- Click Accept checkpoint.
- Click Resume page.
You should briefly see teaching_topic with all four elements preserved. Luna returns to checkpoint_prompt because the current page is complete.
- Click Accept checkpoint again.
- Click Next page.
- Wait for the first element on page two.
The status card should show page 2. The canvas should begin again from the first lesson element.
Before the final check, do you expect the preserved element to remain frozen after another interruption?
- Click Reset.
- Click Start lesson.
- Wait until the visible element counter reaches 1/4.
- Click Interrupt.
- Wait at least two seconds.
You should see doubt_resolution with the narration paused. The counter should remain at 1/4.
The labelled Learner goal box should remain on the canvas. No later lesson element should appear.
Does a control follow the wrong path?
Check that each button sends the event shown in src/App.tsx. Confirm that the corresponding transition exists under the active state in src/lessonMachine.ts.
Help me trace an incorrect CogniCanvas control transition.
That closes the race. Luna now stops both simulated outputs atomically while keeping the learner's place intact.
✔️ Awesome, I've got everything!
Your completed prototype coordinates narration and drawing through one state-owned stream. Save every project file before moving on.
ⓧ I'd like to double check the full code
import { assign, setup } from 'xstate'
type LessonContext = {
page: number
visibleElementCount: number
narration: string
eventLog: string[]
}
type LessonEvent =
| { type: 'START' }
| { type: 'STREAM_TICK'; narration: string; totalSegments: number }
| { type: 'LESSON_COMPLETE' }
| { type: 'INTERRUPT' }
| { type: 'CHECKPOINT_ACCEPT' }
| { type: 'RESOLVE' }
| { type: 'RESUME' }
| { type: 'NEXT_PAGE' }
| { type: 'RESET' }
const pushLog = (context: LessonContext, message: string) =>
[message, ...context.eventLog].slice(0, 7)
const initialContext: LessonContext = {
page: 1,
visibleElementCount: 0,
narration: 'Ready to teach a local lesson.',
eventLog: ['machine:idle'],
}
export const lessonMachine = setup({
types: {
context: {} as LessonContext,
events: {} as LessonEvent,
},
actions: {
beginLesson: assign({
narration: () => 'Luna is preparing the first explanation.',
eventLog: ({ context }) => pushLog(context, 'event:START'),
}),
revealSegment: assign(({ context, event }) => {
if (event.type !== 'STREAM_TICK') {
return {}
}
const visibleElementCount = Math.min(
context.visibleElementCount + 1,
event.totalSegments,
)
return {
visibleElementCount,
narration: event.narration,
eventLog: pushLog(context, `stream:segment-${visibleElementCount}`),
}
}),
reachCheckpoint: assign({
narration: () => 'Checkpoint: does this sequence make sense?',
eventLog: ({ context }) => pushLog(context, 'state:checkpoint_prompt'),
}),
captureInterruption: assign({
narration: () => 'Paused. Luna is listening to the learner.',
eventLog: ({ context }) => pushLog(context, 'event:INTERRUPT'),
}),
acceptCheckpoint: assign({
narration: () => 'Checkpoint accepted. Choose what happens next.',
eventLog: ({ context }) => pushLog(context, 'event:CHECKPOINT_ACCEPT'),
}),
resolveDoubt: assign({
narration: () => 'Doubt resolved. Resume this page or open the next one.',
eventLog: ({ context }) => pushLog(context, 'event:RESOLVE'),
}),
resumeLesson: assign({
narration: () => 'Resuming from the preserved canvas.',
eventLog: ({ context }) => pushLog(context, 'event:RESUME'),
}),
prepareNextPage: assign({
page: ({ context }) => context.page + 1,
visibleElementCount: () => 0,
narration: () => 'A fresh page is ready.',
eventLog: ({ context }) => pushLog(context, 'event:NEXT_PAGE'),
}),
resetLesson: assign({
page: () => initialContext.page,
visibleElementCount: () => initialContext.visibleElementCount,
narration: () => initialContext.narration,
eventLog: () => initialContext.eventLog,
}),
},
}).createMachine({
id: 'lunaLesson',
initial: 'idle',
context: initialContext,
on: {
RESET: {
target: '.idle',
actions: { type: 'resetLesson' },
},
},
states: {
idle: {
on: {
START: {
target: 'teaching_topic',
actions: { type: 'beginLesson' },
},
},
},
teaching_topic: {
on: {
STREAM_TICK: {
actions: { type: 'revealSegment' },
},
LESSON_COMPLETE: {
target: 'checkpoint_prompt',
actions: { type: 'reachCheckpoint' },
},
INTERRUPT: {
target: 'doubt_resolution',
actions: { type: 'captureInterruption' },
},
},
},
checkpoint_prompt: {
on: {
CHECKPOINT_ACCEPT: {
target: 'resume_or_paginate',
actions: { type: 'acceptCheckpoint' },
},
INTERRUPT: {
target: 'doubt_resolution',
actions: { type: 'captureInterruption' },
},
},
},
doubt_resolution: {
on: {
RESOLVE: {
target: 'resume_or_paginate',
actions: { type: 'resolveDoubt' },
},
},
},
resume_or_paginate: {
on: {
RESUME: {
target: 'teaching_topic',
actions: { type: 'resumeLesson' },
},
NEXT_PAGE: {
target: 'teaching_topic',
actions: { type: 'prepareNextPage' },
},
},
},
},
})
import { useEffect, useState } from 'react'
import { useMachine } from '@xstate/react'
import {
Excalidraw,
convertToExcalidrawElements,
} from '@excalidraw/excalidraw'
import type { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types'
import '@excalidraw/excalidraw/index.css'
import { lessonMachine } from './lessonMachine'
import './App.css'
const STAGES = [
'idle',
'teaching_topic',
'checkpoint_prompt',
'doubt_resolution',
'resume_or_paginate',
] as const
const LESSON_SEGMENTS = [
{
narration: 'Start with the learner goal as the input to the teaching loop.',
element: {
type: 'rectangle' as const,
id: 'learner-goal',
x: 40,
y: 90,
width: 220,
height: 100,
backgroundColor: '#dbeafe',
fillStyle: 'solid' as const,
label: { text: 'Learner goal' },
},
},
{
narration: 'Luna adapts the explanation before producing the next output.',
element: {
type: 'arrow' as const,
id: 'adapt-arrow',
x: 275,
y: 140,
width: 150,
height: 0,
strokeColor: '#7c3aed',
strokeWidth: 2,
label: { text: 'adapt' },
},
},
{
narration: 'The response combines words and a visual on the same page.',
element: {
type: 'rectangle' as const,
id: 'luna-response',
x: 440,
y: 90,
width: 220,
height: 100,
backgroundColor: '#ede9fe',
fillStyle: 'solid' as const,
label: { text: 'Luna response' },
},
},
{
narration: 'A checkpoint turns the explanation into an interactive loop.',
element: {
type: 'text' as const,
id: 'checkpoint-label',
x: 210,
y: 250,
text: 'Checkpoint: continue, interrupt, or paginate?',
fontSize: 24,
strokeColor: '#0f172a',
},
},
]
function App() {
const [snapshot, send] = useMachine(lessonMachine)
const [excalidrawAPI, setExcalidrawAPI] =
useState<ExcalidrawImperativeAPI | null>(null)
const isTeaching = snapshot.matches('teaching_topic')
const visibleElementCount = snapshot.context.visibleElementCount
useEffect(() => {
if (!isTeaching) {
return
}
if (visibleElementCount >= LESSON_SEGMENTS.length) {
const completionTimer = window.setTimeout(() => {
send({ type: 'LESSON_COMPLETE' })
}, 700)
return () => window.clearTimeout(completionTimer)
}
const streamTimer = window.setTimeout(() => {
const segment = LESSON_SEGMENTS[visibleElementCount]
send({
type: 'STREAM_TICK',
narration: segment.narration,
totalSegments: LESSON_SEGMENTS.length,
})
}, 900)
return () => window.clearTimeout(streamTimer)
}, [isTeaching, send, visibleElementCount])
useEffect(() => {
if (!excalidrawAPI) {
return
}
const skeletons = LESSON_SEGMENTS.slice(0, visibleElementCount).map(
({ element }) => element,
)
const elements = convertToExcalidrawElements(skeletons, {
regenerateIds: false,
})
excalidrawAPI.updateScene({
elements,
appState: { viewBackgroundColor: '#f8fafc' },
})
}, [excalidrawAPI, visibleElementCount])
const canInterrupt =
snapshot.matches('teaching_topic') ||
snapshot.matches('checkpoint_prompt')
return (
<main className="app-shell">
<header className="hero">
<div>
<p className="eyebrow">CogniCanvas local prototype</p>
<h1>Luna's interruptible teaching loop</h1>
<p className="subtitle">
Simulated narration and canvas events, coordinated by XState.
</p>
</div>
<div className="status-card">
<span>Page {snapshot.context.page}</span>
<strong>{String(snapshot.value)}</strong>
</div>
</header>
<section className="stage-rail" aria-label="Pedagogical states">
{STAGES.map((stage) => (
<div
className={snapshot.matches(stage) ? 'stage active' : 'stage'}
key={stage}
>
{stage}
</div>
))}
</section>
<section className="workspace">
<aside className="control-panel">
<div>
<p className="panel-label">Luna says</p>
<p className="narration">{snapshot.context.narration}</p>
</div>
<div className="progress-block">
<span>Visible elements</span>
<strong>
{visibleElementCount}/{LESSON_SEGMENTS.length}
</strong>
</div>
<div className="controls">
{snapshot.matches('idle') && (
<button onClick={() => send({ type: 'START' })}>
Start lesson
</button>
)}
{canInterrupt && (
<button
className="warning"
onClick={() => send({ type: 'INTERRUPT' })}
>
Interrupt
</button>
)}
{snapshot.matches('checkpoint_prompt') && (
<button onClick={() => send({ type: 'CHECKPOINT_ACCEPT' })}>
Accept checkpoint
</button>
)}
{snapshot.matches('doubt_resolution') && (
<button onClick={() => send({ type: 'RESOLVE' })}>
Resolve doubt
</button>
)}
{snapshot.matches('resume_or_paginate') && (
<>
<button onClick={() => send({ type: 'RESUME' })}>
Resume page
</button>
<button
className="secondary"
onClick={() => send({ type: 'NEXT_PAGE' })}
>
Next page
</button>
</>
)}
<button
className="ghost"
onClick={() => send({ type: 'RESET' })}
>
Reset
</button>
</div>
<div>
<p className="panel-label">Recent events</p>
<ol className="event-log">
{snapshot.context.eventLog.map((event, index) => (
<li key={`${event}-${index}`}>{event}</li>
))}
</ol>
</div>
</aside>
<div className="canvas-card">
<Excalidraw
excalidrawAPI={(api) => setExcalidrawAPI(api)}
viewModeEnabled
/>
</div>
</section>
</main>
)
}
export default App
#root {
min-height: 100vh;
}
.app-shell {
min-height: 100vh;
padding: 28px;
color: #0f172a;
background:
radial-gradient(circle at top left, #ede9fe 0, transparent 34%),
#f8fafc;
}
.hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
max-width: 1280px;
margin: 0 auto 20px;
}
.eyebrow,
.panel-label {
margin: 0 0 8px;
color: #7c3aed;
font-size: 0.76rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
h1 {
margin: 0;
font-size: clamp(2rem, 4vw, 3.6rem);
line-height: 1;
}
.subtitle {
margin: 12px 0 0;
color: #475569;
}
.status-card {
min-width: 190px;
padding: 14px 18px;
border: 1px solid #ddd6fe;
border-radius: 16px;
background: rgba(255, 255, 255, 0.86);
box-shadow: 0 12px 28px rgba(76, 29, 149, 0.09);
}
.status-card span,
.status-card strong {
display: block;
}
.status-card span {
color: #64748b;
font-size: 0.82rem;
}
.status-card strong {
margin-top: 5px;
color: #6d28d9;
}
.stage-rail {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
max-width: 1280px;
margin: 0 auto 16px;
}
.stage {
overflow: hidden;
padding: 10px;
border: 1px solid #e2e8f0;
border-radius: 10px;
color: #64748b;
background: #ffffff;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.75rem;
text-align: center;
text-overflow: ellipsis;
}
.stage.active {
border-color: #7c3aed;
color: #ffffff;
background: #7c3aed;
box-shadow: 0 8px 18px rgba(124, 58, 237, 0.22);
}
.workspace {
display: grid;
grid-template-columns: 300px minmax(0, 1fr);
gap: 16px;
max-width: 1280px;
margin: 0 auto;
}
.control-panel,
.canvas-card {
border: 1px solid #e2e8f0;
border-radius: 18px;
background: #ffffff;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
}
.control-panel {
display: flex;
flex-direction: column;
gap: 22px;
padding: 20px;
}
.narration {
min-height: 88px;
margin: 0;
color: #334155;
font-size: 1.05rem;
line-height: 1.55;
}
.progress-block {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
border-radius: 12px;
background: #f1f5f9;
}
.controls {
display: grid;
gap: 9px;
}
button {
padding: 11px 14px;
border: 0;
border-radius: 10px;
color: #ffffff;
background: #7c3aed;
font: inherit;
font-weight: 750;
cursor: pointer;
}
button:hover {
filter: brightness(1.06);
}
button.warning {
background: #dc2626;
}
button.secondary {
background: #0f766e;
}
button.ghost {
border: 1px solid #cbd5e1;
color: #334155;
background: #ffffff;
}
.event-log {
display: grid;
gap: 6px;
margin: 0;
padding-left: 22px;
color: #475569;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.76rem;
}
.canvas-card {
height: 610px;
min-width: 0;
overflow: hidden;
}
@media (max-width: 850px) {
.hero {
display: grid;
}
.stage-rail {
grid-template-columns: 1fr 1fr;
}
.workspace {
grid-template-columns: 1fr;
}
.canvas-card {
height: 520px;
}
}
:root {
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
color: #0f172a;
background: #f8fafc;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
body {
min-width: 320px;
min-height: 100vh;
margin: 0;
}
button,
input,
textarea {
font: inherit;
}
Secret mission
Challenge an Impossible Transition
Send RESOLVE while Luna is idle. Compare the machine snapshots before and after the event. Restore the production interface after proving the event cannot force an illegal transition.
Clean Up Your Resources
Clean Up Your Resources
Everything in this project runs locally. You can keep the prototype active, stop it for later, or remove it completely without any cloud charges.
Resources you used:
- A running Vite development server for the completed prototype.
- The local cognicanvas folder with all project files.
- The installed project dependencies inside cognicanvas.
Keep everything running
No action is needed. Choose this option if you are still testing the teaching loop.
- Leave the Vite development server running while you continue testing.
- Keep the cognicanvas folder with its installed dependencies.
- Return to http://localhost:5173 whenever you want to replay the lesson.
Pause - I'll come back to this later
Pausing stops the local development server while preserving every project file. This frees the terminal for other work.
- Switch back to the terminal running the Vite development server.
- Press Ctrl+C to stop the server.
- Retain the cognicanvas folder for your next session.
The terminal prompt returns. Your completed prototype remains ready for another session.
Delete - I don't want to use this again
Deleting the project folder permanently removes the prototype and its installed dependencies. Make a separate copy first if you want to preserve your work.
- Switch back to the terminal running the Vite development server.
- Press Ctrl+C to stop the server.
Your terminal prompt returns inside the cognicanvas folder.
- Move one level above the project folder and remove the local project by running these commands:
cd ..
rm -rf cognicanvas
What do these commands do?
The first line moves the terminal into the directory that contains the project folder.
The second line removes the entire project folder. This also removes its source files and installed dependencies.
- Confirm that the project folder is gone by running this command:
ls
What should I see?
The directory listing should no longer contain cognicanvas. This confirms that the local project has been deleted.
That closes the loop. CogniCanvas no longer occupies local disk space or holds a terminal process.
Still see the project folder?
- Confirm that the terminal started inside cognicanvas before the first command moved up one level.
- Check the parent directory permissions if the project folder remains.
Help me safely remove the local CogniCanvas folder on Linux.
Nice Work!
Nice Work!
You did it! CogniCanvas now runs as a local interruptible teaching prototype. Your React interface uses TypeScript to coordinate Luna's narration with an Excalidraw scene through XState.
What you learned:
- Built a teaching dashboard that reveals narration with Excalidraw elements incrementally. Made the active pedagogical state visible through the state rail. Exposed recent transitions through the event log.
- Encoded five pedagogical states with typed events in XState. Limited each state to its declared transition paths.
- Reproduced an interruption race with independent timers. Replaced it with cleanup-aware scheduling that stops pending work while preserving the canvas. Replayed the resume path. Replayed the pagination path.
- Secret Mission: Proved that an undeclared RESOLVE event cannot move Luna out of idle. Removed the temporary test control to restore the production interface.
Ready to quiz yourself?