From 8915aee4983bf430b552a64a6a193296bed1fa4c Mon Sep 17 00:00:00 2001
From: OthmaneZ05 <zahhafothmane2005@gmail.com>
Date: Wed, 15 Jul 2026 12:44:06 -0400
Subject: [PATCH 1/2] feat(learning): pedagogical validation feedback with
 pass/fail/error states

---
 .../components/LearningPanel.test.tsx         |  61 +++++-
 .../learning/components/RoadmapPlayer.tsx     |  41 ++--
 .../components/StepValidationResults.test.tsx | 109 +++++++++++
 .../components/StepValidationResults.tsx      | 183 +++++++++++++-----
 .../src/features/learning/validationStatus.ts |  22 +++
 frontend/src/index.css                        |   5 +-
 frontend/src/locales/en.json                  |  10 +-
 frontend/src/locales/fr.json                  |  10 +-
 8 files changed, 378 insertions(+), 63 deletions(-)
 create mode 100644 frontend/src/features/learning/components/StepValidationResults.test.tsx
 create mode 100644 frontend/src/features/learning/validationStatus.ts

diff --git a/frontend/src/features/learning/components/LearningPanel.test.tsx b/frontend/src/features/learning/components/LearningPanel.test.tsx
index fd70fb5..b984e93 100644
--- a/frontend/src/features/learning/components/LearningPanel.test.tsx
+++ b/frontend/src/features/learning/components/LearningPanel.test.tsx
@@ -59,6 +59,21 @@ const failResponse: StepValidationResponse = {
   checkedAt: '2026-07-15T10:00:00.000Z',
 };
 
+const passResponse: StepValidationResponse = {
+  roadmapId: roadmap.id,
+  stepId: 'create-web-server',
+  stepPassed: true,
+  results: [
+    {
+      index: 0,
+      type: 'container_running',
+      status: 'pass',
+      message: 'The container "web" is running.',
+    },
+  ],
+  checkedAt: '2026-07-15T10:01:00.000Z',
+};
+
 function jsonResponse(ok: boolean, body: unknown): Response {
   return { ok, json: () => Promise.resolve(body) } as Response;
 }
@@ -149,7 +164,7 @@ describe('LearningPanel', () => {
     expect(screen.getByText('Step 1 of 2')).toBeInTheDocument();
   });
 
