diff --git a/frontend/src/features/learning/components/InstructionMarkdown.tsx b/frontend/src/features/learning/components/InstructionMarkdown.tsx new file mode 100644 index 0000000..af66b74 --- /dev/null +++ b/frontend/src/features/learning/components/InstructionMarkdown.tsx @@ -0,0 +1,195 @@ +import { useState } from 'react'; +import { Copy, Check } from 'lucide-react'; + +/** + * Minimal markdown renderer for roadmap-authored text (instructions, hints, + * solutions): bold, inline code, fenced code blocks with a copy button, and + * simple lists. Extracted from RoadmapPlayer so every player surface renders + * roadmap text the same way. + */ + +function CodeBlock({ code }: { code: string }) { + const [copied, setCopied] = useState(false); + const [isHovered, setIsHovered] = useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy text: ', err); + } + }; + + return ( +
+
+ +
+
+        {code}
+      
+
+ ); +} + +export function renderInstruction(text: string) { + if (!text) return null; + + const blocks: React.ReactNode[] = []; + const lines = text.split('\n'); + let inCodeBlock = false; + let codeBlockLines: string[] = []; + + const parseInline = (inlineText: string): React.ReactNode => { + const inlineRegex = /(\*\*.*?\*\*|`.*?`)/g; + const parts = inlineText.split(inlineRegex); + return parts.map((part, index) => { + if (part.startsWith('**') && part.endsWith('**')) { + return {part.slice(2, -2)}; + } + if (part.startsWith('`') && part.endsWith('`')) { + return ( + + {part.slice(1, -1)} + + ); + } + return part; + }); + }; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (line.startsWith('```')) { + if (inCodeBlock) { + const codeContent = codeBlockLines.join('\n'); + blocks.push(); + inCodeBlock = false; + codeBlockLines = []; + } else { + inCodeBlock = true; + } + continue; + } + + if (inCodeBlock) { + codeBlockLines.push(line); + continue; + } + + if (line.trim() === '') { + blocks.push(
); + continue; + } + + const listMatch = line.match(/^(\d+\.|\-|\*)\s+(.*)$/); + if (listMatch) { + const marker = listMatch[1]; + const content = listMatch[2]; + blocks.push( +
+ {marker} + {parseInline(content)} +
+ ); + } else { + blocks.push( +

+ {parseInline(line)} +

+ ); + } + } + + return
{blocks}
; +} + +const styles: Record = { + markdownWrapper: { + display: 'flex', + flexDirection: 'column', + gap: '6px', + }, + instructionLine: { + margin: 0, + fontSize: '12px', + color: 'var(--color-text-secondary)', + lineHeight: 1.6, + }, + codeBlockContainer: { + margin: '8px 0', + backgroundColor: '#0F172A', + borderRadius: '6px', + border: '1px solid rgba(255, 255, 255, 0.05)', + overflow: 'hidden', + }, + pre: { + margin: 0, + padding: '12px', + overflowX: 'auto', + }, + codeBlock: { + fontFamily: 'monospace', + fontSize: '11px', + color: '#E2E8F0', + lineHeight: 1.5, + whiteSpace: 'pre', + }, + listItem: { + display: 'flex', + gap: '6px', + fontSize: '12px', + color: 'var(--color-text-secondary)', + lineHeight: 1.6, + paddingLeft: '4px', + }, + listMarker: { + fontWeight: 700, + color: 'var(--color-text-muted)', + flexShrink: 0, + }, + listContent: { + flex: 1, + }, + codeBlockHeader: { + display: 'flex', + justifyContent: 'flex-end', + padding: '6px 8px 0 8px', + backgroundColor: '#0F172A', + }, + copyBtn: { + background: 'none', + border: 'none', + cursor: 'pointer', + padding: '4px', + borderRadius: '4px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + transition: 'background-color 0.2s', + }, +}; diff --git a/frontend/src/features/learning/components/RoadmapPlayer.tsx b/frontend/src/features/learning/components/RoadmapPlayer.tsx index 2ccedd1..c0ef780 100644 --- a/frontend/src/features/learning/components/RoadmapPlayer.tsx +++ b/frontend/src/features/learning/components/RoadmapPlayer.tsx @@ -1,7 +1,8 @@ -import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ArrowLeft, ChevronLeft, ChevronRight, Globe, Copy, Check } from 'lucide-react'; +import { ArrowLeft, ChevronLeft, ChevronRight, Globe } from 'lucide-react'; import StepValidationResults from './StepValidationResults'; +import StepHints from './StepHints'; +import { renderInstruction } from './InstructionMarkdown'; import { outcomePreset } from '../validationStatus'; import type { StepValidationResponse } from '../../../shared/types/roadmap'; import type { useLearningPlayer } from '../hooks/useLearningPlayer'; @@ -14,43 +15,6 @@ interface RoadmapPlayerProps { networkConfig: NetworkConfig; } -function CodeBlock({ code }: { code: string }) { - const [copied, setCopied] = useState(false); - const [isHovered, setIsHovered] = useState(false); - - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(code); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error('Failed to copy text: ', err); - } - }; - - return ( -
-
- -
-
-        {code}
-      
-
- ); -} - function StepMarker({ response }: { response: StepValidationResponse }) { const { t } = useTranslation(); const { icon: Icon, color, labelKey } = outcomePreset(response); @@ -61,89 +25,6 @@ function StepMarker({ response }: { response: StepValidationResponse }) { ); } -function renderInstruction(text: string) { - if (!text) return null; - - const blocks: React.ReactNode[] = []; - const lines = text.split('\n'); - let inCodeBlock = false; - let codeBlockLines: string[] = []; - - const parseInline = (inlineText: string): React.ReactNode => { - const inlineRegex = /(\*\*.*?\*\*|`.*?`)/g; - const parts = inlineText.split(inlineRegex); - return parts.map((part, index) => { - if (part.startsWith('**') && part.endsWith('**')) { - return {part.slice(2, -2)}; - } - if (part.startsWith('`') && part.endsWith('`')) { - return ( - - {part.slice(1, -1)} - - ); - } - return part; - }); - }; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - - if (line.startsWith('```')) { - if (inCodeBlock) { - const codeContent = codeBlockLines.join('\n'); - blocks.push(); - inCodeBlock = false; - codeBlockLines = []; - } else { - inCodeBlock = true; - } - continue; - } - - if (inCodeBlock) { - codeBlockLines.push(line); - continue; - } - - if (line.trim() === '') { - blocks.push(
); - continue; - } - - const listMatch = line.match(/^(\d+\.|\-|\*)\s+(.*)$/); - if (listMatch) { - const marker = listMatch[1]; - const content = listMatch[2]; - blocks.push( -
- {marker} - {parseInline(content)} -
- ); - } else { - blocks.push( -

- {parseInline(line)} -

- ); - } - } - - return
{blocks}
; -} - export default function RoadmapPlayer({ player, containers = [], @@ -164,6 +45,8 @@ export default function RoadmapPlayer({ validationError, resultsByStepId, validateCurrentStep, + revealedHintsByStepId, + revealNextHint, closeRoadmap, } = player; @@ -253,6 +136,13 @@ export default function RoadmapPlayer({
)} + {/* key resets the solution-confirmation arming when the step changes. */} + ); })()} @@ -469,67 +359,4 @@ const styles: Record = { instructionContainer: { marginTop: '4px', }, - markdownWrapper: { - display: 'flex', - flexDirection: 'column', - gap: '6px', - }, - instructionLine: { - margin: 0, - fontSize: '12px', - color: 'var(--color-text-secondary)', - lineHeight: 1.6, - }, - codeBlockContainer: { - margin: '8px 0', - backgroundColor: '#0F172A', - borderRadius: '6px', - border: '1px solid rgba(255, 255, 255, 0.05)', - overflow: 'hidden', - }, - pre: { - margin: 0, - padding: '12px', - overflowX: 'auto', - }, - codeBlock: { - fontFamily: 'monospace', - fontSize: '11px', - color: '#E2E8F0', - lineHeight: 1.5, - whiteSpace: 'pre', - }, - listItem: { - display: 'flex', - gap: '6px', - fontSize: '12px', - color: 'var(--color-text-secondary)', - lineHeight: 1.6, - paddingLeft: '4px', - }, - listMarker: { - fontWeight: 700, - color: 'var(--color-text-muted)', - flexShrink: 0, - }, - listContent: { - flex: 1, - }, - codeBlockHeader: { - display: 'flex', - justifyContent: 'flex-end', - padding: '6px 8px 0 8px', - backgroundColor: '#0F172A', - }, - copyBtn: { - background: 'none', - border: 'none', - cursor: 'pointer', - padding: '4px', - borderRadius: '4px', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - transition: 'background-color 0.2s', - }, }; diff --git a/frontend/src/features/learning/components/StepHints.test.tsx b/frontend/src/features/learning/components/StepHints.test.tsx new file mode 100644 index 0000000..d7f8d82 --- /dev/null +++ b/frontend/src/features/learning/components/StepHints.test.tsx @@ -0,0 +1,104 @@ +import '../../../i18n'; +import { useState } from 'react'; +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import StepHints from './StepHints'; +import type { RoadmapStep } from '../../../shared/types/roadmap'; + +function buildStep(overrides: Partial): RoadmapStep { + return { + id: 'run-web-server', + title: 'Run the web server', + instruction: 'Serve a page from `web`.', + validators: [{ type: 'container_running', params: { node: 'web' } }], + ...overrides, + }; +} + +/** Owns revealedCount the way useLearningPlayer does (increment, clamped). */ +function Harness({ step, initialRevealed = 0 }: { step: RoadmapStep; initialRevealed?: number }) { + const [revealed, setRevealed] = useState(initialRevealed); + const totalRungs = (step.hints?.length ?? 0) + (step.solution ? 1 : 0); + return ( + setRevealed(count => Math.min(count + 1, totalRungs))} + /> + ); +} + +describe('StepHints', () => { + it('reveals hints one at a time, in order, keeping earlier hints readable', () => { + render(); + + expect(screen.queryByText('First nudge.')).not.toBeInTheDocument(); + expect(screen.getAllByRole('button')).toHaveLength(1); + + fireEvent.click(screen.getByRole('button', { name: 'Show hint (1/3)' })); + expect(screen.getByText('First nudge.')).toBeInTheDocument(); + expect(screen.queryByText('Second nudge.')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Show hint (2/3)' })); + fireEvent.click(screen.getByRole('button', { name: 'Show hint (3/3)' })); + + expect(screen.getByText('First nudge.')).toBeInTheDocument(); + expect(screen.getByText('Second nudge.')).toBeInTheDocument(); + expect(screen.getByText('Almost the answer.')).toBeInTheDocument(); + // No solution on this step: the ladder ends after the last hint. + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('only ever offers the next rung — the solution cannot be reached before all hints', () => { + render(); + + expect(screen.getByRole('button', { name: 'Show hint (1/2)' })).toBeInTheDocument(); + expect(screen.queryByText(/Reveal the solution/)).not.toBeInTheDocument(); + }); + + it('reveals the solution behind a two-click confirmation, styled as its own rung', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Show hint (1/1)' })); + expect(screen.getByText('Only hint.')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Reveal the solution' })); + // First click arms the button; nothing is revealed yet. + expect(screen.queryByText('The full answer.')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Sure? Click again to reveal' })); + expect(screen.getByText('The full answer.')).toBeInTheDocument(); + expect(screen.getByText('Solution')).toBeInTheDocument(); + expect(screen.getByText('Only hint.')).toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('offers the solution directly (still with the brake) on a step with no hints', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Reveal the solution' })); + fireEvent.click(screen.getByRole('button', { name: 'Sure? Click again to reveal' })); + expect(screen.getByText('The full answer.')).toBeInTheDocument(); + }); + + it('renders nothing on a step with neither hints nor solution', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('shows already-revealed hints on arrival when the step is revisited', () => { + // Coming back to a step: the player hook hands back the persisted count. + render( + {}} + /> + ); + + expect(screen.getByText('First nudge.')).toBeInTheDocument(); + expect(screen.getByText('Second nudge.')).toBeInTheDocument(); + expect(screen.queryByText('The answer.')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Reveal the solution' })).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/features/learning/components/StepHints.tsx b/frontend/src/features/learning/components/StepHints.tsx new file mode 100644 index 0000000..0a5ecb2 --- /dev/null +++ b/frontend/src/features/learning/components/StepHints.tsx @@ -0,0 +1,150 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Lightbulb, KeyRound } from 'lucide-react'; +import { renderInstruction } from './InstructionMarkdown'; +import type { RoadmapStep } from '../../../shared/types/roadmap'; + +/** + * Progressive hint ladder of one step: [...hints, solution?], where + * `revealedCount` (owned by useLearningPlayer, keyed by step id) is how many + * rungs are visible. Design decisions, in place of a written spec: + * + * - Strictly sequential: one button reveals the next rung; there is no way + * to jump ahead. Revealed rungs stay readable — including when the learner + * navigates away and back — for the whole session. + * - Hints are free: no validation attempt is required first (a learner stuck + * before even trying needs the first nudge most). + * - The solution is the last rung, behind a light brake: the first click + * arms the button (label switches to a confirmation), the second reveals. + * The armed state is local and resets on step change (`key={step.id}` at + * the call site) — no modal. + * - A step with neither hints nor solution renders nothing. + */ + +interface StepHintsProps { + step: RoadmapStep; + revealedCount: number; + onReveal: () => void; +} + +export default function StepHints({ step, revealedCount, onReveal }: StepHintsProps) { + const { t } = useTranslation(); + const [solutionArmed, setSolutionArmed] = useState(false); + + const hints = step.hints ?? []; + const hasSolution = Boolean(step.solution); + const totalRungs = hints.length + (hasSolution ? 1 : 0); + if (totalRungs === 0) return null; + + const revealed = Math.min(revealedCount, totalRungs); + const solutionRevealed = hasSolution && revealed === totalRungs; + const nextIsSolution = !solutionRevealed && revealed === hints.length; + + const handleReveal = () => { + if (nextIsSolution && !solutionArmed) { + setSolutionArmed(true); + return; + } + setSolutionArmed(false); + onReveal(); + }; + + return ( +
+ {hints.slice(0, revealed).map((hint, index) => ( +
+ + + {t('learning.player.hintLabel', { n: index + 1, total: hints.length })} + + {renderInstruction(hint)} +
+ ))} + + {solutionRevealed && ( +
+ + + {t('learning.player.solutionLabel')} + + {renderInstruction(step.solution!)} +
+ )} + + {revealed < totalRungs && ( + + )} +
+ ); +} + +const styles: Record = { + container: { + display: 'flex', + flexDirection: 'column', + gap: '8px', + marginTop: '10px', + }, + hintBox: { + display: 'flex', + flexDirection: 'column', + gap: '4px', + padding: '8px 10px', + border: '1px solid var(--border-color)', + borderLeft: '3px solid var(--color-accent)', + borderRadius: '6px', + }, + solutionBox: { + borderColor: 'var(--color-warning)', + borderLeft: '3px solid var(--color-warning)', + backgroundColor: 'var(--color-warning-glow)', + }, + hintLabel: { + display: 'flex', + alignItems: 'center', + fontSize: '10px', + fontWeight: 700, + color: 'var(--color-text-muted)', + textTransform: 'uppercase', + letterSpacing: '0.5px', + }, + solutionLabel: { + color: 'var(--color-warning-strong)', + }, + labelIcon: { + marginRight: '5px', + flexShrink: 0, + }, + revealBtn: { + display: 'flex', + alignItems: 'center', + alignSelf: 'flex-start', + padding: '6px 12px', + border: '1px dashed var(--border-color)', + borderRadius: '6px', + background: 'none', + color: 'var(--color-text-secondary)', + fontSize: '11px', + fontWeight: 600, + cursor: 'pointer', + fontFamily: 'var(--font-sans)', + }, + revealSolutionBtn: { + border: '1px dashed var(--color-warning)', + color: 'var(--color-warning-strong)', + }, +}; diff --git a/frontend/src/features/learning/hooks/useLearningPlayer.test.ts b/frontend/src/features/learning/hooks/useLearningPlayer.test.ts index 8ebb712..644bd74 100644 --- a/frontend/src/features/learning/hooks/useLearningPlayer.test.ts +++ b/frontend/src/features/learning/hooks/useLearningPlayer.test.ts @@ -18,6 +18,8 @@ const roadmap: Roadmap = { id: 'create-web-server', title: 'Create the web server', instruction: 'Drag an Ubuntu node named `web` onto the canvas and start it.', + hints: ['Look at the node palette.', 'Drag the Ubuntu card.'], + solution: 'Drag an Ubuntu node, name it `web`, click start.', validators: [{ type: 'container_running', params: { node: 'web' } }], }, { @@ -224,6 +226,52 @@ describe('useLearningPlayer', () => { }); }); + describe('revealNextHint', () => { + it('reveals rungs one at a time for the current step and clamps at the ladder length', async () => { + const { result } = renderHook(() => useLearningPlayer({ projectId: 'p1' })); + await openExampleRoadmap(result, fetchMock); + + act(() => result.current.revealNextHint()); + expect(result.current.revealedHintsByStepId['create-web-server']).toBe(1); + + // 2 hints + 1 solution = 3 rungs; extra calls must not go past the end. + act(() => result.current.revealNextHint()); + act(() => result.current.revealNextHint()); + act(() => result.current.revealNextHint()); + expect(result.current.revealedHintsByStepId['create-web-server']).toBe(3); + }); + + it('does nothing on a step with neither hints nor solution', async () => { + const { result } = renderHook(() => useLearningPlayer({ projectId: 'p1' })); + await openExampleRoadmap(result, fetchMock); + + act(() => result.current.goToStep(1)); + act(() => result.current.revealNextHint()); + expect(result.current.revealedHintsByStepId['add-database']).toBe(0); + }); + + it('keeps revealed hints per step when navigating away and back', async () => { + const { result } = renderHook(() => useLearningPlayer({ projectId: 'p1' })); + await openExampleRoadmap(result, fetchMock); + + act(() => result.current.revealNextHint()); + act(() => result.current.goToStep(1)); + act(() => result.current.goToStep(0)); + + expect(result.current.revealedHintsByStepId['create-web-server']).toBe(1); + }); + + it('resets revealed hints when a roadmap is reopened', async () => { + const { result } = renderHook(() => useLearningPlayer({ projectId: 'p1' })); + await openExampleRoadmap(result, fetchMock); + + act(() => result.current.revealNextHint()); + await openExampleRoadmap(result, fetchMock); + + expect(result.current.revealedHintsByStepId).toEqual({}); + }); + }); + describe('closeRoadmap', () => { it('returns to the catalogue state and drops in-memory results', async () => { const { result } = renderHook(() => useLearningPlayer({ projectId: 'p1' })); @@ -234,10 +282,12 @@ describe('useLearningPlayer', () => { await result.current.validateCurrentStep(); }); + act(() => result.current.revealNextHint()); act(() => result.current.closeRoadmap()); expect(result.current.roadmap).toBeNull(); expect(result.current.resultsByStepId).toEqual({}); + expect(result.current.revealedHintsByStepId).toEqual({}); expect(result.current.currentStepIndex).toBe(0); }); }); diff --git a/frontend/src/features/learning/hooks/useLearningPlayer.ts b/frontend/src/features/learning/hooks/useLearningPlayer.ts index f9ebc7e..68785da 100644 --- a/frontend/src/features/learning/hooks/useLearningPlayer.ts +++ b/frontend/src/features/learning/hooks/useLearningPlayer.ts @@ -29,6 +29,11 @@ export function useLearningPlayer({ projectId }: UseLearningPlayerOptions) { const [validating, setValidating] = useState(false); const [validationError, setValidationError] = useState(null); const [resultsByStepId, setResultsByStepId] = useState>({}); + // Count of revealed rungs on the step's hint ladder [...hints, solution?]. + // Kept across step navigation (a revealed hint stays revealed for the whole + // session), reset when a roadmap is opened or closed — same lifecycle as + // resultsByStepId, and the natural seam for P-4 to persist later. + const [revealedHintsByStepId, setRevealedHintsByStepId] = useState>({}); // React state updates are async, so `validating` alone cannot stop two // synchronous clicks — the ref is the actual double-submit guard. const validatingRef = useRef(false); @@ -61,6 +66,7 @@ export function useLearningPlayer({ projectId }: UseLearningPlayerOptions) { setRoadmap(data); setCurrentStepIndex(0); setResultsByStepId({}); + setRevealedHintsByStepId({}); setValidationError(null); } catch (err) { console.error('Failed to load roadmap:', err); @@ -77,6 +83,7 @@ export function useLearningPlayer({ projectId }: UseLearningPlayerOptions) { setRoadmapError(null); setCurrentStepIndex(0); setResultsByStepId({}); + setRevealedHintsByStepId({}); setValidationError(null); }, []); @@ -90,6 +97,17 @@ export function useLearningPlayer({ projectId }: UseLearningPlayerOptions) { [roadmap] ); + const revealNextHint = useCallback(() => { + if (!currentStep) return; + // Sequential reveal only: one more rung per call, clamped to the ladder + // length so a stray extra click can never jump past the solution. + const totalRungs = (currentStep.hints?.length ?? 0) + (currentStep.solution ? 1 : 0); + setRevealedHintsByStepId(prev => ({ + ...prev, + [currentStep.id]: Math.min((prev[currentStep.id] ?? 0) + 1, totalRungs), + })); + }, [currentStep]); + const validateCurrentStep = useCallback(async () => { if (validatingRef.current || !roadmap || !currentStep) return; validatingRef.current = true; @@ -131,5 +149,7 @@ export function useLearningPlayer({ projectId }: UseLearningPlayerOptions) { validationError, resultsByStepId, validateCurrentStep, + revealedHintsByStepId, + revealNextHint, }; } diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index aa17e52..c89756b 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -46,7 +46,12 @@ "checkNotRun": "Check couldn't run", "markerPassed": "Passed", "markerFailed": "Failed", - "markerError": "Check error" + "markerError": "Check error", + "hintLabel": "Hint {{n}} of {{total}}", + "showHint": "Show hint ({{n}}/{{total}})", + "solutionLabel": "Solution", + "showSolution": "Reveal the solution", + "confirmSolution": "Sure? Click again to reveal" } }, "projects": { diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index 800ea65..0ae27fb 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -46,7 +46,12 @@ "checkNotRun": "Vérification impossible", "markerPassed": "Validée", "markerFailed": "Échouée", - "markerError": "Erreur de vérification" + "markerError": "Erreur de vérification", + "hintLabel": "Indice {{n}} sur {{total}}", + "showHint": "Voir un indice ({{n}}/{{total}})", + "solutionLabel": "Solution", + "showSolution": "Révéler la solution", + "confirmSolution": "Sûr ? Cliquez à nouveau pour révéler" } }, "projects": {