Add interactive checkboxes that must all be checked to proceed

- Checklist items in instruction nodes now render as clickable checkboxes
- All items must be checked before auto-advancing to next step
- "I did it" button is hidden when node has interactive checklist
- Update checklist text to "I wrote the new fractions down"

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2026-01-16 14:36:20 -06:00
parent efc98b8d4d
commit 74da416ab4
3 changed files with 131 additions and 21 deletions

View File

@ -2,19 +2,29 @@
import type { ParsedNodeContent } from '@/lib/flowcharts/schema'
import { css } from '../../../styled-system/css'
import { vstack } from '../../../styled-system/patterns'
import { vstack, hstack } from '../../../styled-system/patterns'
interface FlowchartNodeContentProps {
content: ParsedNodeContent
/** Whether to show in compact mode */
compact?: boolean
/** For interactive checklists: which items are checked (by index) */
checkedItems?: Set<number>
/** Callback when a checklist item is toggled */
onChecklistToggle?: (index: number) => void
}
/**
* Renders parsed node content with proper formatting.
* Handles title, body, examples, warnings, and checklists.
*/
export function FlowchartNodeContent({ content, compact = false }: FlowchartNodeContentProps) {
export function FlowchartNodeContent({
content,
compact = false,
checkedItems,
onChecklistToggle,
}: FlowchartNodeContentProps) {
const isInteractiveChecklist = checkedItems !== undefined && onChecklistToggle !== undefined
return (
<div
data-testid="node-content"
@ -92,30 +102,81 @@ export function FlowchartNodeContent({ content, compact = false }: FlowchartNode
{/* Checklist */}
{content.checklist && content.checklist.length > 0 && (
<ul
<div
data-testid="node-content-checklist"
data-item-count={content.checklist.length}
data-interactive={isInteractiveChecklist}
className={css({
listStyle: 'none',
padding: '3',
backgroundColor: { base: 'green.50', _dark: 'green.900' },
borderRadius: 'md',
})}
>
{content.checklist.map((item, i) => (
<li
key={i}
data-testid={`checklist-item-${i}`}
className={css({
fontSize: 'sm',
color: { base: 'green.800', _dark: 'green.200' },
marginBottom: '1',
})}
>
{item}
</li>
))}
</ul>
{content.checklist.map((item, i) => {
// Strip the ☐ or ☑ prefix from the item text (we'll use real checkboxes)
const itemText = item.replace(/^[☐☑]\s*/, '')
const isChecked = checkedItems?.has(i) ?? false
return isInteractiveChecklist ? (
<label
key={i}
data-testid={`checklist-item-${i}`}
data-checked={isChecked}
className={hstack({
gap: '3',
cursor: 'pointer',
padding: '2',
marginBottom: '1',
borderRadius: 'md',
transition: 'all 0.15s ease-out',
backgroundColor: isChecked
? { base: 'green.200', _dark: 'green.800' }
: 'transparent',
_hover: {
backgroundColor: isChecked
? { base: 'green.200', _dark: 'green.800' }
: { base: 'green.100', _dark: 'green.800/50' },
},
})}
>
<input
type="checkbox"
checked={isChecked}
onChange={() => onChecklistToggle?.(i)}
className={css({
width: '20px',
height: '20px',
accentColor: 'green',
cursor: 'pointer',
})}
/>
<span
className={css({
fontSize: 'sm',
color: { base: 'green.800', _dark: 'green.200' },
textDecoration: isChecked ? 'line-through' : 'none',
opacity: isChecked ? 0.8 : 1,
})}
>
{itemText}
</span>
</label>
) : (
<div
key={i}
data-testid={`checklist-item-${i}`}
className={css({
fontSize: 'sm',
color: { base: 'green.800', _dark: 'green.200' },
marginBottom: '1',
paddingLeft: '2',
})}
>
{item}
</div>
)
})}
</div>
)}
</div>
)

View File

@ -1,6 +1,6 @@
'use client'
import { useState, useCallback, useMemo } from 'react'
import { useState, useCallback, useMemo, useEffect } from 'react'
import type {
ExecutableFlowchart,
FlowchartState,
@ -84,6 +84,8 @@ export function FlowchartWalker({
const [wrongDecision, setWrongDecision] = useState<WrongDecisionState | null>(null)
// History stack for back navigation (stores full state snapshots)
const [stateHistory, setStateHistory] = useState<FlowchartState[]>([])
// Track checked checklist items for the current node
const [checkedItems, setCheckedItems] = useState<Set<number>>(new Set())
// Current node
const currentNode = useMemo(
@ -91,6 +93,18 @@ export function FlowchartWalker({
[flowchart.nodes, state.currentNode]
)
// Check if current node has an interactive checklist
const currentChecklist = currentNode?.content?.checklist
const hasInteractiveChecklist =
currentNode?.definition.type === 'instruction' &&
currentChecklist &&
currentChecklist.length > 0
// Reset checked items when node changes
useEffect(() => {
setCheckedItems(new Set())
}, [state.currentNode])
// Problem display
const problemDisplay = formatProblemDisplay(flowchart, state.problem)
@ -290,6 +304,31 @@ export function FlowchartWalker({
setPhase({ type: 'awaitingCheckpoint' })
}, [])
const handleChecklistToggle = useCallback(
(index: number) => {
setCheckedItems((prev) => {
const next = new Set(prev)
if (next.has(index)) {
next.delete(index)
} else {
next.add(index)
}
// Check if all items are now checked - if so, auto-advance
const totalItems = currentChecklist?.length ?? 0
if (next.size === totalItems && totalItems > 0) {
// Small delay so the user sees the final checkbox check
setTimeout(() => {
advanceToNext()
}, 300)
}
return next
})
},
[currentChecklist, advanceToNext]
)
// =============================================================================
// Determine what to show based on node type and phase
// =============================================================================
@ -301,6 +340,10 @@ export function FlowchartWalker({
switch (def.type) {
case 'instruction':
// If there's an interactive checklist, don't show the button - checking all items advances
if (hasInteractiveChecklist) {
return null
}
return (
<button
data-testid="instruction-advance-button"
@ -717,7 +760,13 @@ export function FlowchartWalker({
borderColor: { base: 'gray.200', _dark: 'gray.700' },
})}
>
{currentNode && <FlowchartNodeContent content={currentNode.content} />}
{currentNode && (
<FlowchartNodeContent
content={currentNode.content}
checkedItems={hasInteractiveChecklist ? checkedItems : undefined}
onChecklistToggle={hasInteractiveChecklist ? handleChecklistToggle : undefined}
/>
)}
</div>
{/* Interaction area */}

View File

@ -82,7 +82,7 @@ flowchart TB
CONV1C --> READY2(("👍"))
STEP2 -->|"NO"| STEP3["<b>CROSS MULTIPLY BOTTOMS</b><br/>──────────────────<br/>New bottom = left × right"]
STEP3 --> STEP3B["<b>CONVERT BOTH FRACTIONS</b><br/>────────────────────<br/>For EACH fraction:<br/>What × old bottom = LCD?"] --> READY3(("👍"))
READY1 --> CHECK1["<b>✅ READY CHECK</b><br/>──────────────<br/>☐ Both bottoms are<br/> the SAME number<br/>☐ I wrote the fractions down"]
READY1 --> CHECK1["<b>✅ READY CHECK</b><br/>──────────────<br/>☐ Both bottoms are<br/> the SAME number<br/>☐ I wrote the new fractions down"]
READY2 --> CHECK1
READY3 --> CHECK1
end