-  it('validates the current step and renders the raw results', async () => {
+  it('validates the current step and renders pedagogical feedback', async () => {
     vi.stubGlobal('fetch', buildFetchMock({}));
     render(<LearningPanel projectId="p1" onClose={() => {}} />);
     await openRoadmapFromCatalog();
@@ -161,6 +176,50 @@ describe('LearningPanel', () => {
       screen.getByText('No container named "web" exists in this project yet.')
     ).toBeInTheDocument();
     expect(screen.getByText(/a running container named "web"/)).toBeInTheDocument();
+    // The step list reflects the failure, and the raw P-1 rendering is gone.
+    expect(screen.getByTitle('Failed')).toBeInTheDocument();
+    expect(screen.queryByText('[fail]')).not.toBeInTheDocument();
+    expect(screen.queryByText('container_running')).not.toBeInTheDocument();
+  });
+
+  it('replaces failure feedback cleanly when a revalidation passes', async () => {
+    let firstAttempt = true;
+    vi.stubGlobal(
+      'fetch',
+      buildFetchMock({
+        validate: () => {
+          const body = firstAttempt ? failResponse : passResponse;
+          firstAttempt = false;
+          return jsonResponse(true, body);
+        },
+      })
+    );
+    render(<LearningPanel projectId="p1" onClose={() => {}} />);
+    await openRoadmapFromCatalog();
+
+    fireEvent.click(screen.getByRole('button', { name: 'Validate' }));
+    expect(await screen.findByText('Not yet — see the results below')).toBeInTheDocument();
+
+    fireEvent.click(screen.getByRole('button', { name: 'Validate' }));
+    expect(await screen.findByText('Step passed')).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: 'Next step' })).toBeInTheDocument();
+    expect(screen.getByTitle('Passed')).toBeInTheDocument();
+    // No artifacts from the failed attempt survive.
+    expect(screen.queryByText('Not yet — see the results below')).not.toBeInTheDocument();
+    expect(
+      screen.queryByText('No container named "web" exists in this project yet.')
+    ).not.toBeInTheDocument();
+  });
+
+  it('advances to the next step from the success banner', async () => {
+    vi.stubGlobal('fetch', buildFetchMock({ validate: () => jsonResponse(true, passResponse) }));
+    render(<LearningPanel projectId="p1" onClose={() => {}} />);
+    await openRoadmapFromCatalog();
+
+    fireEvent.click(screen.getByRole('button', { name: 'Validate' }));
+    fireEvent.click(await screen.findByRole('button', { name: 'Next step' }));
+
+    expect(screen.getByText('Step 2 of 2')).toBeInTheDocument();
   });
 
   it('shows an understandable error with retry when the backend is unreachable during validation', async () => {
diff --git a/frontend/src/features/learning/components/RoadmapPlayer.tsx b/frontend/src/features/learning/components/RoadmapPlayer.tsx
index 63cdbea..e7a801d 100644
--- a/frontend/src/features/learning/components/RoadmapPlayer.tsx
+++ b/frontend/src/features/learning/components/RoadmapPlayer.tsx
@@ -1,7 +1,9 @@
 import { useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import { ArrowLeft, ChevronLeft, ChevronRight, Globe, Copy, Check } from 'lucide-react';
+import { AlertTriangle, ArrowLeft, CheckCircle2, ChevronLeft, ChevronRight, Globe, Copy, Check, XCircle } from 'lucide-react';
 import StepValidationResults from './StepValidationResults';
+import { stepOutcome, type StepOutcome } from '../validationStatus';
+import type { StepValidationResponse } from '../../../shared/types/roadmap';
 import type { useLearningPlayer } from '../hooks/useLearningPlayer';
 import type { ContainerData } from '../../../shared/types';
 import type { NetworkConfig } from '../../../shared/types/network';
@@ -49,6 +51,22 @@ function CodeBlock({ code }: { code: string }) {
   );
 }
 
+const MARKER_PRESETS: Record<StepOutcome, { icon: typeof CheckCircle2; color: string; labelKey: string }> = {
+  passed: { icon: CheckCircle2, color: 'var(--color-success)', labelKey: 'learning.player.markerPassed' },
+  failed: { icon: XCircle, color: 'var(--color-danger)', labelKey: 'learning.player.markerFailed' },
+  error: { icon: AlertTriangle, color: 'var(--color-warning)', labelKey: 'learning.player.markerError' },
+};
+
+function StepMarker({ response }: { response: StepValidationResponse }) {
+  const { t } = useTranslation();
+  const { icon: Icon, color, labelKey } = MARKER_PRESETS[stepOutcome(response)];
+  return (
+    <span style={styles.stepMarker} title={t(labelKey)} aria-label={t(labelKey)}>
+      <Icon size={13} color={color} />
+    </span>
+  );
+}
+
 function renderInstruction(text: string) {
   if (!text) return null;
 
@@ -183,16 +201,7 @@ export default function RoadmapPlayer({
             >
               <span style={styles.stepIndex}>{index + 1}.</span>
               <span style={styles.stepItemTitle}>{step.title}</span>
-              {result && (
-                <span
-                  style={{
-                    ...styles.stepMarker,
-                    color: result.stepPassed ? 'var(--color-success)' : 'var(--color-danger)',
-                  }}
-                >
-                  {result.stepPassed ? '✓' : '✗'}
-                </span>
-              )}
+              {result && <StepMarker response={result} />}
             </button>
           );
         })}
@@ -291,7 +300,11 @@ export default function RoadmapPlayer({
       )}
 
       {resultsByStepId[currentStep.id] && (
-        <StepValidationResults response={resultsByStepId[currentStep.id]} />
+        <StepValidationResults
+          response={resultsByStepId[currentStep.id]}
+          isLastStep={atLastStep}
+          onNextStep={() => goToStep(currentStepIndex + 1)}
+        />
       )}
     </div>
   );
