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": {