@@ -356,8 +369,8 @@ const styles: Record<string, React.CSSProperties> = {
     flex: 1,
   },
   stepMarker: {
-    fontSize: '12px',
-    fontWeight: 700,
+    display: 'flex',
+    alignItems: 'center',
   },
   currentStep: {
     display: 'flex',
diff --git a/frontend/src/features/learning/components/StepValidationResults.test.tsx b/frontend/src/features/learning/components/StepValidationResults.test.tsx
new file mode 100644
index 0000000..cc3600b
--- /dev/null
+++ b/frontend/src/features/learning/components/StepValidationResults.test.tsx
@@ -0,0 +1,109 @@
+import '../../../i18n';
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import StepValidationResults from './StepValidationResults';
+import type { StepValidationResponse, ValidatorResult } from '../../../shared/types/roadmap';
+
+const passResult: ValidatorResult = {
+  index: 0,
+  type: 'container_running',
+  status: 'pass',
+  message: 'The container "web" is running.',
+};
+
+const failResult: ValidatorResult = {
+  index: 0,
+  type: 'container_running',
+  status: 'fail',
+  message: 'No container named "web" exists in this project yet.',
+  expected: 'a running container named "web"',
+  observed: 'no container with that name',
+};
+
+const errorResult: ValidatorResult = {
+  index: 1,
+  type: 'table_exists',
+  status: 'error',
+  message: 'Something went wrong while talking to Docker.',
+  errorCode: 'DOCKER_ERROR',
+};
+
+function buildResponse(results: ValidatorResult[]): StepValidationResponse {
+  return {
+    roadmapId: 'example-first-architecture',
+    stepId: 'create-web-server',
+    stepPassed: results.every(result => result.status === 'pass'),
+    results,
+    checkedAt: '2026-07-15T10:00:00.000Z',
+  };
+}
+
+function renderResults(
+  response: StepValidationResponse,
+  { isLastStep = false, onNextStep = vi.fn() } = {}
+) {
+  render(<StepValidationResults response={response} isLastStep={isLastStep} onNextStep={onNextStep} />);
+  return { onNextStep };
+}
+
+describe('StepValidationResults', () => {
+  it('renders the success banner with a Next step button that advances', () => {
+    const { onNextStep } = renderResults(buildResponse([passResult]));
+
+    expect(screen.getByText('Step passed')).toBeInTheDocument();
+    fireEvent.click(screen.getByRole('button', { name: 'Next step' }));
+    expect(onNextStep).toHaveBeenCalledOnce();
+  });
+
+  it('celebrates the roadmap on the last step and offers no Next step button', () => {
+    renderResults(buildResponse([passResult]), { isLastStep: true });
+
+    expect(
+      screen.getByText('Step passed — that was the last one. Roadmap complete!')
+    ).toBeInTheDocument();
+    expect(screen.queryByRole('button', { name: 'Next step' })).not.toBeInTheDocument();
+  });
+
+  it('renders a pedagogical failure: banner, message, expected and observed', () => {
+    renderResults(buildResponse([failResult]));
+
+    expect(screen.getByText('Not yet — see the results below')).toBeInTheDocument();
+    expect(
+      screen.getByText('No container named "web" exists in this project yet.')
+    ).toBeInTheDocument();
+    expect(screen.getByText('a running container named "web"')).toBeInTheDocument();
+    expect(screen.getByText('no container with that name')).toBeInTheDocument();
+  });
+
+  it('renders an infrastructure error distinctly, without the raw error code', () => {
+    renderResults(buildResponse([errorResult]));
+
+    expect(
+      screen.getByText("Some checks couldn't run — that's on us, not you. Fix the issue below or just try again.")
+    ).toBeInTheDocument();
+    expect(screen.getByText("Check couldn't run")).toBeInTheDocument();
+    expect(screen.queryByText('DOCKER_ERROR')).not.toBeInTheDocument();
+  });
+
+  it('lets an error win over a failure in the banner while still listing the failure', () => {
+    renderResults(buildResponse([failResult, errorResult]));
+
+    expect(
+      screen.getByText("Some checks couldn't run — that's on us, not you. Fix the issue below or just try again.")
+    ).toBeInTheDocument();
+    expect(screen.queryByText('Not yet — see the results below')).not.toBeInTheDocument();
+    expect(
+      screen.getByText('No container named "web" exists in this project yet.')
+    ).toBeInTheDocument();
+  });
+
+  it('uses the Docker-specific wording when the daemon is unreachable', () => {
+    renderResults(
+      buildResponse([{ ...errorResult, errorCode: 'DOCKER_UNAVAILABLE' }])
+    );
+
+    expect(
+      screen.getByText("Docker isn't running, so the checks couldn't run. Start Docker and validate again.")
+    ).toBeInTheDocument();
+  });
+});
diff --git a/frontend/src/features/learning/components/StepValidationResults.tsx b/frontend/src/features/learning/components/StepValidationResults.tsx
index 2d4061c..cdcb0e0 100644
--- a/frontend/src/features/learning/components/StepValidationResults.tsx
+++ b/frontend/src/features/learning/components/StepValidationResults.tsx
@@ -1,57 +1,111 @@
 import { useTranslation } from 'react-i18next';
-import type { StepValidationResponse, ValidatorResult } from '../../../shared/types/roadmap';
+import { AlertTriangle, CheckCircle2, XCircle } from 'lucide-react';
+import { stepOutcome, STATUS_COLORS } from '../validationStatus';
+import type { StepValidationResponse, ValidatorResult, ValidatorStatus } from '../../../shared/types/roadmap';
 
 interface StepValidationResultsProps {
   response: StepValidationResponse;
+  isLastStep: boolean;
+  onNextStep: () => void;
 }
 
-// P-1 scope: a deliberately raw rendering of the engine's report.
-// P-2 turns this into the real ✓/✗/⚠ pedagogical feedback.
-export default function StepValidationResults({ response }: StepValidationResultsProps) {
-  const { t } = useTranslation();
+const STATUS_ICONS: Record<ValidatorStatus, typeof CheckCircle2> = {
+  pass: CheckCircle2,
+  fail: XCircle,
+  error: AlertTriangle,
+};
+
+const MARKER_LABEL_KEYS: Record<ValidatorStatus, string> = {
+  pass: 'learning.player.markerPassed',
+  fail: 'learning.player.markerFailed',
+  error: 'learning.player.markerError',
+};
 
+export default function StepValidationResults({
+  response,
+  isLastStep,
+  onNextStep,
+}: StepValidationResultsProps) {
   return (
     <div style={styles.container}>
-      <div
-        style={{
-          ...styles.banner,
-          color: response.stepPassed ? 'var(--color-success)' : 'var(--color-danger)',
-        }}
-      >
-        {response.stepPassed ? t('learning.player.stepPassed') : t('learning.player.stepFailed')}
-      </div>
+      <OutcomeBanner response={response} isLastStep={isLastStep} onNextStep={onNextStep} />
       {response.results.map(result => (
-        <ResultBlock key={result.index} result={result} />
+        <ValidatorResultCard key={result.index} result={result} />
       ))}
     </div>
   );
 }
 
-function ResultBlock({ result }: { result: ValidatorResult }) {
+function OutcomeBanner({ response, isLastStep, onNextStep }: StepValidationResultsProps) {
+  const { t } = useTranslation();
+  const outcome = stepOutcome(response);
+
+  if (outcome === 'passed') {
+    return (
+      <div style={{ ...styles.banner, ...styles.bannerPassed }}>
+        <div style={styles.bannerHeader}>
+          <CheckCircle2 size={15} style={styles.bannerIcon} color="var(--color-success)" />
+          <span style={{ color: 'var(--color-success)' }}>
+            {isLastStep ? t('learning.player.roadmapComplete') : t('learning.player.stepPassed')}
+          </span>
+        </div>
+        {!isLastStep && (
+          <button onClick={onNextStep} style={styles.nextStepBtn}>
+            {t('learning.player.nextStep')}
+          </button>
+        )}
+      </div>
+    );
+  }
+
+  if (outcome === 'error') {
+    const dockerDown = response.results.some(result => result.errorCode === 'DOCKER_UNAVAILABLE');
+    return (
+      <div style={{ ...styles.banner, ...styles.bannerError }}>
+        <div style={styles.bannerHeader}>
+          <AlertTriangle size={15} style={styles.bannerIcon} color="var(--color-warning)" />
+          <span style={{ color: 'var(--color-warning-strong)' }}>
+            {dockerDown ? t('learning.player.stepErrorDocker') : t('learning.player.stepError')}
+          </span>
+        </div>
+      </div>
+    );
+  }
+
+  return (
+    <div style={{ ...styles.banner, ...styles.bannerFailed }}>
+      <div style={styles.bannerHeader}>
+        <XCircle size={15} style={styles.bannerIcon} color="var(--color-danger)" />
+        <span style={{ color: 'var(--color-danger)' }}>{t('learning.player.stepFailed')}</span>
+      </div>
+    </div>
+  );
+}
+
+function ValidatorResultCard({ result }: { result: ValidatorResult }) {
   const { t } = useTranslation();
-  const statusColor =
-    result.status === 'pass'
-      ? 'var(--color-success)'
-      : result.status === 'fail'
-        ? 'var(--color-danger)'
-        : 'var(--color-text-muted)';
+  const Icon = STATUS_ICONS[result.status];
+  const color = STATUS_COLORS[result.status];
 
   return (
-    <div style={styles.result}>
-      <div style={styles.resultHeader}>
-        <span style={{ ...styles.resultStatus, color: statusColor }}>[{result.status}]</span>
-        <span style={styles.resultType}>{result.type}</span>
-        {result.errorCode && <span style={styles.errorCode}>{result.errorCode}</span>}
+    <div style={{ ...styles.card, borderLeft: `3px solid ${color}` }}>
+      <div style={styles.cardHeader}>
+        <Icon size={14} color={color} style={styles.cardIcon} aria-label={t(MARKER_LABEL_KEYS[result.status])} />
+        <span style={styles.cardMessage}>{result.message}</span>
       </div>
-      <div style={styles.resultMessage}>{result.message}</div>
+      {result.status === 'error' && (
+        <span style={styles.checkNotRun}>{t('learning.player.checkNotRun')}</span>
+      )}
       {result.expected && (
         <div style={styles.detailLine}>
-          <span style={styles.detailLabel}>{t('learning.player.expected')}:</span> {result.expected}
+          <span style={styles.detailLabel}>{t('learning.player.expected')}:</span>{' '}
+          <span style={styles.detailValue}>{result.expected}</span>
         </div>
       )}
       {result.observed && (
         <div style={styles.detailLine}>
-          <span style={styles.detailLabel}>{t('learning.player.observed')}:</span> {result.observed}
+          <span style={styles.detailLabel}>{t('learning.player.observed')}:</span>{' '}
+          <span style={styles.detailValue}>{result.observed}</span>
         </div>
       )}
     </div>
@@ -65,10 +119,49 @@ const styles: Record<string, React.CSSProperties> = {
     gap: '8px',
   },
   banner: {
-    fontSize: '13px',
+    display: 'flex',
+    flexDirection: 'column',
+    gap: '10px',
+    padding: '10px 12px',
+    borderRadius: '6px',
+    fontSize: '12px',
     fontWeight: 700,
+    lineHeight: 1.5,
+  },
+  bannerPassed: {
+    border: '1px solid var(--color-success)',
+    backgroundColor: 'var(--color-success-glow)',
+  },
+  bannerFailed: {
+    border: '1px solid var(--color-danger)',
+    backgroundColor: 'var(--color-danger-glow)',
+  },
+  bannerError: {
+    border: '1px solid var(--color-warning)',
+    backgroundColor: 'var(--color-warning-glow)',
+  },
+  bannerHeader: {
+    display: 'flex',
+    alignItems: 'flex-start',
+    gap: '6px',
+  },
+  bannerIcon: {
+    flexShrink: 0,
+    marginTop: '2px',
+  },
+  nextStepBtn: {
+    alignSelf: 'flex-start',
+    padding: '6px 14px',
+    border: 'none',
+    borderRadius: '6px',
+    backgroundColor: 'var(--color-success)',
+    color: '#FFFFFF',
+    fontSize: '12px',
+    fontWeight: 600,
+    cursor: 'pointer',
+    fontFamily: 'var(--font-sans)',
   },
-  result: {
+  card: {
     border: '1px solid var(--border-color)',
     borderRadius: '6px',
     padding: '8px 10px',
@@ -76,28 +169,25 @@ const styles: Record<string, React.CSSProperties> = {
     flexDirection: 'column',
     gap: '4px',
   },
-  resultHeader: {
+  cardHeader: {
     display: 'flex',
-    alignItems: 'center',
+    alignItems: 'flex-start',
     gap: '6px',
-    fontFamily: 'var(--font-mono)',
-    fontSize: '11px',
-  },
-  resultStatus: {
-    fontWeight: 700,
-  },
-  resultType: {
-    color: 'var(--color-text-secondary)',
   },
-  errorCode: {
-    color: 'var(--color-text-muted)',
-    marginLeft: 'auto',
+  cardIcon: {
+    flexShrink: 0,
+    marginTop: '2px',
   },
-  resultMessage: {
+  cardMessage: {
     fontSize: '12px',
     color: 'var(--color-text-primary)',
     lineHeight: 1.5,
   },
+  checkNotRun: {
+    fontSize: '11px',
+    fontWeight: 600,
+    color: 'var(--color-warning-strong)',
+  },
   detailLine: {
     fontSize: '11px',
     color: 'var(--color-text-secondary)',
@@ -107,4 +197,7 @@ const styles: Record<string, React.CSSProperties> = {
     fontWeight: 600,
     color: 'var(--color-text-muted)',
   },
+  detailValue: {
+    fontFamily: 'var(--font-mono)',
+  },
 };
diff --git a/frontend/src/features/learning/validationStatus.ts b/frontend/src/features/learning/validationStatus.ts
new file mode 100644
index 0000000..f7b9a6a
--- /dev/null
+++ b/frontend/src/features/learning/validationStatus.ts
@@ -0,0 +1,22 @@
+import type { StepValidationResponse, ValidatorStatus } from '../../shared/types/roadmap';
+
+export type StepOutcome = 'passed' | 'failed' | 'error';
+
+/**
+ * Aggregates a step's validator results into a single outcome.
+ * `error` wins over `failed`: when a check could not run, the step's real
+ * state is unknowable — telling the learner "not yet, fix your work" would
+ * blame them for an infrastructure problem. Failed checks still render
+ * individually below the banner, so no pedagogical information is lost.
+ */
+export function stepOutcome(response: StepValidationResponse): StepOutcome {
+  if (response.stepPassed) return 'passed';
+  if (response.results.some(result => result.status === 'error')) return 'error';
+  return 'failed';
+}
+
+export const STATUS_COLORS: Record<ValidatorStatus, string> = {
+  pass: 'var(--color-success)',
+  fail: 'var(--color-danger)',
+  error: 'var(--color-warning)',
+};
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 26264cc..b0322fc 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -18,7 +18,10 @@
   --color-success-glow: rgba(5, 150, 105, 0.1);
   --color-danger: #DC2626;
   --color-danger-glow: rgba(220, 38, 38, 0.1);
-  
+  --color-warning: #F59E0B;
+  --color-warning-glow: rgba(245, 158, 11, 0.1);
+  --color-warning-strong: #92400E;
+
   --font-sans: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif;
   --font-mono: 'JetBrains Mono', monospace;
   
diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json
index 52ef466..c2b8555 100644
--- a/frontend/src/locales/en.json
+++ b/frontend/src/locales/en.json
@@ -38,7 +38,15 @@
       "stepPassed": "Step passed",
       "stepFailed": "Not yet — see the results below",
       "expected": "Expected",
-      "observed": "Observed"
+      "observed": "Observed",
+      "stepError": "Some checks couldn't run — that's on us, not you. Fix the issue below or just try again.",
+      "stepErrorDocker": "Docker isn't running, so the checks couldn't run. Start Docker and validate again.",
+      "nextStep": "Next step",
+      "roadmapComplete": "Step passed — that was the last one. Roadmap complete!",
+      "checkNotRun": "Check couldn't run",
+      "markerPassed": "Passed",
+      "markerFailed": "Failed",
+      "markerError": "Check error"
     }
   },
   "projects": {
diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json
index 317719a..0825d05 100644
--- a/frontend/src/locales/fr.json
+++ b/frontend/src/locales/fr.json
@@ -38,7 +38,15 @@
       "stepPassed": "Étape validée",
       "stepFailed": "Pas encore — voir les résultats ci-dessous",
       "expected": "Attendu",
-      "observed": "Observé"
+      "observed": "Observé",
+      "stepError": "Certaines vérifications n'ont pas pu s'exécuter — ce n'est pas de votre faute. Corrigez le problème ci-dessous ou réessayez simplement.",
+      "stepErrorDocker": "Docker n'est pas démarré, les vérifications n'ont pas pu s'exécuter. Démarrez Docker puis validez à nouveau.",
+      "nextStep": "Étape suivante",
+      "roadmapComplete": "Étape validée — c'était la dernière. Roadmap terminée !",
+      "checkNotRun": "Vérification impossible",
+      "markerPassed": "Validée",
+      "markerFailed": "Échouée",
+      "markerError": "Erreur de vérification"
     }
   },
   "projects": {

From ae9ca9e512c2d046c30ae3007fbf7fc27c450edd Mon Sep 17 00:00:00 2001
From: OthmaneZ05 <zahhafothmane2005@gmail.com>
Date: Wed, 15 Jul 2026 12:55:03 -0400
Subject: [PATCH 2/2] refactor(learning): centralize status presets and apply
 review findings

---
 .../components/LearningPanel.test.tsx         |  2 +-
 .../learning/components/RoadmapPlayer.tsx     | 16 +++-------
 .../components/StepValidationResults.test.tsx |  2 +-
 .../components/StepValidationResults.tsx      | 27 +++++-----------
 .../src/features/learning/validationStatus.ts | 31 ++++++++++++++++---
 frontend/src/locales/en.json                  |  2 +-
 frontend/src/locales/fr.json                  |  2 +-
 frontend/src/shared/types/roadmap.ts          |  6 +++-
 8 files changed, 48 insertions(+), 40 deletions(-)

diff --git a/frontend/src/features/learning/components/LearningPanel.test.tsx b/frontend/src/features/learning/components/LearningPanel.test.tsx
index b984e93..21ca46b 100644
--- a/frontend/src/features/learning/components/LearningPanel.test.tsx
+++ b/frontend/src/features/learning/components/LearningPanel.test.tsx
@@ -176,7 +176,7 @@ describe('LearningPanel', () => {
       screen.getByText('No container named "web" exists in this project yet.')
     ).toBeInTheDocument();
     expect(screen.getByText(/a running container named "web"/)).toBeInTheDocument();
-    // The step list reflects the failure, and the raw P-1 rendering is gone.
+    // The step list reflects the failure, and the raw status/type strings are gone.
     expect(screen.getByTitle('Failed')).toBeInTheDocument();
     expect(screen.queryByText('[fail]')).not.toBeInTheDocument();
     expect(screen.queryByText('container_running')).not.toBeInTheDocument();
diff --git a/frontend/src/features/learning/components/RoadmapPlayer.tsx b/frontend/src/features/learning/components/RoadmapPlayer.tsx
index e7a801d..2ccedd1 100644
--- a/frontend/src/features/learning/components/RoadmapPlayer.tsx
+++ b/frontend/src/features/learning/components/RoadmapPlayer.tsx
@@ -1,8 +1,8 @@
 import { useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import { AlertTriangle, ArrowLeft, CheckCircle2, ChevronLeft, ChevronRight, Globe, Copy, Check, XCircle } from 'lucide-react';
+import { ArrowLeft, ChevronLeft, ChevronRight, Globe, Copy, Check } from 'lucide-react';
 import StepValidationResults from './StepValidationResults';
-import { stepOutcome, type StepOutcome } from '../validationStatus';
+import { outcomePreset } from '../validationStatus';
 import type { StepValidationResponse } from '../../../shared/types/roadmap';
 import type { useLearningPlayer } from '../hooks/useLearningPlayer';
 import type { ContainerData } from '../../../shared/types';
@@ -51,18 +51,12 @@ function CodeBlock({ code }: { code: string }) {
   );
 }
 
-const MARKER_PRESETS: Record<StepOutcome, { icon: typeof CheckCircle2; color: string; labelKey: string }> = {
-  passed: { icon: CheckCircle2, color: 'var(--color-success)', labelKey: 'learning.player.markerPassed' },
-  failed: { icon: XCircle, color: 'var(--color-danger)', labelKey: 'learning.player.markerFailed' },
-  error: { icon: AlertTriangle, color: 'var(--color-warning)', labelKey: 'learning.player.markerError' },
-};
-
 function StepMarker({ response }: { response: StepValidationResponse }) {
   const { t } = useTranslation();
-  const { icon: Icon, color, labelKey } = MARKER_PRESETS[stepOutcome(response)];
+  const { icon: Icon, color, labelKey } = outcomePreset(response);
   return (
-    <span style={styles.stepMarker} title={t(labelKey)} aria-label={t(labelKey)}>
-      <Icon size={13} color={color} />
+    <span style={styles.stepMarker} title={t(labelKey)}>
+      <Icon size={13} color={color} role="img" aria-label={t(labelKey)} />
     </span>
   );
 }
diff --git a/frontend/src/features/learning/components/StepValidationResults.test.tsx b/frontend/src/features/learning/components/StepValidationResults.test.tsx
index cc3600b..6452666 100644
--- a/frontend/src/features/learning/components/StepValidationResults.test.tsx
+++ b/frontend/src/features/learning/components/StepValidationResults.test.tsx
@@ -103,7 +103,7 @@ describe('StepValidationResults', () => {
     );
 
     expect(
-      screen.getByText("Docker isn't running, so the checks couldn't run. Start Docker and validate again.")
+      screen.getByText("Docker wasn't running, so the checks couldn't run. Start Docker and validate again.")
     ).toBeInTheDocument();
   });
 });
diff --git a/frontend/src/features/learning/components/StepValidationResults.tsx b/frontend/src/features/learning/components/StepValidationResults.tsx
index cdcb0e0..f972b3e 100644
--- a/frontend/src/features/learning/components/StepValidationResults.tsx
+++ b/frontend/src/features/learning/components/StepValidationResults.tsx
@@ -1,7 +1,7 @@
 import { useTranslation } from 'react-i18next';
 import { AlertTriangle, CheckCircle2, XCircle } from 'lucide-react';
-import { stepOutcome, STATUS_COLORS } from '../validationStatus';
-import type { StepValidationResponse, ValidatorResult, ValidatorStatus } from '../../../shared/types/roadmap';
+import { stepOutcome, isDockerUnavailable, STATUS_PRESETS } from '../validationStatus';
+import type { StepValidationResponse, ValidatorResult } from '../../../shared/types/roadmap';
 
 interface StepValidationResultsProps {
   response: StepValidationResponse;
@@ -9,18 +9,6 @@ interface StepValidationResultsProps {
   onNextStep: () => void;
 }
 
-const STATUS_ICONS: Record<ValidatorStatus, typeof CheckCircle2> = {
-  pass: CheckCircle2,
-  fail: XCircle,
-  error: AlertTriangle,
-};
-
-const MARKER_LABEL_KEYS: Record<ValidatorStatus, string> = {
-  pass: 'learning.player.markerPassed',
-  fail: 'learning.player.markerFailed',
-  error: 'learning.player.markerError',
-};
-
 export default function StepValidationResults({
   response,
   isLastStep,
@@ -59,7 +47,7 @@ function OutcomeBanner({ response, isLastStep, onNextStep }: StepValidationResul
   }
 
   if (outcome === 'error') {
-    const dockerDown = response.results.some(result => result.errorCode === 'DOCKER_UNAVAILABLE');
+    const dockerDown = isDockerUnavailable(response);
     return (
       <div style={{ ...styles.banner, ...styles.bannerError }}>
         <div style={styles.bannerHeader}>
@@ -84,25 +72,24 @@ function OutcomeBanner({ response, isLastStep, onNextStep }: StepValidationResul
 
 function ValidatorResultCard({ result }: { result: ValidatorResult }) {
   const { t } = useTranslation();
-  const Icon = STATUS_ICONS[result.status];
-  const color = STATUS_COLORS[result.status];
+  const { icon: Icon, color, labelKey } = STATUS_PRESETS[result.status];
 
   return (
     <div style={{ ...styles.card, borderLeft: `3px solid ${color}` }}>
       <div style={styles.cardHeader}>
-        <Icon size={14} color={color} style={styles.cardIcon} aria-label={t(MARKER_LABEL_KEYS[result.status])} />
+        <Icon size={14} color={color} style={styles.cardIcon} role="img" aria-label={t(labelKey)} />
         <span style={styles.cardMessage}>{result.message}</span>
       </div>
       {result.status === 'error' && (
         <span style={styles.checkNotRun}>{t('learning.player.checkNotRun')}</span>
       )}
-      {result.expected && (
+      {result.expected != null && (
         <div style={styles.detailLine}>
           <span style={styles.detailLabel}>{t('learning.player.expected')}:</span>{' '}
           <span style={styles.detailValue}>{result.expected}</span>
         </div>
       )}
-      {result.observed && (
+      {result.observed != null && (
         <div style={styles.detailLine}>
           <span style={styles.detailLabel}>{t('learning.player.observed')}:</span>{' '}
           <span style={styles.detailValue}>{result.observed}</span>
diff --git a/frontend/src/features/learning/validationStatus.ts b/frontend/src/features/learning/validationStatus.ts
index f7b9a6a..e00db7b 100644
--- a/frontend/src/features/learning/validationStatus.ts
+++ b/frontend/src/features/learning/validationStatus.ts
@@ -1,3 +1,4 @@
+import { AlertTriangle, CheckCircle2, XCircle } from 'lucide-react';
 import type { StepValidationResponse, ValidatorStatus } from '../../shared/types/roadmap';
 
 export type StepOutcome = 'passed' | 'failed' | 'error';
@@ -15,8 +16,30 @@ export function stepOutcome(response: StepValidationResponse): StepOutcome {
   return 'failed';
 }
 
-export const STATUS_COLORS: Record<ValidatorStatus, string> = {
-  pass: 'var(--color-success)',
-  fail: 'var(--color-danger)',
-  error: 'var(--color-warning)',
+// Matches the backend DockerErrorCode emitted when the daemon is unreachable
+// (backend infrastructure/docker/dockerErrors.ts).
+const DOCKER_UNAVAILABLE = 'DOCKER_UNAVAILABLE';
+
+/** True when a check of this attempt could not run because the Docker daemon was unreachable. */
+export function isDockerUnavailable(response: StepValidationResponse): boolean {
+  return response.results.some(result => result.errorCode === DOCKER_UNAVAILABLE);
+}
+
+interface StatusPreset {
+  icon: typeof CheckCircle2;
+  color: string;
+  labelKey: string;
+}
+
+/** Single source of each status's visual identity (icon, color, i18n label). */
+export const STATUS_PRESETS: Record<ValidatorStatus, StatusPreset> = {
+  pass: { icon: CheckCircle2, color: 'var(--color-success)', labelKey: 'learning.player.markerPassed' },
+  fail: { icon: XCircle, color: 'var(--color-danger)', labelKey: 'learning.player.markerFailed' },
+  error: { icon: AlertTriangle, color: 'var(--color-warning)', labelKey: 'learning.player.markerError' },
 };
+
+/** Preset for a step's aggregate outcome — the step-list marker shows one glyph per step. */
+export function outcomePreset(response: StepValidationResponse): StatusPreset {
+  const status: Record<StepOutcome, ValidatorStatus> = { passed: 'pass', failed: 'fail', error: 'error' };
+  return STATUS_PRESETS[status[stepOutcome(response)]];
+}
diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json
index c2b8555..aa17e52 100644
--- a/frontend/src/locales/en.json
+++ b/frontend/src/locales/en.json
@@ -40,7 +40,7 @@
       "expected": "Expected",
       "observed": "Observed",
       "stepError": "Some checks couldn't run — that's on us, not you. Fix the issue below or just try again.",
-      "stepErrorDocker": "Docker isn't running, so the checks couldn't run. Start Docker and validate again.",
+      "stepErrorDocker": "Docker wasn't running, so the checks couldn't run. Start Docker and validate again.",
       "nextStep": "Next step",
       "roadmapComplete": "Step passed — that was the last one. Roadmap complete!",
       "checkNotRun": "Check couldn't run",
diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json
index 0825d05..800ea65 100644
--- a/frontend/src/locales/fr.json
+++ b/frontend/src/locales/fr.json
@@ -40,7 +40,7 @@
       "expected": "Attendu",
       "observed": "Observé",
       "stepError": "Certaines vérifications n'ont pas pu s'exécuter — ce n'est pas de votre faute. Corrigez le problème ci-dessous ou réessayez simplement.",
-      "stepErrorDocker": "Docker n'est pas démarré, les vérifications n'ont pas pu s'exécuter. Démarrez Docker puis validez à nouveau.",
+      "stepErrorDocker": "Docker n'était pas démarré, les vérifications n'ont pas pu s'exécuter. Démarrez Docker puis validez à nouveau.",
       "nextStep": "Étape suivante",
       "roadmapComplete": "Étape validée — c'était la dernière. Roadmap terminée !",
       "checkNotRun": "Vérification impossible",
diff --git a/frontend/src/shared/types/roadmap.ts b/frontend/src/shared/types/roadmap.ts
index 9e1a09a..bcfa108 100644
--- a/frontend/src/shared/types/roadmap.ts
+++ b/frontend/src/shared/types/roadmap.ts
@@ -76,7 +76,11 @@ export interface ValidatorResult {
   type: string;
   status: ValidatorStatus;
   message: string;
-  /** Set iff status is 'error'. Wider than the backend union: the frontend only displays it. */
+  /**
+   * Set iff status is 'error'. Wider than the backend union: the frontend matches
+   * the codes it knows (see features/learning/validationStatus.ts) and treats the
+   * rest generically, so new backend codes never break rendering.
+   */
   errorCode?: string;
   expected?: string;
   observed?: string;
