diff --git a/backend/src/modules/learning/controllers/learningController.test.ts b/backend/src/modules/learning/controllers/learningController.test.ts index 0555b68..4c5a051 100644 --- a/backend/src/modules/learning/controllers/learningController.test.ts +++ b/backend/src/modules/learning/controllers/learningController.test.ts @@ -210,6 +210,31 @@ describe('LearningController', () => { }); }); + describe('GET /api/learning/progress', () => { + it('returns every play-through summary wrapped in entries', async () => { + const summaries = [ + { projectId: 'project-1', roadmapId: 'roadmap-1', updatedAt: '2026-07-16T10:00:00.000Z', completedSteps: 3 }, + ]; + (ProgressService.listProgress as jest.Mock).mockReturnValue(summaries); + + const res = await request(app).get('/api/learning/progress'); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ entries: summaries }); + }); + + it('returns 500 when the service throws', async () => { + (ProgressService.listProgress as jest.Mock).mockImplementation(() => { + throw new Error('disk exploded'); + }); + + const res = await request(app).get('/api/learning/progress'); + + expect(res.status).toBe(500); + expect(res.body.error).toBe('disk exploded'); + }); + }); + describe('GET /api/learning/progress/:projectId/:roadmapId', () => { it('returns the stored progress for the pair', async () => { const progress = { diff --git a/backend/src/modules/learning/controllers/learningController.ts b/backend/src/modules/learning/controllers/learningController.ts index f10eec1..7aabf5d 100644 --- a/backend/src/modules/learning/controllers/learningController.ts +++ b/backend/src/modules/learning/controllers/learningController.ts @@ -115,6 +115,14 @@ export class LearningController { } } + public static async listProgress(req: Request, res: Response): Promise { + try { + res.json({ entries: ProgressService.listProgress() }); + } catch (err: unknown) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }); + } + } + public static async getProgress(req: Request, res: Response): Promise { try { res.json( diff --git a/backend/src/modules/learning/routes/learningRoutes.ts b/backend/src/modules/learning/routes/learningRoutes.ts index beca60f..fc74066 100644 --- a/backend/src/modules/learning/routes/learningRoutes.ts +++ b/backend/src/modules/learning/routes/learningRoutes.ts @@ -6,6 +6,7 @@ const router = Router(); router.get('/roadmaps', LearningController.listRoadmaps); router.get('/roadmaps/:id', LearningController.getRoadmap); router.post('/validate', LearningController.validate); +router.get('/progress', LearningController.listProgress); router.get('/progress/:projectId/:roadmapId', LearningController.getProgress); router.put('/progress/:projectId/:roadmapId/hints', LearningController.recordRevealedHints); router.delete('/progress/:projectId/:roadmapId', LearningController.resetProgress); diff --git a/backend/src/modules/learning/services/__fixtures__/roadmaps/a-unlisted-id.json b/backend/src/modules/learning/services/__fixtures__/roadmaps/a-unlisted-id.json new file mode 100644 index 0000000..47d6ac3 --- /dev/null +++ b/backend/src/modules/learning/services/__fixtures__/roadmaps/a-unlisted-id.json @@ -0,0 +1,22 @@ +{ + "schemaVersion": 1, + "id": "zz-unlisted-roadmap", + "title": "Unlisted roadmap", + "description": "Sorts first by file name, last by id: proves the catalogue orders on ids.", + "language": "en", + "estimatedMinutes": 5, + "difficulty": "beginner", + "steps": [ + { + "id": "start-web", + "title": "Start the web server", + "instruction": "Create a node named `web` and start it.", + "validators": [ + { + "type": "container_running", + "params": { "node": "web" } + } + ] + } + ] +} diff --git a/backend/src/modules/learning/services/progressService.test.ts b/backend/src/modules/learning/services/progressService.test.ts index ac255e7..f941719 100644 --- a/backend/src/modules/learning/services/progressService.test.ts +++ b/backend/src/modules/learning/services/progressService.test.ts @@ -96,6 +96,38 @@ describe('ProgressService', () => { expect(ProgressService.getProgress('project-2', 'roadmap-1', file).steps).not.toEqual({}); }); + describe('listProgress', () => { + it('returns an empty list when no store exists', () => { + expect(ProgressService.listProgress(file)).toEqual([]); + expect(fs.existsSync(file)).toBe(false); + }); + + it('summarizes every entry with its count of passed steps', () => { + ProgressService.recordValidation('project-1', 'roadmap-1', 'step-a', true, '2026-07-16T10:00:00.000Z', file); + ProgressService.recordValidation('project-1', 'roadmap-1', 'step-b', false, '2026-07-16T10:01:00.000Z', file); + ProgressService.recordValidation('project-1', 'roadmap-1', 'step-c', true, '2026-07-16T10:02:00.000Z', file); + ProgressService.recordValidation('project-2', 'roadmap-2', 'step-a', false, '2026-07-16T10:03:00.000Z', file); + + const summaries = ProgressService.listProgress(file); + + expect(summaries).toHaveLength(2); + expect(summaries[0]).toMatchObject({ projectId: 'project-1', roadmapId: 'roadmap-1', completedSteps: 2 }); + expect(summaries[0].updatedAt).not.toBe(''); + expect(summaries[1]).toMatchObject({ projectId: 'project-2', roadmapId: 'roadmap-2', completedSteps: 0 }); + }); + + it('does not consume the one-shot storeRecovered flag reserved for getProgress', () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + fs.writeFileSync(file, 'not json at all {'); + + expect(ProgressService.listProgress(file)).toEqual([]); + + // The recovery notice must still reach the player's next per-pair read. + expect(ProgressService.getProgress('project-1', 'roadmap-1', file).storeRecovered).toBe(true); + errorSpy.mockRestore(); + }); + }); + describe('unreadable store recovery', () => { let errorSpy: jest.SpyInstance; diff --git a/backend/src/modules/learning/services/progressService.ts b/backend/src/modules/learning/services/progressService.ts index 19ba761..988227f 100644 --- a/backend/src/modules/learning/services/progressService.ts +++ b/backend/src/modules/learning/services/progressService.ts @@ -32,6 +32,15 @@ interface ProgressStore { entries: ProgressEntry[]; } +/** One item of GET /api/learning/progress — documented in docs/learning-api.md. */ +export interface ProgressEntrySummary { + projectId: string; + roadmapId: string; + updatedAt: string; + /** Count of steps whose latest validation passed. */ + completedSteps: number; +} + /** Contract of GET /api/learning/progress/:projectId/:roadmapId — documented in docs/learning-api.md. */ export interface RoadmapProgressResponse { projectId: string; @@ -83,6 +92,21 @@ export class ProgressService { return response; } + /** + * Summarizes every play-through in the store, for surfaces that show + * progress across projects (e.g. the landing page's roadmap cards). + * Deliberately leaves the one-shot `storeRecovered` flag untouched — that + * notice belongs to the player's getProgress, which can act on it. + */ + public static listProgress(filePath: string = PROGRESS_PATH): ProgressEntrySummary[] { + return this.readStore(filePath).entries.map(entry => ({ + projectId: entry.projectId, + roadmapId: entry.roadmapId, + updatedAt: entry.updatedAt, + completedSteps: Object.values(entry.steps).filter(step => step.passed).length, + })); + } + public static recordValidation( projectId: string, roadmapId: string, diff --git a/backend/src/modules/learning/services/roadmapService.test.ts b/backend/src/modules/learning/services/roadmapService.test.ts index d6e241c..3db5dcf 100644 --- a/backend/src/modules/learning/services/roadmapService.test.ts +++ b/backend/src/modules/learning/services/roadmapService.test.ts @@ -1,5 +1,5 @@ import path from 'path'; -import { RoadmapService } from './roadmapService'; +import { CURATED_ROADMAP_ORDER, RoadmapService } from './roadmapService'; const FIXTURES_DIR = path.resolve(__dirname, '__fixtures__/roadmaps'); @@ -34,9 +34,35 @@ describe('RoadmapService', () => { estimatedMinutes: 10, stepCount: 2, }, + { + id: 'zz-unlisted-roadmap', + title: 'Unlisted roadmap', + description: 'Sorts first by file name, last by id: proves the catalogue orders on ids.', + language: 'en', + difficulty: 'beginner', + estimatedMinutes: 5, + stepCount: 1, + }, ]); }); + it('orders roadmaps outside the curated list by id, not by file name', () => { + const ids = RoadmapService.listRoadmaps(FIXTURES_DIR).map(summary => summary.id); + + // a-unlisted-id.json holds `zz-unlisted-roadmap`: file name first, id last. + expect(ids).toEqual(['fixture-roadmap', 'fixture-roadmap', 'zz-unlisted-roadmap']); + }); + + it('opens the shipped catalogue on the curated order — the first entry is what a first-run user is pitched', () => { + const summaries = RoadmapService.listRoadmaps(); + const curated = summaries.map(s => s.id).filter(id => CURATED_ROADMAP_ORDER.includes(id)); + // Translations share an id and sit next to each other: collapse the runs. + const distinct = curated.filter((id, index) => id !== curated[index - 1]); + + expect(summaries[0].id).toBe(CURATED_ROADMAP_ORDER[0]); + expect(distinct).toEqual(CURATED_ROADMAP_ORDER.filter(id => curated.includes(id))); + }); + it('skips invalid roadmap files and warns with the file name', () => { RoadmapService.listRoadmaps(FIXTURES_DIR); diff --git a/backend/src/modules/learning/services/roadmapService.ts b/backend/src/modules/learning/services/roadmapService.ts index 684940b..babd741 100644 --- a/backend/src/modules/learning/services/roadmapService.ts +++ b/backend/src/modules/learning/services/roadmapService.ts @@ -19,6 +19,22 @@ export interface RoadmapSummary { // services/ → learning → modules → src|dist → backend → repo root. const ROADMAPS_DIR = path.resolve(__dirname, '../../../../../roadmaps'); +/** + * The catalogue is a suggested path, not a directory listing: this is the order + * the shipped roadmaps are meant to be taken in, and its first entry is the one + * a first-run user is pitched on the landing page. Without it the order would + * fall back to file names — alphabetical, which pitches whichever roadmap + * happens to sort first. + * + * Roadmaps that are not listed here (community files dropped into `roadmaps/`) + * follow, ordered by id. Ids that no longer exist are simply ignored. + */ +export const CURATED_ROADMAP_ORDER = [ + 'resilient-three-tier', + 'cache-aside-redis', + 'redis-queue-workers', +]; + /** * Loads roadmap files (format v1) from the roadmaps/ directory. * @@ -29,15 +45,25 @@ const ROADMAPS_DIR = path.resolve(__dirname, '../../../../../roadmaps'); */ export class RoadmapService { public static listRoadmaps(dir: string = ROADMAPS_DIR): RoadmapSummary[] { - return this.readAll(dir).map(roadmap => ({ - id: roadmap.id, - title: roadmap.title, - description: roadmap.description, - language: roadmap.language, - difficulty: roadmap.difficulty, - estimatedMinutes: roadmap.estimatedMinutes, - stepCount: roadmap.steps.length, - })); + return this.readAll(dir) + .map(roadmap => ({ + id: roadmap.id, + title: roadmap.title, + description: roadmap.description, + language: roadmap.language, + difficulty: roadmap.difficulty, + estimatedMinutes: roadmap.estimatedMinutes, + stepCount: roadmap.steps.length, + })) + // Curated order first, then unlisted ids by id. Translations of one id + // compare equal and keep the file-name order readAll() guarantees, so + // the whole list stays deterministic. + .sort((a, b) => this.catalogueRank(a.id) - this.catalogueRank(b.id) || a.id.localeCompare(b.id)); + } + + private static catalogueRank(id: string): number { + const index = CURATED_ROADMAP_ORDER.indexOf(id); + return index === -1 ? CURATED_ROADMAP_ORDER.length : index; } /** diff --git a/docs/learning-api.md b/docs/learning-api.md index 48dd18f..7e5d07a 100644 --- a/docs/learning-api.md +++ b/docs/learning-api.md @@ -13,6 +13,8 @@ This document is the contract for API consumers (the roadmap player in the front Lists the available roadmaps as summaries — **one entry per file**. Translations of the same roadmap share an `id` and differ by `language` (see the format's language model), so they appear as separate catalogue entries; a selection UI should surface the `language` field. +The list is a **suggested path, not a directory listing**: the roadmaps shipped with Torollo come first, in the order they are meant to be taken (`CURATED_ROADMAP_ORDER` in `roadmapService.ts`), and the rest — roadmaps you dropped into `roadmaps/` yourself — follow, ordered by `id`. Clients can rely on that order: the app pitches the first entry to a first-run user. + ```json [ { @@ -113,6 +115,10 @@ Roadmap progression is persisted locally in `~/.torollo/progress.json`, next to `passed` is the verdict of the **latest** validation (same semantics as the player's in-session display); `attempts` counts the validation runs that reached evaluation; `revealedHints` is the absolute number of revealed rungs on the step's hint ladder `[...hints, solution?]`. Validator results are deliberately **not** persisted — they describe a past container state; only the verdict survives. The top-level `version` is the migration contract: a reader that finds an unknown version (or an unparseable file) must not guess — the server moves the file aside as `progress.json.corrupt`, starts fresh, and reports it once via `storeRecovered` on the next progress read so the UI can tell the user. Writes are write-then-rename, so a crash mid-write cannot truncate the store. Deleting a project deletes its progress entries. +### `GET /api/learning/progress` + +Returns `{ "entries": [ { "projectId", "roadmapId", "updatedAt", "completedSteps" } ] }` — one summary per `(projectId, roadmapId)` play-through in the store, where `completedSteps` counts the steps whose latest validation passed. Used by surfaces that show progress across projects (e.g. the landing page's roadmap cards, which keep the most recent entry per roadmap). This endpoint never emits `storeRecovered` — that one-shot notice is reserved for the per-pair read below. + ### `GET /api/learning/progress/:projectId/:roadmapId` Returns `{ projectId, roadmapId, steps }` — `steps` is the per-step record above, `{}` when nothing was ever recorded. `storeRecovered: true` is present once after a corrupt/unknown-version store was discarded. The player calls this when opening a roadmap and resumes on the first step whose `passed` is not true. diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 84d16c0..1d1663e 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import CanvasPage from '../pages/CanvasPage/CanvasPage'; import TerminalModal from '../features/terminal/components/TerminalModal'; import ProjectsPage from '../pages/ProjectsPage/ProjectsPage'; -import type { ProjectInfo, TerminalInfo } from '../shared/types'; +import type { LearningIntent, ProjectInfo, TerminalInfo } from '../shared/types'; function App() { const [activeProject, setActiveProject] = useState(() => { @@ -14,6 +14,9 @@ function App() { } }); const [activeTerminal, setActiveTerminal] = useState(null); + // Session-only, deliberately not persisted: a reload must never replay + // an "open the learning panel" intent from a past navigation. + const [learningIntent, setLearningIntent] = useState(null); const handleSelectProject = (project: ProjectInfo | null) => { setActiveProject(project); @@ -27,18 +30,23 @@ function App() { return (
{!activeProject ? ( - handleSelectProject({ id, name })} + { + setLearningIntent(intent ?? null); + handleSelectProject({ id, name }); + }} /> ) : ( - setLearningIntent(null)} onBackToProjects={() => { handleSelectProject(null); setActiveTerminal(null); }} - onTerminalOpen={(id, name) => setActiveTerminal({ id, name })} + onTerminalOpen={(id, name) => setActiveTerminal({ id, name })} /> )} diff --git a/frontend/src/features/learning/components/DifficultyChip.tsx b/frontend/src/features/learning/components/DifficultyChip.tsx new file mode 100644 index 0000000..76e59e0 --- /dev/null +++ b/frontend/src/features/learning/components/DifficultyChip.tsx @@ -0,0 +1,41 @@ +import { useTranslation } from 'react-i18next'; +import type { RoadmapDifficulty } from '../../../shared/types/roadmap'; + +interface DifficultyChipProps { + difficulty: RoadmapDifficulty; +} + +const DIFFICULTY_COLORS: Record = { + beginner: 'var(--color-success)', + intermediate: 'var(--color-warning)', + advanced: 'var(--color-danger)', +}; + +/** Small colored pill for a roadmap's difficulty — green/amber/red semantics. */ +export default function DifficultyChip({ difficulty }: DifficultyChipProps) { + const { t } = useTranslation(); + const color = DIFFICULTY_COLORS[difficulty]; + return ( + + {t(`learning.catalog.difficulty.${difficulty}`)} + + ); +} + +const styles: Record = { + chip: { + display: 'inline-flex', + alignItems: 'center', + padding: '2px var(--space-2)', + borderRadius: 'var(--radius-sm)', + fontSize: 'var(--text-xs)', + fontWeight: 600, + whiteSpace: 'nowrap', + }, +}; diff --git a/frontend/src/features/learning/components/LearningPanel.test.tsx b/frontend/src/features/learning/components/LearningPanel.test.tsx index d651026..69b3891 100644 --- a/frontend/src/features/learning/components/LearningPanel.test.tsx +++ b/frontend/src/features/learning/components/LearningPanel.test.tsx @@ -119,8 +119,7 @@ function buildFetchMock(handlers: { async function openRoadmapFromCatalog() { fireEvent.click(await screen.findByText('Your first architecture')); - // The current step's title appears twice: in the step list and in the detail block. - await screen.findAllByText('Create the web server'); + await screen.findByText('Create the web server'); } describe('LearningPanel', () => { @@ -143,6 +142,33 @@ describe('LearningPanel', () => { expect(screen.getByText('Build a minimal two-tier architecture.')).toBeInTheDocument(); }); + it('opens directly on the roadmap named by initialRoadmap', async () => { + const fetchMock = buildFetchMock({}); + vi.stubGlobal('fetch', fetchMock); + render( + {}} + /> + ); + + await screen.findByText('Create the web server'); + const urls = fetchMock.mock.calls.map(call => String(call[0])); + expect(urls.some(url => url.includes(`/api/learning/roadmaps/${roadmap.id}?language=en`))).toBe(true); + expect(urls.some(url => url.includes(`/api/learning/progress/p1/${roadmap.id}`))).toBe(true); + }); + + it('stays on the catalogue when no initialRoadmap is given', async () => { + const fetchMock = buildFetchMock({}); + vi.stubGlobal('fetch', fetchMock); + render( {}} />); + + expect(await screen.findByText('Your first architecture')).toBeInTheDocument(); + const urls = fetchMock.mock.calls.map(call => String(call[0])); + expect(urls.some(url => url.includes('/api/learning/roadmaps/'))).toBe(false); + }); + it('shows a retry path when the catalogue cannot be loaded', async () => { const fetchMock = buildFetchMock({}); fetchMock.mockRejectedValueOnce(new Error('network down')); @@ -154,25 +180,27 @@ describe('LearningPanel', () => { expect(await screen.findByText('Your first architecture')).toBeInTheDocument(); }); - it('opens a roadmap: all steps listed, current step highlighted, instruction shown', async () => { + it('opens a roadmap: only the current step is shown, with progress bar and instruction', async () => { vi.stubGlobal('fetch', buildFetchMock({})); render( {}} />); await openRoadmapFromCatalog(); - expect(screen.getByText('Add the database')).toBeInTheDocument(); + // Focus mode: other steps stay hidden until the learner reaches them. + expect(screen.queryByText('Add the database')).not.toBeInTheDocument(); expect(screen.getByText('Step 1 of 2')).toBeInTheDocument(); + expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '0'); expect( screen.getByText((_, el) => el?.tagName === 'P' && el.textContent === 'Drag an Ubuntu node named web onto the canvas and start it.') ).toBeInTheDocument(); }); - it('navigates between steps with next/previous', async () => { + it('navigates between steps with skip/previous', async () => { vi.stubGlobal('fetch', buildFetchMock({})); render( {}} />); await openRoadmapFromCatalog(); - fireEvent.click(screen.getByRole('button', { name: 'Next' })); + fireEvent.click(screen.getByRole('button', { name: 'Skip step' })); expect(screen.getByText('Step 2 of 2')).toBeInTheDocument(); expect( screen.getByText((_, el) => el?.tagName === 'P' && el.textContent === 'Add a Postgres node named db.') @@ -189,13 +217,12 @@ describe('LearningPanel', () => { fireEvent.click(screen.getByRole('button', { name: 'Validate' })); - expect(await screen.findByText('Not yet — see the results below')).toBeInTheDocument(); + expect(await screen.findByText('Not yet')).toBeInTheDocument(); expect( 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 status/type strings are gone. - expect(screen.getByTitle('Failed')).toBeInTheDocument(); + // The toast stays terse: no expected/observed dump, no raw status/type strings. + expect(screen.queryByText(/a running container named "web"/)).not.toBeInTheDocument(); expect(screen.queryByText('[fail]')).not.toBeInTheDocument(); expect(screen.queryByText('container_running')).not.toBeInTheDocument(); }); @@ -216,28 +243,40 @@ describe('LearningPanel', () => { await openRoadmapFromCatalog(); fireEvent.click(screen.getByRole('button', { name: 'Validate' })); - expect(await screen.findByText('Not yet — see the results below')).toBeInTheDocument(); + expect(await screen.findByText('Not yet')).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(); + expect(await screen.findByText('Validation passed')).toBeInTheDocument(); + expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '1'); // No artifacts from the failed attempt survive. - expect(screen.queryByText('Not yet — see the results below')).not.toBeInTheDocument(); + expect(screen.queryByText('Not yet')).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 () => { + it('keeps navigation in the sidebar: the toast offers no Next step button', async () => { vi.stubGlobal('fetch', buildFetchMock({ validate: () => jsonResponse(true, passResponse) })); render( {}} />); await openRoadmapFromCatalog(); fireEvent.click(screen.getByRole('button', { name: 'Validate' })); - fireEvent.click(await screen.findByRole('button', { name: 'Next step' })); + await screen.findByText('Validation passed'); - expect(screen.getByText('Step 2 of 2')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Next step' })).not.toBeInTheDocument(); + }); + + it('closes the validation toast on dismiss and shows it again on the next attempt', async () => { + vi.stubGlobal('fetch', buildFetchMock({ validate: () => jsonResponse(true, passResponse) })); + render( {}} />); + await openRoadmapFromCatalog(); + + fireEvent.click(screen.getByRole('button', { name: 'Validate' })); + fireEvent.click(await screen.findByRole('button', { name: 'Dismiss' })); + expect(screen.queryByText('Validation passed')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Validate' })); + expect(await screen.findByText('Validation passed')).toBeInTheDocument(); }); it('shows an understandable error with retry when the backend is unreachable during validation', async () => { @@ -261,10 +300,10 @@ describe('LearningPanel', () => { validateFails = false; fireEvent.click(screen.getByRole('button', { name: 'Retry' })); - expect(await screen.findByText('Not yet — see the results below')).toBeInTheDocument(); + expect(await screen.findByText('Not yet')).toBeInTheDocument(); }); - it('restores persisted progress: reopens on the first incomplete step with ✓ markers', async () => { + it('restores persisted progress: reopens on the first incomplete step with the bar filled', async () => { vi.stubGlobal( 'fetch', buildFetchMock({ @@ -280,9 +319,9 @@ describe('LearningPanel', () => { fireEvent.click(await screen.findByText('Your first architecture')); expect(await screen.findByText('Step 2 of 2')).toBeInTheDocument(); - expect(screen.getByTitle('Passed')).toBeInTheDocument(); + expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '1'); // Only the verdict is restored — no stale validator results are replayed. - expect(screen.queryByText('Step passed')).not.toBeInTheDocument(); + expect(screen.queryByText('Validation passed')).not.toBeInTheDocument(); }); it('restarts the roadmap behind a two-click confirmation', async () => { @@ -307,7 +346,7 @@ describe('LearningPanel', () => { fireEvent.click(screen.getByRole('button', { name: 'Sure? Click again to restart' })); expect(await screen.findByText('Step 1 of 2')).toBeInTheDocument(); - expect(screen.queryByTitle('Passed')).not.toBeInTheDocument(); + expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '0'); }); it('tells the user when an unreadable progress store was reset, dismissibly', async () => { diff --git a/frontend/src/features/learning/components/LearningPanel.tsx b/frontend/src/features/learning/components/LearningPanel.tsx index 3bbc4fc..cf7aa18 100644 --- a/frontend/src/features/learning/components/LearningPanel.tsx +++ b/frontend/src/features/learning/components/LearningPanel.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { GraduationCap, X } from 'lucide-react'; import { useLearningPlayer } from '../hooks/useLearningPlayer'; @@ -8,6 +9,8 @@ import type { NetworkConfig } from '../../../shared/types/network'; interface LearningPanelProps { projectId: string; + /** Deep-link from the landing page: open directly on this roadmap. One-shot, consumed on mount. */ + initialRoadmap?: { id: string; language: string } | null; onClose: () => void; containers?: ContainerData[]; networkConfig?: NetworkConfig; @@ -20,6 +23,7 @@ interface LearningPanelProps { */ export default function LearningPanel({ projectId, + initialRoadmap, onClose, containers = [], networkConfig = { @@ -32,6 +36,17 @@ export default function LearningPanel({ const { t } = useTranslation(); const player = useLearningPlayer({ projectId }); + // Arrival intent: open the requested roadmap once on mount. openRoadmap + // hydrates persisted progress and resumes at the first incomplete step. + const arrivalRoadmapRef = useRef(initialRoadmap); + const openRoadmapRef = useRef(player.openRoadmap); + openRoadmapRef.current = player.openRoadmap; + useEffect(() => { + if (arrivalRoadmapRef.current) { + openRoadmapRef.current(arrivalRoadmapRef.current); + } + }, []); + return (
diff --git a/frontend/src/features/learning/components/RoadmapCatalog.tsx b/frontend/src/features/learning/components/RoadmapCatalog.tsx index dbe644c..90c834a 100644 --- a/frontend/src/features/learning/components/RoadmapCatalog.tsx +++ b/frontend/src/features/learning/components/RoadmapCatalog.tsx @@ -1,6 +1,8 @@ import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useRoadmaps } from '../hooks/useRoadmaps'; +import { filterByUiLanguage } from '../roadmapLanguage'; +import DifficultyChip from './DifficultyChip'; import type { RoadmapSummary } from '../../../shared/types/roadmap'; interface RoadmapCatalogProps { @@ -15,14 +17,9 @@ export default function RoadmapCatalog({ onOpen }: RoadmapCatalogProps) { fetchRoadmaps(); }, [fetchRoadmaps]); - // Only surface roadmaps authored in the active UI language: an English user - // sees English roadmaps only. Compare on the base subtag so 'en-US' still - // matches an 'en' roadmap. Re-evaluated on every render, so toggling the - // language in the topbar re-filters the catalogue immediately. - const uiLanguage = i18n.language.split('-')[0]; - const visibleSummaries = summaries.filter( - summary => summary.language.split('-')[0] === uiLanguage - ); + // Re-evaluated on every render, so toggling the language in the topbar + // re-filters the catalogue immediately. + const visibleSummaries = filterByUiLanguage(summaries, i18n.language); if (loading) { return
{t('learning.catalog.loading')}
; @@ -53,12 +50,10 @@ export default function RoadmapCatalog({ onOpen }: RoadmapCatalogProps) { >
{summary.title} + {summary.difficulty && }
{summary.description}
- {summary.difficulty && ( - {t(`learning.catalog.difficulty.${summary.difficulty}`)} - )} {t('learning.catalog.steps', { count: summary.stepCount })} {summary.estimatedMinutes != null && ( {t('learning.catalog.minutes', { count: summary.estimatedMinutes })} diff --git a/frontend/src/features/learning/components/RoadmapPlayer.tsx b/frontend/src/features/learning/components/RoadmapPlayer.tsx index 7d35775..5cf9230 100644 --- a/frontend/src/features/learning/components/RoadmapPlayer.tsx +++ b/frontend/src/features/learning/components/RoadmapPlayer.tsx @@ -1,14 +1,14 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { AlertTriangle, ArrowLeft, ChevronLeft, ChevronRight, Globe, RotateCcw, X } from 'lucide-react'; -import StepValidationResults from './StepValidationResults'; +import ValidationToast from './ValidationToast'; import StepHints from './StepHints'; import { renderInstruction } from './InstructionMarkdown'; -import { outcomePreset, STATUS_PRESETS } from '../validationStatus'; -import type { StepValidationResponse } from '../../../shared/types/roadmap'; +import ProgressBar from '../../../shared/components/ProgressBar'; import type { useLearningPlayer } from '../hooks/useLearningPlayer'; import type { ContainerData } from '../../../shared/types'; import type { NetworkConfig } from '../../../shared/types/network'; +import type { StepValidationResponse } from '../../../shared/types/roadmap'; interface RoadmapPlayerProps { player: ReturnType; @@ -16,31 +16,6 @@ interface RoadmapPlayerProps { networkConfig: NetworkConfig; } -function StepMarker({ response }: { response: StepValidationResponse }) { - const { t } = useTranslation(); - const { icon: Icon, color, labelKey } = outcomePreset(response); - return ( - - - - ); -} - -/** - * ✓ for a step whose recorded validation passed in a previous session. Only - * the verdict is persisted — validator results describe a past container - * state — so there is no response object and no results card behind it. - */ -function RestoredStepMarker() { - const { t } = useTranslation(); - const { icon: Icon, color, labelKey } = STATUS_PRESETS.pass; - return ( - - - - ); -} - export default function RoadmapPlayer({ player, containers = [], @@ -76,6 +51,21 @@ export default function RoadmapPlayer({ // same light two-click brake as the solution reveal: first click arms a // confirmation label, second executes; leaving the button disarms. const [resetArmed, setResetArmed] = useState(false); + + // Which validation result the learner closed, per step. A new attempt + // clears the step's entry, so the toast reappears for every fresh verdict. + const [dismissedByStepId, setDismissedByStepId] = useState< + Record + >({}); + const handleValidate = () => { + setDismissedByStepId(prev => { + const next = { ...prev }; + delete next[currentStep!.id]; + return next; + }); + validateCurrentStep(); + }; + const handleReset = () => { if (!resetArmed) { setResetArmed(true); @@ -89,6 +79,11 @@ export default function RoadmapPlayer({ const atFirstStep = currentStepIndex === 0; const atLastStep = currentStepIndex === roadmap.steps.length - 1; + // Steps count as done when persisted progress says so or this session's + // validation passed — the bar tracks achievement, not navigation position. + const completedCount = roadmap.steps.filter( + step => completedStepIds[step.id] || resultsByStepId[step.id]?.stepPassed + ).length; return (
@@ -113,6 +108,23 @@ export default function RoadmapPlayer({
{roadmap.title} +
+ + + {t('learning.player.stepCounter', { + current: currentStepIndex + 1, + total: roadmap.steps.length, + })} + +
+ {resetError !== null && (
{resetError || t('learning.player.resetProgressError')} @@ -133,31 +145,6 @@ export default function RoadmapPlayer({
)} -
- {roadmap.steps.map((step, index) => { - const result = resultsByStepId[step.id]; - const isCurrent = index === currentStepIndex; - return ( - - ); - })} -
- {/* Determine if we should show a localhost link to the container. */} {(() => { let localhostLink: string | null = null; @@ -189,13 +176,10 @@ export default function RoadmapPlayer({ return (
- - {t('learning.player.stepCounter', { - current: currentStepIndex + 1, - total: roadmap.steps.length, - })} - - {currentStep.title} +
+ {currentStepIndex + 1} + {currentStep.title} +
{renderInstruction(currentStep.instruction)}
@@ -221,6 +205,14 @@ export default function RoadmapPlayer({ ); })()} + +
- - {validationError !== null && (
{validationError || t('learning.player.validationError')} -
)} - {resultsByStepId[currentStep.id] && ( - goToStep(currentStepIndex + 1)} - /> - )} + {resultsByStepId[currentStep.id] && + dismissedByStepId[currentStep.id] !== resultsByStepId[currentStep.id] && ( + + setDismissedByStepId(prev => ({ + ...prev, + [currentStep.id]: resultsByStepId[currentStep.id], + })) + } + /> + )}
); } @@ -339,42 +329,10 @@ const styles: Record = { color: 'var(--color-text-primary)', lineHeight: 1.4, }, - stepList: { + progressBlock: { display: 'flex', flexDirection: 'column', - gap: '4px', - }, - stepItem: { - display: 'flex', - alignItems: 'center', - gap: '6px', - padding: '7px 10px', - border: '1px solid transparent', - borderRadius: '6px', - background: 'none', - cursor: 'pointer', - textAlign: 'left', - fontFamily: 'var(--font-sans)', - color: 'var(--color-text-secondary)', - }, - stepItemCurrent: { - border: '1px solid var(--color-accent)', - backgroundColor: 'color-mix(in srgb, var(--color-accent) 6%, transparent)', - color: 'var(--color-text-primary)', - }, - stepIndex: { - fontSize: '11px', - fontWeight: 700, - color: 'var(--color-text-muted)', - }, - stepItemTitle: { - fontSize: '12px', - fontWeight: 500, - flex: 1, - }, - stepMarker: { - display: 'flex', - alignItems: 'center', + gap: '5px', }, currentStep: { display: 'flex', @@ -384,16 +342,36 @@ const styles: Record = { paddingTop: '12px', }, stepCounter: { + alignSelf: 'flex-end', fontSize: '10px', fontWeight: 700, color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.5px', }, + stepTitleRow: { + display: 'flex', + alignItems: 'flex-start', + gap: '8px', + }, + stepBadge: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '20px', + height: '20px', + flexShrink: 0, + border: '1px solid var(--border-color)', + borderRadius: '50%', + fontSize: '11px', + fontWeight: 700, + color: 'var(--color-text-primary)', + }, stepTitle: { fontSize: '13px', fontWeight: 600, color: 'var(--color-text-primary)', + lineHeight: '20px', }, instruction: { margin: 0, diff --git a/frontend/src/features/learning/components/StepHints.test.tsx b/frontend/src/features/learning/components/StepHints.test.tsx index d7f8d82..e26b2e1 100644 --- a/frontend/src/features/learning/components/StepHints.test.tsx +++ b/frontend/src/features/learning/components/StepHints.test.tsx @@ -68,7 +68,7 @@ describe('StepHints', () => { 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('solution')).toBeInTheDocument(); expect(screen.getByText('Only hint.')).toBeInTheDocument(); expect(screen.queryByRole('button')).not.toBeInTheDocument(); }); diff --git a/frontend/src/features/learning/components/StepHints.tsx b/frontend/src/features/learning/components/StepHints.tsx index 0a5ecb2..08d6dce 100644 --- a/frontend/src/features/learning/components/StepHints.tsx +++ b/frontend/src/features/learning/components/StepHints.tsx @@ -1,6 +1,5 @@ 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'; @@ -19,6 +18,11 @@ import type { RoadmapStep } from '../../../shared/types/roadmap'; * 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. + * + * Visually the ladder is marginalia, not a stack of alert cards: one hairline + * rule bounds the whole column, each rung carries a lowercase mono marker + * (`hint 1/3`, `solution`), and the reveal control is the last line of the + * same column. No icons, no fills — only the solution marker takes color. */ interface StepHintsProps { @@ -50,11 +54,12 @@ export default function StepHints({ step, revealedCount, onReveal }: StepHintsPr }; return ( -
+ // The rule marks revealed marginalia; before the first reveal the lone + // button stands unruled. +
0 ? styles.containerRuled : {}) }}> {hints.slice(0, revealed).map((hint, index) => ( -
- - +
+ {t('learning.player.hintLabel', { n: index + 1, total: hints.length })} {renderInstruction(hint)} @@ -62,9 +67,8 @@ export default function StepHints({ step, revealedCount, onReveal }: StepHintsPr ))} {solutionRevealed && ( -
- - +
+ {t('learning.player.solutionLabel')} {renderInstruction(step.solution!)} @@ -76,11 +80,6 @@ export default function StepHints({ step, revealedCount, onReveal }: StepHintsPr onClick={handleReveal} style={{ ...styles.revealBtn, ...(nextIsSolution ? styles.revealSolutionBtn : {}) }} > - {nextIsSolution ? ( - - ) : ( - - )} {nextIsSolution ? solutionArmed ? t('learning.player.confirmSolution') @@ -96,55 +95,41 @@ const styles: Record = { container: { display: 'flex', flexDirection: 'column', - gap: '8px', - marginTop: '10px', + gap: '12px', + marginTop: '12px', + }, + containerRuled: { + paddingLeft: '10px', + borderLeft: '2px solid var(--border-color)', }, - hintBox: { + rung: { 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)', + gap: '3px', }, - hintLabel: { - display: 'flex', - alignItems: 'center', + marker: { + fontFamily: 'var(--font-mono)', fontSize: '10px', - fontWeight: 700, color: 'var(--color-text-muted)', - textTransform: 'uppercase', - letterSpacing: '0.5px', }, - solutionLabel: { + solutionMarker: { 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', + padding: 0, + border: 'none', background: 'none', color: 'var(--color-text-secondary)', fontSize: '11px', fontWeight: 600, cursor: 'pointer', fontFamily: 'var(--font-sans)', + textDecoration: 'underline', + textDecorationStyle: 'dotted', + textUnderlineOffset: '3px', }, revealSolutionBtn: { - border: '1px dashed var(--color-warning)', color: 'var(--color-warning-strong)', }, }; diff --git a/frontend/src/features/learning/components/StepValidationResults.test.tsx b/frontend/src/features/learning/components/StepValidationResults.test.tsx deleted file mode 100644 index 6452666..0000000 --- a/frontend/src/features/learning/components/StepValidationResults.test.tsx +++ /dev/null @@ -1,109 +0,0 @@ -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(); - 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 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 deleted file mode 100644 index bc86251..0000000 --- a/frontend/src/features/learning/components/StepValidationResults.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { AlertTriangle, CheckCircle2, XCircle } from 'lucide-react'; -import { stepOutcome, isDockerUnavailable, STATUS_PRESETS } from '../validationStatus'; -import type { StepValidationResponse, ValidatorResult } from '../../../shared/types/roadmap'; - -interface StepValidationResultsProps { - response: StepValidationResponse; - isLastStep: boolean; - onNextStep: () => void; -} - -export default function StepValidationResults({ - response, - isLastStep, - onNextStep, -}: StepValidationResultsProps) { - return ( -
- - {response.results.map(result => ( - - ))} -
- ); -} - -function OutcomeBanner({ response, isLastStep, onNextStep }: StepValidationResultsProps) { - const { t } = useTranslation(); - const outcome = stepOutcome(response); - - if (outcome === 'passed') { - return ( -
-
- - - {isLastStep ? t('learning.player.roadmapComplete') : t('learning.player.stepPassed')} - -
- {!isLastStep && ( - - )} -
- ); - } - - if (outcome === 'error') { - const dockerDown = isDockerUnavailable(response); - return ( -
-
- - - {dockerDown ? t('learning.player.stepErrorDocker') : t('learning.player.stepError')} - -
-
- ); - } - - return ( -
-
- - {t('learning.player.stepFailed')} -
-
- ); -} - -function ValidatorResultCard({ result }: { result: ValidatorResult }) { - const { t } = useTranslation(); - const { icon: Icon, color, labelKey } = STATUS_PRESETS[result.status]; - - return ( -
-
- - {result.message} -
- {result.status === 'error' && ( - {t('learning.player.checkNotRun')} - )} - {result.expected != null && ( -
- {t('learning.player.expected')}:{' '} - {result.expected} -
- )} - {result.observed != null && ( -
- {t('learning.player.observed')}:{' '} - {result.observed} -
- )} -
- ); -} - -const styles: Record = { - container: { - display: 'flex', - flexDirection: 'column', - gap: '8px', - }, - banner: { - 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: 'var(--color-white)', - fontSize: '12px', - fontWeight: 600, - cursor: 'pointer', - fontFamily: 'var(--font-sans)', - }, - card: { - border: '1px solid var(--border-color)', - borderRadius: '6px', - padding: '8px 10px', - display: 'flex', - flexDirection: 'column', - gap: '4px', - }, - cardHeader: { - display: 'flex', - alignItems: 'flex-start', - gap: '6px', - }, - cardIcon: { - flexShrink: 0, - marginTop: '2px', - }, - 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)', - lineHeight: 1.4, - }, - detailLabel: { - fontWeight: 600, - color: 'var(--color-text-muted)', - }, - detailValue: { - fontFamily: 'var(--font-mono)', - }, -}; diff --git a/frontend/src/features/learning/components/ValidationToast.test.tsx b/frontend/src/features/learning/components/ValidationToast.test.tsx new file mode 100644 index 0000000..38ef9b5 --- /dev/null +++ b/frontend/src/features/learning/components/ValidationToast.test.tsx @@ -0,0 +1,133 @@ +import '../../../i18n'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import ValidationToast from './ValidationToast'; +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 renderToast( + response: StepValidationResponse, + { isLastStep = false, onDismiss = vi.fn() } = {} +) { + render(); + return { onDismiss }; +} + +describe('ValidationToast', () => { + it('shows the passed verdict with the first check message', () => { + renderToast(buildResponse([passResult])); + + expect(screen.getByText('Validation passed')).toBeInTheDocument(); + expect(screen.getByText('The container "web" is running.')).toBeInTheDocument(); + }); + + it('celebrates the roadmap on the last step', () => { + renderToast(buildResponse([passResult]), { isLastStep: true }); + + expect( + screen.getByText('Validation passed — that was the last step. Roadmap complete!') + ).toBeInTheDocument(); + }); + + it('shows a failure as the verdict plus the first failing message only', () => { + renderToast(buildResponse([failResult])); + + expect(screen.getByText('Not yet')).toBeInTheDocument(); + expect( + screen.getByText('No container named "web" exists in this project yet.') + ).toBeInTheDocument(); + // Expected/observed details stay in the sidebar's pedagogy, not the toast. + expect(screen.queryByText(/a running container named/)).not.toBeInTheDocument(); + expect(screen.queryByText(/no container with that name/)).not.toBeInTheDocument(); + }); + + it('skips the messages of checks that passed when the step failed', () => { + renderToast(buildResponse([passResult, { ...failResult, index: 2 }])); + + expect(screen.queryByText('The container "web" is running.')).not.toBeInTheDocument(); + expect( + screen.getByText('No container named "web" exists in this project yet.') + ).toBeInTheDocument(); + }); + + it('counts additional failing checks instead of listing them', () => { + renderToast( + buildResponse([ + failResult, + { ...failResult, index: 1, message: 'The node is not named "db".' }, + { ...failResult, index: 2, message: 'The container is not running.' }, + ]) + ); + + expect( + screen.getByText('No container named "web" exists in this project yet.') + ).toBeInTheDocument(); + expect(screen.queryByText('The node is not named "db".')).not.toBeInTheDocument(); + expect(screen.getByText('+2 more checks to fix')).toBeInTheDocument(); + }); + + it('shows an infrastructure error distinctly, without the raw error code', () => { + renderToast(buildResponse([errorResult])); + + expect(screen.getByText("Checks couldn't run")).toBeInTheDocument(); + expect( + screen.getByText("Some checks couldn't run — that's on us, not you. Fix the issue or just try again.") + ).toBeInTheDocument(); + expect(screen.queryByText('DOCKER_ERROR')).not.toBeInTheDocument(); + }); + + it('lets an error win over a failure in the verdict', () => { + renderToast(buildResponse([failResult, errorResult])); + + expect(screen.getByText("Checks couldn't run")).toBeInTheDocument(); + expect(screen.queryByText('Not yet')).not.toBeInTheDocument(); + }); + + it('uses the Docker-specific wording when the daemon is unreachable', () => { + renderToast(buildResponse([{ ...errorResult, errorCode: 'DOCKER_UNAVAILABLE' }])); + + expect( + screen.getByText("Docker wasn't running, so the checks couldn't run. Start Docker and validate again.") + ).toBeInTheDocument(); + }); + + it('reports the dismissal through the close button', () => { + const { onDismiss } = renderToast(buildResponse([passResult])); + + fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })); + expect(onDismiss).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/src/features/learning/components/ValidationToast.tsx b/frontend/src/features/learning/components/ValidationToast.tsx new file mode 100644 index 0000000..f139ab1 --- /dev/null +++ b/frontend/src/features/learning/components/ValidationToast.tsx @@ -0,0 +1,146 @@ +import { createPortal } from 'react-dom'; +import { useTranslation } from 'react-i18next'; +import { AlertTriangle, Check, X } from 'lucide-react'; +import { stepOutcome, isDockerUnavailable } from '../validationStatus'; +import type { StepValidationResponse } from '../../../shared/types/roadmap'; + +interface ValidationToastProps { + response: StepValidationResponse; + isLastStep: boolean; + onDismiss: () => void; +} + +/** + * The verdict of a validation attempt, floated over the canvas rather than + * stacked in the sidebar: the learner's eyes are on their architecture when + * they hit Validate, so the answer appears where they are looking. Kept to + * one verdict and a single detail line — the full instruction, hints and + * navigation live in the sidebar, the toast only answers "did it work?". + */ +export default function ValidationToast({ + response, + isLastStep, + onDismiss, +}: ValidationToastProps) { + const { t } = useTranslation(); + const outcome = stepOutcome(response); + + const failedChecks = response.results.filter(result => result.status !== 'pass'); + + let disc: React.CSSProperties; + let DiscIcon: typeof Check; + let title: string; + let titleColor: string; + let detail: string | undefined; + let remainingChecks = 0; + + if (outcome === 'passed') { + disc = { backgroundColor: 'var(--color-success)' }; + DiscIcon = Check; + title = isLastStep ? t('learning.player.roadmapComplete') : t('learning.player.stepPassed'); + titleColor = 'var(--color-success)'; + detail = response.results[0]?.message; + } else if (outcome === 'error') { + disc = { backgroundColor: 'var(--color-warning)' }; + DiscIcon = AlertTriangle; + title = t('learning.player.checksBlocked'); + titleColor = 'var(--color-warning-strong)'; + detail = isDockerUnavailable(response) + ? t('learning.player.stepErrorDocker') + : t('learning.player.stepError'); + } else { + disc = { backgroundColor: 'var(--color-danger)' }; + DiscIcon = X; + title = t('learning.player.stepFailed'); + titleColor = 'var(--color-danger)'; + detail = failedChecks[0]?.message; + remainingChecks = failedChecks.length - 1; + } + + return createPortal( +
+ + + +
+ {title} + {detail && {detail}} + {remainingChecks > 0 && ( + + {t('learning.player.moreChecks', { count: remainingChecks })} + + )} +
+ +
, + document.body + ); +} + +const styles: Record = { + toast: { + position: 'fixed', + bottom: '64px', + left: '50%', + transform: 'translateX(-50%)', + zIndex: 60, + display: 'flex', + alignItems: 'flex-start', + gap: '10px', + minWidth: '340px', + maxWidth: '480px', + padding: '12px 14px', + backgroundColor: 'var(--bg-surface-solid)', + border: '1px solid var(--border-color)', + borderRadius: '10px', + boxShadow: '0 8px 24px rgba(15, 23, 42, 0.12)', + }, + disc: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '22px', + height: '22px', + borderRadius: '50%', + flexShrink: 0, + }, + body: { + display: 'flex', + flexDirection: 'column', + gap: '2px', + flex: 1, + minWidth: 0, + }, + title: { + fontSize: '13px', + fontWeight: 700, + lineHeight: '22px', + }, + detail: { + fontSize: '12px', + color: 'var(--color-text-secondary)', + lineHeight: 1.5, + }, + moreChecks: { + fontSize: '11px', + color: 'var(--color-text-muted)', + lineHeight: 1.5, + }, + dismissBtn: { + display: 'flex', + padding: '4px', + marginTop: '-2px', + marginRight: '-4px', + border: 'none', + background: 'none', + color: 'var(--color-text-muted)', + cursor: 'pointer', + flexShrink: 0, + }, +}; diff --git a/frontend/src/features/learning/hooks/useLearningProgressSummaries.test.ts b/frontend/src/features/learning/hooks/useLearningProgressSummaries.test.ts new file mode 100644 index 0000000..881179d --- /dev/null +++ b/frontend/src/features/learning/hooks/useLearningProgressSummaries.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useLearningProgressSummaries } from './useLearningProgressSummaries'; +import type { ProgressEntrySummary } from '../../../shared/types/roadmap'; + +function jsonResponse(ok: boolean, body: unknown): Response { + return { ok, json: () => Promise.resolve(body) } as Response; +} + +const entries: ProgressEntrySummary[] = [ + { projectId: 'p1', roadmapId: 'cache-aside-redis', updatedAt: '2026-07-20T10:00:00.000Z', completedSteps: 3 }, + { projectId: 'p2', roadmapId: 'cache-aside-redis', updatedAt: '2026-07-21T10:00:00.000Z', completedSteps: 1 }, + { projectId: 'p1', roadmapId: 'resilient-three-tier', updatedAt: '2026-07-19T10:00:00.000Z', completedSteps: 5 }, +]; + +describe('useLearningProgressSummaries', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('keeps only the most recent entry per roadmap', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(true, { entries })); + + const { result } = renderHook(() => useLearningProgressSummaries()); + await act(async () => { + await result.current.fetchProgress(); + }); + + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/learning/progress')); + expect(result.current.byRoadmapId['cache-aside-redis']).toMatchObject({ + projectId: 'p2', + completedSteps: 1, + }); + expect(result.current.byRoadmapId['resilient-three-tier']).toMatchObject({ + projectId: 'p1', + completedSteps: 5, + }); + expect(result.current.error).toBe(false); + }); + + it('flags an error on an unexpected response shape', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(true, { unexpected: 'shape' })); + + const { result } = renderHook(() => useLearningProgressSummaries()); + await act(async () => { + await result.current.fetchProgress(); + }); + + expect(result.current.byRoadmapId).toEqual({}); + expect(result.current.error).toBe(true); + }); + + it('flags an error when the backend is unreachable, and retry clears it', async () => { + fetchMock + .mockRejectedValueOnce(new Error('network down')) + .mockResolvedValueOnce(jsonResponse(true, { entries: [] })); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { result } = renderHook(() => useLearningProgressSummaries()); + await act(async () => { + await result.current.fetchProgress(); + }); + expect(result.current.error).toBe(true); + + await act(async () => { + await result.current.fetchProgress(); + }); + expect(result.current.error).toBe(false); + + errorSpy.mockRestore(); + }); +}); diff --git a/frontend/src/features/learning/hooks/useLearningProgressSummaries.ts b/frontend/src/features/learning/hooks/useLearningProgressSummaries.ts new file mode 100644 index 0000000..e415232 --- /dev/null +++ b/frontend/src/features/learning/hooks/useLearningProgressSummaries.ts @@ -0,0 +1,44 @@ +import { useCallback, useState } from 'react'; +import { API_BASE } from '../../../shared/types'; +import type { ProgressEntrySummary, ProgressListResponse } from '../../../shared/types/roadmap'; + +/** + * Loads every roadmap play-through summary (GET /api/learning/progress) and + * keeps the most recent entry per roadmap — the landing page shows one + * "Continue" per roadmap, pointing at the project it was last played in. + * Progress is a garnish there: on error the cards simply render without it, + * so this hook exposes the flag but callers should not fail the section. + */ +export function useLearningProgressSummaries() { + const [byRoadmapId, setByRoadmapId] = useState>({}); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); + + const fetchProgress = useCallback(async () => { + try { + setLoading(true); + setError(false); + const res = await fetch(`${API_BASE}/api/learning/progress`); + const data: ProgressListResponse = await res.json(); + if (res.ok && Array.isArray(data?.entries)) { + const latest: Record = {}; + for (const entry of data.entries) { + const current = latest[entry.roadmapId]; + if (!current || entry.updatedAt > current.updatedAt) { + latest[entry.roadmapId] = entry; + } + } + setByRoadmapId(latest); + } else { + setError(true); + } + } catch (err) { + console.error('Failed to fetch learning progress:', err); + setError(true); + } finally { + setLoading(false); + } + }, []); + + return { byRoadmapId, loading, error, fetchProgress }; +} diff --git a/frontend/src/features/learning/hooks/useRoadmapDetail.ts b/frontend/src/features/learning/hooks/useRoadmapDetail.ts new file mode 100644 index 0000000..200fc41 --- /dev/null +++ b/frontend/src/features/learning/hooks/useRoadmapDetail.ts @@ -0,0 +1,75 @@ +import { useCallback, useRef, useState } from 'react'; +import { API_BASE } from '../../../shared/types'; +import type { + Roadmap, + RoadmapProgressResponse, + RoadmapSummary, + StepProgress, +} from '../../../shared/types/roadmap'; + +interface FetchArgs extends Pick { + /** Project the roadmap was last played in, when it has been played. */ + projectId?: string; +} + +/** + * Loads everything the roadmap briefing page shows: the full roadmap + * (GET /api/learning/roadmaps/:id) and, when the roadmap has already been + * played, that play-through's per-step progress. + * + * The roadmap is the page; its progress is a garnish, so a failing progress + * request leaves the page rendered without ticks rather than erroring out — + * same policy as useLearningProgressSummaries. + */ +export function useRoadmapDetail() { + const [roadmap, setRoadmap] = useState(null); + const [stepProgress, setStepProgress] = useState>({}); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); + // Switching language (or roadmap) while a request is in flight races on + // resolution order; only the latest request may commit. + const seqRef = useRef(0); + + const fetchDetail = useCallback(async ({ id, language, projectId }: FetchArgs) => { + const seq = ++seqRef.current; + try { + setLoading(true); + setError(false); + const res = await fetch( + `${API_BASE}/api/learning/roadmaps/${encodeURIComponent(id)}?language=${encodeURIComponent(language)}` + ); + const data = await res.json(); + if (seq !== seqRef.current) return; + if (!res.ok || !Array.isArray(data?.steps) || data.steps.length === 0) { + setError(true); + return; + } + + let steps: Record = {}; + if (projectId) { + try { + const progressRes = await fetch( + `${API_BASE}/api/learning/progress/${encodeURIComponent(projectId)}/${encodeURIComponent(id)}` + ); + if (progressRes.ok) { + const progress: RoadmapProgressResponse = await progressRes.json(); + steps = progress.steps ?? {}; + } + } catch (err) { + console.error('Failed to load roadmap progress:', err); + } + } + if (seq !== seqRef.current) return; + + setRoadmap(data as Roadmap); + setStepProgress(steps); + } catch (err) { + console.error('Failed to load roadmap:', err); + if (seq === seqRef.current) setError(true); + } finally { + if (seq === seqRef.current) setLoading(false); + } + }, []); + + return { roadmap, stepProgress, loading, error, fetchDetail }; +} diff --git a/frontend/src/features/learning/onboarding.test.ts b/frontend/src/features/learning/onboarding.test.ts new file mode 100644 index 0000000..c19611a --- /dev/null +++ b/frontend/src/features/learning/onboarding.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { hasSeenLearningPitch, markLearningPitchSeen } from './onboarding'; + +describe('learning onboarding flag', () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('starts unseen and stays seen once marked', () => { + expect(hasSeenLearningPitch()).toBe(false); + + markLearningPitchSeen(); + + expect(hasSeenLearningPitch()).toBe(true); + }); + + it('falls back to unseen when storage is unavailable, without throwing', () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('storage disabled'); + }); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('storage disabled'); + }); + + expect(() => markLearningPitchSeen()).not.toThrow(); + expect(hasSeenLearningPitch()).toBe(false); + }); +}); diff --git a/frontend/src/features/learning/onboarding.ts b/frontend/src/features/learning/onboarding.ts new file mode 100644 index 0000000..ebd548d --- /dev/null +++ b/frontend/src/features/learning/onboarding.ts @@ -0,0 +1,30 @@ +const STORAGE_KEY = 'torollo-learning-pitch-seen'; + +/** + * Whether the first-run learning pitch (hero + why-panel + sample receipt) has + * already been shown and acted on. + * + * Derived state — "no roadmap has any completed step" — is not enough on its + * own: someone who launches a roadmap and validates nothing would be pitched + * again on every visit, and the pitch is an introduction, not a dashboard. + * The flag lives in localStorage like the canvas layout: it is per browser + * profile, and losing it only re-shows the pitch, never data. + */ +export function hasSeenLearningPitch(): boolean { + try { + return localStorage.getItem(STORAGE_KEY) === 'true'; + } catch { + // Storage disabled (private mode, hardened profile): fall back to the + // derived detection rather than breaking the page. + return false; + } +} + +/** Called when a roadmap is actually launched — the pitch has done its job. */ +export function markLearningPitchSeen(): void { + try { + localStorage.setItem(STORAGE_KEY, 'true'); + } catch { + // See above: a pitch shown twice is better than a crash. + } +} diff --git a/frontend/src/features/learning/roadmapChecks.test.ts b/frontend/src/features/learning/roadmapChecks.test.ts new file mode 100644 index 0000000..d99dd40 --- /dev/null +++ b/frontend/src/features/learning/roadmapChecks.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest'; +import { deriveSampleChecks } from './roadmapChecks'; +import type { Roadmap, RoadmapValidator } from '../../shared/types/roadmap'; + +function roadmapOf(validatorsPerStep: RoadmapValidator[][]): Roadmap { + return { + schemaVersion: 1, + id: 'test-roadmap', + title: 'Test roadmap', + description: 'A roadmap.', + language: 'en', + steps: validatorsPerStep.map((validators, i) => ({ + id: `step-${i}`, + title: `Step ${i}`, + instruction: 'Do it.', + validators, + })), + }; +} + +describe('deriveSampleChecks', () => { + it('describes the first checks in play order, one line each', () => { + const lines = deriveSampleChecks( + roadmapOf([ + [{ type: 'container_running', params: { node: 'web' } }], + [{ type: 'table_exists', params: { node: 'db', table: 'books' } }], + [{ type: 'edge_exists', params: { source: 'web', target: 'db', port: 5432 } }], + ]) + ); + + expect(lines).toEqual([ + { key: 'learning.detail.check.containerRunning', params: { node: 'web' } }, + { key: 'learning.detail.check.tableExists', params: { node: 'db', table: 'books' } }, + { + key: 'learning.detail.check.edgeAllowedPort', + params: { source: 'web', target: 'db', port: 5432 }, + }, + ]); + }); + + it('drops repeated checks and stops at the limit', () => { + const lines = deriveSampleChecks( + roadmapOf([ + [ + { type: 'container_running', params: { node: 'web' } }, + { type: 'container_running', params: { node: 'web' } }, + { type: 'container_running', params: { node: 'db' } }, + ], + ]), + 2 + ); + + expect(lines.map(l => l.params.node)).toEqual(['web', 'db']); + }); + + it('uses the portless phrasing when a link check accepts any port', () => { + const [line] = deriveSampleChecks( + roadmapOf([[{ type: 'edge_exists', params: { source: 'web', target: 'cache' } }]]) + ); + + expect(line.key).toBe('learning.detail.check.edgeAllowed'); + }); + + it('skips validators it cannot describe rather than inventing a line', () => { + const lines = deriveSampleChecks( + roadmapOf([ + [ + { type: 'community_custom_check', params: { node: 'kafka' } }, + { type: 'table_exists', params: { node: 'db' } }, + { type: 'redis_key_exists', params: { node: 'cache', key: 'cache:books' } }, + ], + ]) + ); + + expect(lines).toEqual([ + { + key: 'learning.detail.check.redisKey', + params: { node: 'cache', redisKey: 'cache:books' }, + }, + ]); + }); +}); diff --git a/frontend/src/features/learning/roadmapChecks.ts b/frontend/src/features/learning/roadmapChecks.ts new file mode 100644 index 0000000..61d357d --- /dev/null +++ b/frontend/src/features/learning/roadmapChecks.ts @@ -0,0 +1,105 @@ +import type { Roadmap, RoadmapValidator } from '../../shared/types/roadmap'; + +/** + * Turns a roadmap's first validators into receipt lines describing what the + * engine will actually check (DESIGN §2: a receipt shows the real thing, never + * a paraphrase). Used for the "sample validation receipt" a learner sees before + * playing a roadmap — once they have played it, the panel shows their real run + * instead. + * + * Returns {key, params} descriptors, not text: the same contract as the + * backend validator messages, so this module stays translation-free. + */ + +export interface CheckLine { + key: string; + params: Record; +} + +/** Lines beyond this make the block a wall of text rather than a sample. */ +const MAX_LINES = 4; + +/** + * One entry per validator type the engine ships. Unknown types (community + * validators) are skipped rather than rendered with a made-up description. + */ +const LINE_BUILDERS: Record CheckLine | null> = { + container_running: p => + str(p.node) ? { key: 'learning.detail.check.containerRunning', params: { node: String(p.node) } } : null, + table_exists: p => + str(p.node) && str(p.table) + ? { key: 'learning.detail.check.tableExists', params: { node: String(p.node), table: String(p.table) } } + : null, + redis_key_exists: p => + str(p.node) && str(p.key) + ? { key: 'learning.detail.check.redisKey', params: { node: String(p.node), redisKey: String(p.key) } } + : null, + mongo_collection_exists: p => + str(p.node) && str(p.collection) + ? { + key: 'learning.detail.check.mongoCollection', + params: { node: String(p.node), collection: String(p.collection) }, + } + : null, + edge_exists: p => + str(p.source) && str(p.target) + ? { + key: num(p.port) ? 'learning.detail.check.edgeAllowedPort' : 'learning.detail.check.edgeAllowed', + params: { source: String(p.source), target: String(p.target), port: Number(p.port) }, + } + : null, + port_denied: p => + str(p.source) && str(p.target) && num(p.port) + ? { + key: 'learning.detail.check.portDenied', + params: { source: String(p.source), target: String(p.target), port: Number(p.port) }, + } + : null, + lb_upstreams: p => + str(p.node) && num(p.min) + ? { key: 'learning.detail.check.lbUpstreams', params: { node: String(p.node), min: Number(p.min) } } + : null, + asg_replicas: p => + str(p.node) && num(p.count) + ? { key: 'learning.detail.check.asgReplicas', params: { node: String(p.node), count: Number(p.count) } } + : null, + http_get_contains: p => + str(p.node) && str(p.expectedText) + ? { + key: 'learning.detail.check.httpContains', + params: { + node: String(p.node), + path: str(p.path) ? String(p.path) : '/', + expectedText: String(p.expectedText), + }, + } + : null, +}; + +function str(value: unknown): boolean { + return typeof value === 'string' && value.length > 0; +} + +function num(value: unknown): boolean { + return typeof value === 'number'; +} + +/** The first few checks of a roadmap, in play order, as receipt lines. */ +export function deriveSampleChecks(roadmap: Roadmap, limit: number = MAX_LINES): CheckLine[] { + const lines: CheckLine[] = []; + for (const step of roadmap.steps) { + for (const validator of step.validators) { + const line = LINE_BUILDERS[validator.type]?.(validator.params) ?? null; + // Two steps checking the same thing would print the same line twice. + if (line && !lines.some(existing => sameLine(existing, line))) { + lines.push(line); + if (lines.length === limit) return lines; + } + } + } + return lines; +} + +function sameLine(a: CheckLine, b: CheckLine): boolean { + return a.key === b.key && JSON.stringify(a.params) === JSON.stringify(b.params); +} diff --git a/frontend/src/features/learning/roadmapLanguage.ts b/frontend/src/features/learning/roadmapLanguage.ts new file mode 100644 index 0000000..fd8c807 --- /dev/null +++ b/frontend/src/features/learning/roadmapLanguage.ts @@ -0,0 +1,14 @@ +import type { RoadmapSummary } from '../../shared/types/roadmap'; + +/** + * Only surface roadmaps authored in the active UI language: an English user + * sees English roadmaps only. Compare on the base subtag so 'en-US' still + * matches an 'en' roadmap. + */ +export function filterByUiLanguage( + summaries: RoadmapSummary[], + uiLanguage: string +): RoadmapSummary[] { + const base = uiLanguage.split('-')[0]; + return summaries.filter(summary => summary.language.split('-')[0] === base); +} diff --git a/frontend/src/features/learning/roadmapTopology.test.ts b/frontend/src/features/learning/roadmapTopology.test.ts new file mode 100644 index 0000000..a954dd7 --- /dev/null +++ b/frontend/src/features/learning/roadmapTopology.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect } from 'vitest'; +import { deriveTopology } from './roadmapTopology'; +import type { Roadmap, RoadmapValidator } from '../../shared/types/roadmap'; + +function roadmapOf(validatorsPerStep: RoadmapValidator[][]): Roadmap { + return { + schemaVersion: 1, + id: 'test-roadmap', + title: 'Test roadmap', + description: 'A roadmap.', + language: 'en', + steps: validatorsPerStep.map((validators, i) => ({ + id: `step-${i}`, + title: `Step ${i}`, + instruction: 'Do it.', + validators, + })), + }; +} + +describe('deriveTopology', () => { + it('lists nodes in the order the roadmap first checks them', () => { + const { nodes } = deriveTopology( + roadmapOf([ + [{ type: 'container_running', params: { node: 'web' } }], + [{ type: 'container_running', params: { node: 'db' } }], + [{ type: 'edge_exists', params: { source: 'web', target: 'cache', port: 6379 } }], + ]) + ); + + expect(nodes.map(n => n.name)).toEqual(['web', 'db', 'cache']); + }); + + it('gives a node the role of the strongest check targeting it', () => { + const { nodes } = deriveTopology( + roadmapOf([ + [ + { type: 'container_running', params: { node: 'db' } }, + { type: 'http_get_contains', params: { node: 'web', port: 80, path: '/', expectedText: 'hi' } }, + ], + [ + // A store's identity must win over the generic checks around it. + { type: 'table_exists', params: { node: 'db', table: 'books' } }, + { type: 'container_running', params: { node: 'web' } }, + ], + ]) + ); + + expect(nodes).toEqual([ + { name: 'db', role: 'postgres' }, + { name: 'web', role: 'httpService' }, + ]); + }); + + it('reads connectivity checks as allow and deny links, deduplicated', () => { + const { links } = deriveTopology( + roadmapOf([ + [ + { type: 'edge_exists', params: { source: 'web', target: 'db', port: 5432 } }, + { type: 'edge_exists', params: { source: 'web', target: 'db', port: 5432 } }, + { type: 'edge_exists', params: { source: 'web', target: 'cache' } }, + ], + [{ type: 'port_denied', params: { source: 'cache', target: 'db', port: 5432 } }], + ]) + ); + + expect(links).toEqual([ + { source: 'web', target: 'db', port: 5432, mode: 'allow' }, + { source: 'web', target: 'cache', port: undefined, mode: 'allow' }, + { source: 'cache', target: 'db', port: 5432, mode: 'deny' }, + ]); + }); + + it('derives skills once each, capped at six', () => { + const { skills } = deriveTopology( + roadmapOf([ + [ + { type: 'container_running', params: { node: 'web' } }, + { type: 'container_running', params: { node: 'db' } }, + { type: 'table_exists', params: { node: 'db', table: 'books' } }, + { type: 'edge_exists', params: { source: 'web', target: 'db', port: 5432 } }, + { type: 'redis_key_exists', params: { node: 'cache', key: 'k' } }, + { type: 'port_denied', params: { source: 'cache', target: 'db', port: 5432 } }, + { type: 'http_get_contains', params: { node: 'web', port: 80, path: '/', expectedText: 'hi' } }, + { type: 'lb_upstreams', params: { node: 'lb', min: 2 } }, + ], + ]) + ); + + expect(skills).toEqual(['containers', 'sql', 'networking', 'redis', 'securityGroups', 'httpServices']); + }); + + it('keeps nodes of unknown validator types without guessing a role', () => { + const { nodes, links, skills } = deriveTopology( + roadmapOf([ + [ + { type: 'community_custom_check', params: { node: 'kafka', topic: 'orders' } }, + { type: 'community_link_check', params: { source: 'web', target: 'kafka' } }, + ], + ]) + ); + + expect(nodes).toEqual([ + { name: 'kafka', role: 'container' }, + { name: 'web', role: 'container' }, + ]); + // Only the engine's own connectivity checks describe firewall intent. + expect(links).toEqual([]); + expect(skills).toEqual([]); + }); + + it('ignores params of the wrong type instead of rendering them', () => { + const { nodes, links } = deriveTopology( + roadmapOf([ + [ + { type: 'container_running', params: { node: 42 } }, + { type: 'edge_exists', params: { source: 'web', target: 'db', port: '5432' } }, + ], + ]) + ); + + expect(nodes.map(n => n.name)).toEqual(['web', 'db']); + expect(links).toEqual([{ source: 'web', target: 'db', port: undefined, mode: 'allow' }]); + }); +}); diff --git a/frontend/src/features/learning/roadmapTopology.ts b/frontend/src/features/learning/roadmapTopology.ts new file mode 100644 index 0000000..6d9566c --- /dev/null +++ b/frontend/src/features/learning/roadmapTopology.ts @@ -0,0 +1,163 @@ +import type { Roadmap, RoadmapValidator } from '../../shared/types/roadmap'; + +/** + * What a roadmap will make the learner build, read off the roadmap's own + * validators instead of a declared field. + * + * The format has no architecture or skills metadata (docs/roadmap-format.md), + * and adding one would let the briefing drift from what is actually checked. + * The checks are the contract, so they are the source: a roadmap that asserts + * `redis_key_exists` on node "cache" is, by definition, a roadmap where "cache" + * is a Redis. Nothing here can go stale, and community roadmaps get it for free. + * + * Validator params are inert JSON typed as `unknown`; every read is defensive + * and unknown validator types are skipped for roles and skills but still + * contribute their nodes. + */ + +/** Node role, strongest evidence wins. Keys — the UI translates them. */ +export type NodeRole = + | 'postgres' + | 'redis' + | 'mongo' + | 'loadBalancer' + | 'autoScaling' + | 'httpService' + | 'container'; + +/** Skill keys, in the order the UI shows them. */ +export type SkillKey = + | 'containers' + | 'sql' + | 'redis' + | 'mongo' + | 'networking' + | 'securityGroups' + | 'loadBalancing' + | 'autoScaling' + | 'httpServices'; + +export interface TopologyNode { + /** The canvas node name the learner must use, e.g. "cache". */ + name: string; + role: NodeRole; +} + +export interface TopologyLink { + source: string; + target: string; + /** Absent when the roadmap accepts any port (`edge_exists` without `port`). */ + port?: number; + /** `allow`: traffic must reach. `deny`: traffic must be blocked. */ + mode: 'allow' | 'deny'; +} + +export interface RoadmapTopology { + nodes: TopologyNode[]; + links: TopologyLink[]; + skills: SkillKey[]; +} + +/** How many skill chips the stats strip shows before the row gets noisy. */ +const MAX_SKILLS = 6; + +/** + * Role implied by a validator targeting a node. A validator that reads a + * Redis key proves the node is a Redis; one that just fetches HTTP only proves + * it serves HTTP, so it must never win over a store's identity. + */ +const ROLE_BY_VALIDATOR: Record = { + table_exists: 'postgres', + redis_key_exists: 'redis', + mongo_collection_exists: 'mongo', + lb_upstreams: 'loadBalancer', + asg_replicas: 'autoScaling', + http_get_contains: 'httpService', + container_running: 'container', +}; + +/** Higher wins when several validators target the same node. */ +const ROLE_STRENGTH: Record = { + postgres: 3, + redis: 3, + mongo: 3, + loadBalancer: 3, + autoScaling: 3, + httpService: 2, + container: 1, +}; + +const SKILL_BY_VALIDATOR: Record = { + container_running: 'containers', + table_exists: 'sql', + redis_key_exists: 'redis', + mongo_collection_exists: 'mongo', + edge_exists: 'networking', + port_denied: 'securityGroups', + lb_upstreams: 'loadBalancing', + asg_replicas: 'autoScaling', + http_get_contains: 'httpServices', +}; + +function stringParam(validator: RoadmapValidator, key: string): string | null { + const value = validator.params?.[key]; + return typeof value === 'string' && value.length > 0 ? value : null; +} + +function portParam(validator: RoadmapValidator): number | undefined { + const value = validator.params?.port; + return typeof value === 'number' ? value : undefined; +} + +/** Reads the topology and skills a roadmap's checks assert. */ +export function deriveTopology(roadmap: Roadmap): RoadmapTopology { + // Insertion-ordered: nodes appear in the order the roadmap first checks + // them, which is the order the learner builds them. + const roles = new Map(); + const links: TopologyLink[] = []; + const seenLinks = new Set(); + const skills: SkillKey[] = []; + + const noteNode = (name: string | null, role: NodeRole) => { + if (!name) return; + const current = roles.get(name); + if (!current || ROLE_STRENGTH[role] > ROLE_STRENGTH[current]) { + roles.set(name, role); + } + }; + + for (const step of roadmap.steps) { + for (const validator of step.validators) { + const role = ROLE_BY_VALIDATOR[validator.type] ?? 'container'; + noteNode(stringParam(validator, 'node'), role); + + const source = stringParam(validator, 'source'); + const target = stringParam(validator, 'target'); + // Both endpoints of a connectivity check are nodes on the canvas, but + // the check says nothing about what they run. + noteNode(source, 'container'); + noteNode(target, 'container'); + + if (source && target && (validator.type === 'edge_exists' || validator.type === 'port_denied')) { + const port = portParam(validator); + const mode = validator.type === 'port_denied' ? 'deny' : 'allow'; + const key = `${source}>${target}:${port ?? 'any'}:${mode}`; + if (!seenLinks.has(key)) { + seenLinks.add(key); + links.push({ source, target, port, mode }); + } + } + + const skill = SKILL_BY_VALIDATOR[validator.type]; + if (skill && !skills.includes(skill)) { + skills.push(skill); + } + } + } + + return { + nodes: [...roles].map(([name, role]) => ({ name, role })), + links, + skills: skills.slice(0, MAX_SKILLS), + }; +} diff --git a/frontend/src/features/learning/validationStatus.ts b/frontend/src/features/learning/validationStatus.ts index e00db7b..2d8aefb 100644 --- a/frontend/src/features/learning/validationStatus.ts +++ b/frontend/src/features/learning/validationStatus.ts @@ -1,5 +1,4 @@ -import { AlertTriangle, CheckCircle2, XCircle } from 'lucide-react'; -import type { StepValidationResponse, ValidatorStatus } from '../../shared/types/roadmap'; +import type { StepValidationResponse } from '../../shared/types/roadmap'; export type StepOutcome = 'passed' | 'failed' | 'error'; @@ -24,22 +23,3 @@ const DOCKER_UNAVAILABLE = 'DOCKER_UNAVAILABLE'; 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 = { - 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 = { passed: 'pass', failed: 'fail', error: 'error' }; - return STATUS_PRESETS[status[stepOutcome(response)]]; -} diff --git a/frontend/src/index.css b/frontend/src/index.css index 61d466d..45d4f76 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -85,9 +85,53 @@ --color-pink-hover: #DB2777; --color-orange: #FF6600; + /* node identities — decorative only, never UI chrome (DESIGN §3.1). + Same hues the canvas node cards use, so a node keeps one identity + everywhere it is drawn. */ + --node-postgres: #64748B; + --node-redis: #DC2626; + --node-mongo: #475569; + --node-load-balancer: #EF4444; + --node-auto-scaling: #EC4899; + --node-http: #3B82F6; + + /* dark navigation rail (home shell chrome) */ + --bg-rail: #0F172A; + --rail-text: #F8FAFC; + --rail-text-muted: rgba(248, 250, 252, 0.55); + + /* overlays & shadows */ + --overlay-scrim: rgba(15, 23, 42, 0.45); + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 10px 15px -3px rgba(0, 0, 0, 0.08); + --shadow-lg: 0 24px 48px -12px rgba(0, 0, 0, 0.18); + + /* radius */ + --radius-sm: 6px; /* inputs, small buttons, chips, receipts */ + --radius-md: 10px; /* buttons, cards inside panels */ + --radius-lg: 14px; /* node cards, panels, toasts */ + --radius-xl: 18px; /* modals */ + + /* spacing (4px grid) */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + + /* type scale */ + --text-xs: 11px; + --text-sm: 12px; + --text-md: 13px; + --text-lg: 15px; + --text-xl: 18px; + --text-2xl: 24px; + --font-sans: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif; --font-mono: 'JetBrains Mono', monospace; - + background-color: var(--bg-main); color: var(--color-text-primary); font-family: var(--font-sans); @@ -215,6 +259,110 @@ body { } } +/* Shared Button primitive (shared/components/Button.tsx) — classes so hover + states stay in CSS. Also usable on for link-shaped actions. */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + border-radius: var(--radius-md); + font-family: var(--font-sans); + font-weight: 600; + cursor: pointer; + text-decoration: none; + transition: background 0.15s ease, border-color 0.15s ease, filter 0.15s ease; +} +.btn:disabled { + opacity: 0.55; + cursor: default; +} +.btn-md { + height: 34px; + padding: 0 var(--space-3); + font-size: var(--text-md); +} +.btn-lg { + height: 42px; + padding: 0 var(--space-5); + font-size: var(--text-lg); +} +.btn-primary { + background: var(--color-accent); + color: var(--bg-surface-solid); + border: 1px solid transparent; + box-shadow: var(--shadow-sm); +} +.btn-primary:hover:not(:disabled) { + filter: brightness(0.94); +} +.btn-outline { + background: var(--bg-surface-solid); + color: var(--color-text-primary); + border: 1px solid var(--border-color); +} +.btn-outline:hover:not(:disabled) { + border-color: var(--border-color-hover); + background: var(--bg-subtle); +} + +/* Navigation rail buttons (dark chrome — hover/active need CSS) */ +.rail-btn { + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + border-radius: var(--radius-md); + color: var(--rail-text-muted); + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; +} +.rail-btn:hover { + color: var(--rail-text); + background: color-mix(in srgb, var(--rail-text) 10%, transparent); +} +.rail-btn.active { + color: var(--rail-text); + background: var(--color-accent); +} + +/* Loading skeleton block */ +@keyframes skeletonPulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.55; + } +} +.skeleton { + background: var(--bg-main); + border-radius: var(--radius-md); + animation: skeletonPulse 1.6s ease-in-out infinite; +} + +/* Receipt fade-in (receipts never animate for attention — entry only) */ +@keyframes receiptFadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .skeleton { + animation: none; + } + .receipt-appear { + animation: none !important; + } +} + /* Interactive focus outlines */ input:focus { border-color: var(--color-accent) !important; diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 8cc9573..c9a2cfe 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -31,26 +31,22 @@ "loadError": "Could not load this roadmap. Pick one below to try again.", "stepCounter": "Step {{current}} of {{total}}", "previous": "Previous", - "next": "Next", + "skipStep": "Skip step", "validate": "Validate", "validating": "Checking...", "validationError": "Could not reach the server. Your work is untouched — try again.", "retry": "Retry", - "stepPassed": "Step passed", - "stepFailed": "Not yet — see the results below", - "expected": "Expected", - "observed": "Observed", - "stepError": "Some checks couldn't run — that's on us, not you. Fix the issue below or just try again.", + "stepPassed": "Validation passed", + "stepFailed": "Not yet", + "checksBlocked": "Checks couldn't run", + "stepError": "Some checks couldn't run — that's on us, not you. Fix the issue or just try 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", - "markerPassed": "Passed", - "markerFailed": "Failed", - "markerError": "Check error", - "hintLabel": "Hint {{n}} of {{total}}", + "moreChecks_one": "+{{count}} more check to fix", + "moreChecks_other": "+{{count}} more checks to fix", + "roadmapComplete": "Validation passed — that was the last step. Roadmap complete!", + "hintLabel": "hint {{n}}/{{total}}", "showHint": "Show hint ({{n}}/{{total}})", - "solutionLabel": "Solution", + "solutionLabel": "solution", "showSolution": "Reveal the solution", "confirmSolution": "Sure? Click again to reveal", "resetProgress": "Restart roadmap", @@ -58,31 +54,158 @@ "resetProgressError": "Could not reset the progress. Nothing was changed — try again.", "progressRecovered": "Your saved progress could not be read and had to be reset. Sorry — this roadmap starts fresh.", "dismissNotice": "Dismiss" + }, + "landing": { + "title": "Learning", + "subtitle": "Guided roadmaps, checked against real containers", + "heroTitle": "Learn system design by running real infrastructure.", + "heroBody": "Follow a roadmap, build the system on the canvas, and validate every step against your actual Docker containers.", + "startRoadmap": "View the roadmap", + "browseRoadmaps": "Browse all roadmaps", + "roadmapsTitle": "Roadmaps", + "continueLabel": "Continue · step {{current}} of {{total}}", + "completedLabel": "Completed", + "resumeLabel": "Resume", + "resumeAction": "Continue", + "stepOf": "Step {{current}} of {{total}}", + "whyTitle": "Why this is different", + "whyContainersTitle": "Real containers", + "whyContainersBody": "Every node you drag is a real Docker container running on your machine.", + "whyCommandsTitle": "Real commands", + "whyCommandsBody": "You work in real terminals — redis-cli, psql, curl — not a simulation.", + "whyReceiptsTitle": "Real validation receipts", + "whyReceiptsBody": "Every check shows the exact command it ran and what it observed.", + "sampleReceiptLabel": "Sample validation receipt", + "sampleReceipt": { + "verdict": "Step passed" + }, + "pickProjectTitle": "Choose a project", + "pickProjectBody": "\"{{roadmap}}\" runs against one project’s containers. Pick where to work on it.", + "pickProjectNew": "New project" + }, + "detail": { + "breadcrumbLearning": "Learning", + "breadcrumbRoadmaps": "Roadmaps", + "loading": "Loading roadmap", + "loadError": "Could not load this roadmap. Is the backend running?", + "retry": "Retry", + "back": "All roadmaps", + "stat": { + "difficulty": "Difficulty", + "steps": "Steps", + "duration": "Est. duration", + "skills": "Skills you'll practise" + }, + "skill": { + "containers": "Containers", + "sql": "SQL", + "redis": "Redis", + "mongo": "MongoDB", + "networking": "Networking", + "securityGroups": "Security groups", + "loadBalancing": "Load balancing", + "autoScaling": "Auto-scaling", + "httpServices": "HTTP services" + }, + "build": { + "title": "What you'll build", + "subtitle": "The nodes and connections this roadmap checks on your machine.", + "linksTitle": "Connections", + "allowed": "must be reachable", + "denied": "must be blocked", + "anyPort": "any port" + }, + "role": { + "postgres": "PostgreSQL", + "redis": "Redis", + "mongo": "MongoDB", + "loadBalancer": "Load balancer", + "autoScaling": "Auto-scaling group", + "httpService": "HTTP service", + "container": "Container" + }, + "outline": { + "title": "Step outline", + "subtitle": "Follow these steps to complete the roadmap.", + "passed": "Passed", + "current": "You are here" + }, + "verify": { + "title": "How Torollo verifies this", + "liveTitle": "Live checks", + "liveBody": "Every check runs against the containers on your machine, as you build.", + "receiptsTitle": "Receipts", + "receiptsBody": "Each check shows what it looked at and what it observed.", + "commandsTitle": "Real commands", + "commandsBody": "The same tools you would use in production — psql, redis-cli, curl." + }, + "check": { + "containerRunning": "check: container \"{{node}}\" is running", + "tableExists": "check: table \"{{table}}\" exists in \"{{node}}\"", + "redisKey": "check: key \"{{redisKey}}\" exists in \"{{node}}\"", + "mongoCollection": "check: collection \"{{collection}}\" exists in \"{{node}}\"", + "edgeAllowed": "check: {{source}} → {{target}} is reachable", + "edgeAllowedPort": "check: {{source}} → {{target}} is reachable on :{{port}}", + "portDenied": "check: {{source}} → {{target}} is blocked on :{{port}}", + "lbUpstreams": "check: \"{{node}}\" balances at least {{min}} upstreams", + "asgReplicas": "check: \"{{node}}\" runs exactly {{count}} replicas", + "httpContains": "check: GET {{path}} on \"{{node}}\" contains \"{{expectedText}}\"" + }, + "receipt": { + "sampleLabel": "Sample validation receipt", + "lastRunLabel": "Your last run · {{project}}", + "stepsPassing": "{{passed}} of {{total}} steps passing", + "lastChecked": "last checked: {{date}}", + "nextStep": "next: \"{{title}}\"", + "allPassing": "every step passing — roadmap complete" + }, + "prerequisites": { + "title": "Before you start" + }, + "launch": { + "start": "Launch lab", + "startCaption": "Spin up your environment and begin.", + "continue": "Continue · step {{current}} of {{total}}", + "continueCaption": "Resumes in \"{{project}}\".", + "restart": "Restart roadmap", + "restartTitle": "Restart roadmap", + "restartMessage": "This clears your progress on \"{{title}}\" in \"{{project}}\". Your containers are left untouched.", + "restartConfirm": "Restart roadmap", + "restartError": "Could not clear the progress. Nothing was changed — try again.", + "dockerOk": "Docker connected", + "dockerDown": "Docker isn't running — start Docker to launch containers.", + "dockerRetry": "Check again" + } } }, "projects": { "title": "Torollo Projects", "subtitle": "Organize your infrastructure labs", - "newProject": "New Project", - "loading": "Loading projects...", - "createTitle": "Create Project Stack", + "newProject": "New project", + "createTitle": "Create a project", "createLabel": "Give your project a descriptive name to organize your containers.", "createPlaceholder": "e.g. Web App Lab, API Gateway Test", - "createSubmit": "Create Project", - "deleteTitle": "Delete Project", - "deleteConfirm": "Delete Project", + "createSubmit": "Create project", + "deleteTitle": "Delete project", + "deleteConfirm": "Delete project", "deleteMessage": "This will permanently delete \"{{name}}\" and stop/remove all associated Docker containers. This action cannot be undone.", - "noProjects": "No projects found. Create a new one!", - "lastUpdated": "Last updated:", - "open": "Open", - "delete": "Delete", "storeRecovered": "Your projects file could not be read and had to be reset. The unreadable file was kept as projects.json.corrupt in your .torollo folder.", "dismissNotice": "Dismiss notice", - "emptyTitle": "No projects yet", - "emptyDesc": "Click \"New Project\" to create your first infrastructure stack.", "deleting": "Deleting...", "deleteProjectTooltip": "Delete project", - "openStack": "Open Stack" + "openStack": "Open project", + "docsLink": "Docs", + "sectionTitle": "Projects", + "loadError": "Could not load projects. Is the backend running?", + "retry": "Retry", + "loadingAria": "Loading projects", + "firstLabName": "My first lab", + "firstRun": { + "title": "Build real infrastructure on your machine.", + "body": "Drag databases, load balancers and networks onto a canvas — every node is a real Docker container. Learn system design by running it, not by drawing it.", + "startLearning": "Start learning", + "startScratch": "Start from scratch" + } }, "vpc": { "titleInfo": "Project VPC Settings", @@ -540,7 +663,9 @@ "genericError": "An error occurred", "dockerUnavailable": "Docker daemon unreachable — start Docker and the lab will reconnect automatically.", "interSubnetBlocked": "This machine's firewall is dropping traffic between subnets — cross-subnet connections won't work here, even with correct ALLOW rules.", - "deleteConnection": "Delete Connection" + "deleteConnection": "Delete Connection", + "copy": "Copy", + "copied": "Copied" }, "toasts": { "subnetDeleteBlocked": "Cannot delete subnet: Move or delete all nodes inside the subnet first.", @@ -673,6 +798,14 @@ "testPacket": "Test Packet" } }, + "canvas": { + "empty": { + "title": "This canvas is empty", + "body": "Every node you drop here becomes a real Docker container. A guided roadmap tells you what to build, step by step, and checks it against those containers.", + "followRoadmap": "Follow a roadmap", + "dragHint": "or drag a node from the library" + } + }, "nodeLibrary": { "title": "Node Library", "search": "Search nodes...", @@ -752,5 +885,10 @@ "autoscalinggroup": "e.g. asg-1" } } + }, + "nav": { + "main": "Main navigation", + "projects": "Projects", + "learning": "Learning" } } diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index 72c78c3..6623e43 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -31,26 +31,22 @@ "loadError": "Impossible de charger cette roadmap. Choisissez-en une ci-dessous pour réessayer.", "stepCounter": "Étape {{current}} sur {{total}}", "previous": "Précédent", - "next": "Suivant", + "skipStep": "Passer l'étape", "validate": "Valider", "validating": "Vérification...", "validationError": "Impossible de joindre le serveur. Votre travail est intact — réessayez.", "retry": "Réessayer", - "stepPassed": "Étape validée", - "stepFailed": "Pas encore — voir les résultats ci-dessous", - "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.", + "stepPassed": "Validation réussie", + "stepFailed": "Pas encore", + "checksBlocked": "Vérifications impossibles", + "stepError": "Certaines vérifications n'ont pas pu s'exécuter — ce n'est pas de votre faute. Corrigez le problème ou réessayez simplement.", "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", - "markerPassed": "Validée", - "markerFailed": "Échouée", - "markerError": "Erreur de vérification", - "hintLabel": "Indice {{n}} sur {{total}}", + "moreChecks_one": "+{{count}} autre vérification à corriger", + "moreChecks_other": "+{{count}} autres vérifications à corriger", + "roadmapComplete": "Validation réussie — c'était la dernière étape. Roadmap terminée !", + "hintLabel": "indice {{n}}/{{total}}", "showHint": "Voir un indice ({{n}}/{{total}})", - "solutionLabel": "Solution", + "solutionLabel": "solution", "showSolution": "Révéler la solution", "confirmSolution": "Sûr ? Cliquez à nouveau pour révéler", "resetProgress": "Recommencer la roadmap", @@ -58,31 +54,158 @@ "resetProgressError": "Impossible de réinitialiser la progression. Rien n'a été modifié — réessayez.", "progressRecovered": "Votre progression sauvegardée était illisible et a dû être réinitialisée. Désolé — cette roadmap repart de zéro.", "dismissNotice": "Fermer" + }, + "landing": { + "title": "Apprentissage", + "subtitle": "Des parcours guidés, vérifiés sur de vrais conteneurs", + "heroTitle": "Apprenez le system design en exécutant une vraie infrastructure.", + "heroBody": "Suivez un parcours, construisez le système sur le canevas et validez chaque étape sur vos vrais conteneurs Docker.", + "startRoadmap": "Voir le parcours", + "browseRoadmaps": "Parcourir tous les parcours", + "roadmapsTitle": "Parcours", + "continueLabel": "Continuer · étape {{current}} sur {{total}}", + "completedLabel": "Terminé", + "resumeLabel": "Reprendre", + "resumeAction": "Continuer", + "stepOf": "Étape {{current}} sur {{total}}", + "whyTitle": "Pourquoi c'est différent", + "whyContainersTitle": "De vrais conteneurs", + "whyContainersBody": "Chaque nœud que vous déposez est un vrai conteneur Docker qui tourne sur votre machine.", + "whyCommandsTitle": "De vraies commandes", + "whyCommandsBody": "Vous travaillez dans de vrais terminaux — redis-cli, psql, curl — pas dans une simulation.", + "whyReceiptsTitle": "De vrais reçus de validation", + "whyReceiptsBody": "Chaque vérification montre la commande exacte exécutée et ce qui a été observé.", + "sampleReceiptLabel": "Exemple de reçu de validation", + "sampleReceipt": { + "verdict": "Étape validée" + }, + "pickProjectTitle": "Choisir un projet", + "pickProjectBody": "« {{roadmap}} » s'exécute sur les conteneurs d'un projet. Choisissez où travailler.", + "pickProjectNew": "Nouveau projet" + }, + "detail": { + "breadcrumbLearning": "Apprentissage", + "breadcrumbRoadmaps": "Parcours", + "loading": "Chargement du parcours", + "loadError": "Impossible de charger ce parcours. Le backend est-il démarré ?", + "retry": "Réessayer", + "back": "Tous les parcours", + "stat": { + "difficulty": "Difficulté", + "steps": "Étapes", + "duration": "Durée estimée", + "skills": "Ce que vous pratiquez" + }, + "skill": { + "containers": "Conteneurs", + "sql": "SQL", + "redis": "Redis", + "mongo": "MongoDB", + "networking": "Réseau", + "securityGroups": "Groupes de sécurité", + "loadBalancing": "Répartition de charge", + "autoScaling": "Mise à l'échelle", + "httpServices": "Services HTTP" + }, + "build": { + "title": "Ce que vous allez construire", + "subtitle": "Les nœuds et les connexions que ce parcours vérifie sur votre machine.", + "linksTitle": "Connexions", + "allowed": "doit être joignable", + "denied": "doit être bloqué", + "anyPort": "n'importe quel port" + }, + "role": { + "postgres": "PostgreSQL", + "redis": "Redis", + "mongo": "MongoDB", + "loadBalancer": "Répartiteur de charge", + "autoScaling": "Groupe de mise à l'échelle", + "httpService": "Service HTTP", + "container": "Conteneur" + }, + "outline": { + "title": "Les étapes", + "subtitle": "Suivez ces étapes pour terminer le parcours.", + "passed": "Validée", + "current": "Vous êtes ici" + }, + "verify": { + "title": "Comment Torollo vérifie", + "liveTitle": "Vérifications réelles", + "liveBody": "Chaque vérification s'exécute sur les conteneurs de votre machine, au fil de la construction.", + "receiptsTitle": "Reçus", + "receiptsBody": "Chaque vérification montre ce qu'elle a regardé et ce qu'elle a observé.", + "commandsTitle": "Vraies commandes", + "commandsBody": "Les outils que vous utiliseriez en production — psql, redis-cli, curl." + }, + "check": { + "containerRunning": "vérif : le conteneur \"{{node}}\" tourne", + "tableExists": "vérif : la table \"{{table}}\" existe dans \"{{node}}\"", + "redisKey": "vérif : la clé \"{{redisKey}}\" existe dans \"{{node}}\"", + "mongoCollection": "vérif : la collection \"{{collection}}\" existe dans \"{{node}}\"", + "edgeAllowed": "vérif : {{source}} → {{target}} est joignable", + "edgeAllowedPort": "vérif : {{source}} → {{target}} est joignable sur :{{port}}", + "portDenied": "vérif : {{source}} → {{target}} est bloqué sur :{{port}}", + "lbUpstreams": "vérif : \"{{node}}\" répartit vers au moins {{min}} cibles", + "asgReplicas": "vérif : \"{{node}}\" fait tourner exactement {{count}} répliques", + "httpContains": "vérif : GET {{path}} sur \"{{node}}\" contient \"{{expectedText}}\"" + }, + "receipt": { + "sampleLabel": "Exemple de reçu de validation", + "lastRunLabel": "Votre dernière session · {{project}}", + "stepsPassing": "{{passed}} étapes validées sur {{total}}", + "lastChecked": "dernière vérification : {{date}}", + "nextStep": "suite : \"{{title}}\"", + "allPassing": "toutes les étapes validées — parcours terminé" + }, + "prerequisites": { + "title": "Avant de commencer" + }, + "launch": { + "start": "Lancer le lab", + "startCaption": "Démarrez votre environnement et commencez.", + "continue": "Continuer · étape {{current}} sur {{total}}", + "continueCaption": "Reprend dans \"{{project}}\".", + "restart": "Recommencer le parcours", + "restartTitle": "Recommencer le parcours", + "restartMessage": "Cela efface votre progression sur \"{{title}}\" dans \"{{project}}\". Vos conteneurs ne sont pas touchés.", + "restartConfirm": "Recommencer le parcours", + "restartError": "Impossible d'effacer la progression. Rien n'a été modifié — réessayez.", + "dockerOk": "Docker connecté", + "dockerDown": "Docker n'est pas démarré — démarrez Docker pour lancer les conteneurs.", + "dockerRetry": "Vérifier à nouveau" + } } }, "projects": { "title": "Projets Torollo", "subtitle": "Organisez vos laboratoires d'infrastructure", - "newProject": "Nouveau Projet", - "loading": "Chargement des projets...", - "createTitle": "Créer un Projet", + "newProject": "Nouveau projet", + "createTitle": "Créer un projet", "createLabel": "Donnez un nom descriptif à votre projet pour organiser vos conteneurs.", "createPlaceholder": "ex: Lab App Web, Test API Gateway", - "createSubmit": "Créer le Projet", - "deleteTitle": "Supprimer le Projet", - "deleteConfirm": "Supprimer le Projet", + "createSubmit": "Créer le projet", + "deleteTitle": "Supprimer le projet", + "deleteConfirm": "Supprimer le projet", "deleteMessage": "Cela supprimera définitivement \"{{name}}\" et arrêtera/supprimera tous les conteneurs Docker associés. Cette action est irréversible.", - "noProjects": "Aucun projet trouvé. Créez-en un nouveau !", - "lastUpdated": "Dernière mise à jour :", - "open": "Ouvrir", - "delete": "Supprimer", "storeRecovered": "Votre fichier de projets était illisible et a dû être réinitialisé. Le fichier illisible a été conservé sous projects.json.corrupt dans votre dossier .torollo.", "dismissNotice": "Fermer l'avertissement", - "emptyTitle": "Aucun projet pour l'instant", - "emptyDesc": "Cliquez sur « Nouveau projet » pour créer votre première stack d'infrastructure.", "deleting": "Suppression...", "deleteProjectTooltip": "Supprimer le projet", - "openStack": "Ouvrir la stack" + "openStack": "Ouvrir le projet", + "docsLink": "Docs", + "sectionTitle": "Projets", + "loadError": "Impossible de charger les projets. Le backend est-il démarré ?", + "retry": "Réessayer", + "loadingAria": "Chargement des projets", + "firstLabName": "Mon premier lab", + "firstRun": { + "title": "Construisez une vraie infrastructure sur votre machine.", + "body": "Glissez des bases de données, des load balancers et des réseaux sur un canevas — chaque nœud est un vrai conteneur Docker. Apprenez le system design en l'exécutant, pas en le dessinant.", + "startLearning": "Commencer à apprendre", + "startScratch": "Partir de zéro" + } }, "vpc": { "titleInfo": "Paramètres VPC du Projet", @@ -540,7 +663,9 @@ "genericError": "Une erreur s'est produite", "dockerUnavailable": "Démon Docker injoignable — démarrez Docker et le lab se reconnectera automatiquement.", "interSubnetBlocked": "Le pare-feu de cette machine bloque le trafic entre les sous-réseaux — les connexions inter-sous-réseaux ne fonctionneront pas ici, même avec des règles ALLOW correctes.", - "deleteConnection": "Supprimer la connexion" + "deleteConnection": "Supprimer la connexion", + "copy": "Copier", + "copied": "Copié" }, "toasts": { "subnetDeleteBlocked": "Impossible de supprimer le sous-réseau : déplacez ou supprimez d'abord tous les nœuds qu'il contient.", @@ -673,6 +798,14 @@ "testPacket": "Tester le paquet" } }, + "canvas": { + "empty": { + "title": "Ce canevas est vide", + "body": "Chaque nœud déposé ici devient un vrai conteneur Docker. Un parcours guidé vous dit quoi construire, étape par étape, et le vérifie sur ces conteneurs.", + "followRoadmap": "Suivre un parcours", + "dragHint": "ou glissez un nœud depuis la bibliothèque" + } + }, "nodeLibrary": { "title": "Bibliothèque de nœuds", "search": "Rechercher des nœuds...", @@ -752,5 +885,10 @@ "autoscalinggroup": "ex. asg-1" } } + }, + "nav": { + "main": "Navigation principale", + "projects": "Projets", + "learning": "Apprentissage" } } diff --git a/frontend/src/pages/CanvasPage/CanvasPage.test.tsx b/frontend/src/pages/CanvasPage/CanvasPage.test.tsx index 6cd297b..c011e8d 100644 --- a/frontend/src/pages/CanvasPage/CanvasPage.test.tsx +++ b/frontend/src/pages/CanvasPage/CanvasPage.test.tsx @@ -89,6 +89,8 @@ async function renderCanvasPage(fetchMock: ReturnType, props: Part @@ -117,6 +119,72 @@ describe('CanvasPage', () => { vi.useRealTimers(); }); + it('opens the learning panel on mount when an arrival intent is given, and consumes it once', async () => { + const fetchMock = buildFetchMock({ containers: [], networkConfig: { vpcConfig: validVpcConfig, subnets: [], nodeSubnetMap: {}, nodeSecurityGroups: {}, nodeIpMap: {} } }); + const onLearningIntentConsumed = vi.fn(); + await renderCanvasPage(fetchMock, { initialLearning: {}, onLearningIntentConsumed }); + + // Topbar button + panel header both say "Learning": 2 means the panel is open. + expect(screen.getAllByText('Learning')).toHaveLength(2); + expect(onLearningIntentConsumed).toHaveBeenCalledTimes(1); + }); + + it('keeps the learning panel closed without an arrival intent', async () => { + const fetchMock = buildFetchMock({ containers: [], networkConfig: { vpcConfig: validVpcConfig, subnets: [], nodeSubnetMap: {}, nodeSecurityGroups: {}, nodeIpMap: {} } }); + await renderCanvasPage(fetchMock); + + // The topbar button is labeled via topbar.learning; the panel header ("Learning") + // must not be there. Both resolve to the same string, so count occurrences. + expect(screen.getAllByText('Learning')).toHaveLength(1); + }); + + it('offers a roadmap on an empty canvas, and opens the learning panel from it', async () => { + const fetchMock = buildFetchMock({ containers: [], networkConfig: { vpcConfig: validVpcConfig, subnets: [], nodeSubnetMap: {}, nodeSecurityGroups: {}, nodeIpMap: {} } }); + await renderCanvasPage(fetchMock); + + expect(await screen.findByText('This canvas is empty')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /Follow a roadmap/ })); + + // The panel now answers the same question, so the empty state steps aside. + expect(screen.getAllByText('Learning')).toHaveLength(2); + expect(screen.queryByText('This canvas is empty')).toBeNull(); + }); + + it('waits for the first container list before declaring the canvas empty', async () => { + // "Nothing yet" must not be shown as "nothing at all": a project that is + // still loading would otherwise flash the empty state before its nodes. + const neverSettles = vi.fn((url: string) => + url.includes('/network-config') + ? jsonResponse(true, { vpcConfig: validVpcConfig, subnets: [], nodeSubnetMap: {}, nodeSecurityGroups: {}, nodeIpMap: {} }) + : new Promise(() => {}) + ); + await renderCanvasPage(neverSettles as unknown as ReturnType); + + expect(screen.queryByText('This canvas is empty')).toBeNull(); + }); + + it('keeps the empty state off a canvas that holds something', async () => { + const withSubnetOnly = buildFetchMock({ + containers: [], + networkConfig: { vpcConfig: validVpcConfig, subnets: [subnetFixture()], nodeSubnetMap: {}, nodeSecurityGroups: {}, nodeIpMap: {} }, + }); + const { unmount } = await renderCanvasPage(withSubnetOnly); + + await waitFor(() => expect(screen.getByText('Public Subnet-1')).toBeInTheDocument()); + expect(screen.queryByText('This canvas is empty')).toBeNull(); + + unmount(); + const withContainerOnly = buildFetchMock({ + containers: [{ id: 'c1', name: 'web-1', state: 'running', status: 'running', type: 'ubuntu' }], + networkConfig: { vpcConfig: validVpcConfig, subnets: [], nodeSubnetMap: {}, nodeSecurityGroups: {}, nodeIpMap: {} }, + }); + await renderCanvasPage(withContainerOnly); + + await waitFor(() => expect(screen.getByText('web-1')).toBeInTheDocument()); + expect(screen.queryByText('This canvas is empty')).toBeNull(); + }); + it('fetches containers and network config on mount and renders the project header', async () => { const fetchMock = buildFetchMock({ containers: [], networkConfig: { vpcConfig: validVpcConfig, subnets: [], nodeSubnetMap: {}, nodeSecurityGroups: {}, nodeIpMap: {} } }); await renderCanvasPage(fetchMock); diff --git a/frontend/src/pages/CanvasPage/CanvasPage.tsx b/frontend/src/pages/CanvasPage/CanvasPage.tsx index 824e1d3..4ac3af4 100644 --- a/frontend/src/pages/CanvasPage/CanvasPage.tsx +++ b/frontend/src/pages/CanvasPage/CanvasPage.tsx @@ -15,6 +15,7 @@ import AsgNode from '../../features/nodes/AsgNode/AsgNode'; import VpcNode from '../../features/nodes/VpcNode/VpcNode'; import SubnetNode from '../../features/nodes/SubnetNode/SubnetNode'; import NodeLibrary from './components/NodeLibrary'; +import CanvasEmptyState from './components/CanvasEmptyState'; import LearningPanel from '../../features/learning/components/LearningPanel'; import { useContainers } from '../../shared/hooks/useContainers'; import { useToast } from '../../shared/hooks/useToast'; @@ -27,6 +28,7 @@ import CanvasModals from './components/CanvasModals'; import type { InspectorState } from './components/CanvasModals'; import ButtonEdge from './components/ButtonEdge'; import { API_BASE } from '../../shared/types'; +import type { LearningIntent } from '../../shared/types'; import { useNetworkConfig } from './hooks/useNetworkConfig'; import { useCanvasDragDrop } from './hooks/useCanvasDragDrop'; import { positionToCell, resolveSubnetChildPosition, subnetSize } from './utils/canvasGeometry'; @@ -42,6 +44,10 @@ import { assignNodeToSubnet, removeNodeFromConfig } from './utils/networkConfigO interface CanvasPageProps { projectId: string; projectName: string; + /** Arrival intent from the landing page: open the learning panel, optionally on one roadmap. */ + initialLearning?: LearningIntent | null; + /** Called once on mount so the owner can clear the one-shot intent. */ + onLearningIntentConsumed?: () => void; onBackToProjects: () => void; onTerminalOpen: (id: string, name: string) => void; } @@ -58,13 +64,21 @@ const INSPECTOR_KIND_BY_NODE_TYPE: Record = { autoscalinggroup: 'asg', }; -export default function CanvasPage({ projectId, projectName, onBackToProjects, onTerminalOpen }: CanvasPageProps) { +export default function CanvasPage({ + projectId, + projectName, + initialLearning, + onLearningIntentConsumed, + onBackToProjects, + onTerminalOpen, +}: CanvasPageProps) { const { t } = useTranslation(); const { toast, showNotification, showToast, dismissToast } = useToast(); const { containers, loading, + loaded, creating, opErrors, dockerUnavailable, @@ -97,8 +111,14 @@ export default function CanvasPage({ projectId, projectName, onBackToProjects, o // Ref to track saved positions (avoids re-render loops) const positionsRef = useRef>({}); - const { networkConfig, saveNetworkConfig, fetchNetworkConfig, triggerArchitectureAudit, interSubnetBlocked } = - useNetworkConfig({ projectId, containers, showNotification }); + const { + networkConfig, + loaded: networkConfigLoaded, + saveNetworkConfig, + fetchNetworkConfig, + triggerArchitectureAudit, + interSubnetBlocked, + } = useNetworkConfig({ projectId, containers, showNotification }); // A service was dropped inside a subnet: stash the drop context and open the create modal const onRequestCreateNode = useCallback((drop: { position: { x: number; y: number }; type: string; subnetId: string }) => { @@ -122,9 +142,26 @@ export default function CanvasPage({ projectId, projectName, onBackToProjects, o onRequestCreateNode, }); + // Nothing on the canvas and nothing loading: the canvas has to say what to do + // next. Both fetches must have landed first, or opening a project would flash + // the empty state before its nodes arrive. + const isCanvasEmpty = + loaded && networkConfigLoaded && containers.length === 0 && networkConfig.subnets.length === 0; + const [showVpcSettings, setShowVpcSettings] = useState(false); const [showTrafficSimulator, setShowTrafficSimulator] = useState(false); - const [showLearning, setShowLearning] = useState(false); + const [showLearning, setShowLearning] = useState(() => Boolean(initialLearning)); + // One-shot arrival intent (CanvasPage mounts fresh per project): the roadmap + // to auto-open is captured here, and the owner clears its copy right away so + // a later toggle of the panel can never replay it. + const initialRoadmapRef = useRef(initialLearning?.roadmap ?? null); + const intentConsumedRef = useRef(onLearningIntentConsumed); + useEffect(() => { + intentConsumedRef.current?.(); + // Child effects ran first, so the panel (mounted on this same first render + // when an intent exists) has already captured its roadmap prop. + initialRoadmapRef.current = null; + }, []); const nodeTypes = useMemo(() => ({ ubuntu: UbuntuNode, @@ -584,6 +621,7 @@ export default function CanvasPage({ projectId, projectName, onBackToProjects, o {showLearning && ( setShowLearning(false)} containers={containers} networkConfig={networkConfig} @@ -629,6 +667,11 @@ export default function CanvasPage({ projectId, projectName, onBackToProjects, o + + {/* The learning panel already answers "what now?" when it is open. */} + {isCanvasEmpty && !showLearning && ( + setShowLearning(true)} /> + )}
diff --git a/frontend/src/pages/CanvasPage/components/CanvasEmptyState.tsx b/frontend/src/pages/CanvasPage/components/CanvasEmptyState.tsx new file mode 100644 index 0000000..45a68eb --- /dev/null +++ b/frontend/src/pages/CanvasPage/components/CanvasEmptyState.tsx @@ -0,0 +1,90 @@ +import { useTranslation } from 'react-i18next'; +import { ArrowRight, GraduationCap } from 'lucide-react'; +import Button from '../../../shared/components/Button'; + +interface CanvasEmptyStateProps { + /** Opens the learning panel on the roadmap catalogue. */ + onFollowRoadmap: () => void; +} + +/** + * Shown centered on the dotted grid while the canvas holds nothing — no + * container, no subnet — and the learning panel is closed. A user must never + * face an empty canvas with no proposed next step (DESIGN §4.2): the guided + * roadmaps are the product's loop, and nothing else on this screen says so. + * + * It never swallows a drop: only the button takes pointer events, so drags + * from the node library land on the canvas underneath as usual. + */ +export default function CanvasEmptyState({ onFollowRoadmap }: CanvasEmptyStateProps) { + const { t } = useTranslation(); + return ( +
+
+

{t('canvas.empty.title')}

+

{t('canvas.empty.body')}

+
+ + + {t('canvas.empty.dragHint')} + + +
+
+
+ ); +} + +const styles: Record = { + wrapper: { + position: 'absolute', + inset: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + // Below the floating VPC header (z-index 10), above the React Flow pane. + zIndex: 5, + pointerEvents: 'none', + }, + panel: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: 'var(--space-3)', + padding: 'var(--space-6)', + maxWidth: '420px', + textAlign: 'center', + }, + title: { + fontSize: 'var(--text-lg)', + fontWeight: 700, + color: 'var(--color-text-primary)', + margin: 0, + }, + body: { + fontSize: 'var(--text-sm)', + color: 'var(--color-text-secondary)', + lineHeight: 1.6, + margin: 0, + }, + actions: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: 'var(--space-3)', + marginTop: 'var(--space-1)', + }, + action: { + pointerEvents: 'auto', + }, + hint: { + display: 'inline-flex', + alignItems: 'center', + gap: 'var(--space-1)', + fontSize: 'var(--text-sm)', + color: 'var(--color-text-muted)', + }, +}; diff --git a/frontend/src/pages/CanvasPage/hooks/useNetworkConfig.ts b/frontend/src/pages/CanvasPage/hooks/useNetworkConfig.ts index 2703b41..0738180 100644 --- a/frontend/src/pages/CanvasPage/hooks/useNetworkConfig.ts +++ b/frontend/src/pages/CanvasPage/hooks/useNetworkConfig.ts @@ -45,6 +45,10 @@ export function useNetworkConfig({ projectId, containers, showNotification }: Us // enforcement synchronously, so the verdict is fresh by the time it returns. const [interSubnetBlocked, setInterSubnetBlocked] = useState(false); + // True once a first config has been resolved (backend or localStorage + // fallback): until then an empty subnet list means "not read yet". + const [loaded, setLoaded] = useState(false); + const refreshNetworkHealth = useCallback(() => { fetch(`${API_BASE}/api/projects/${projectId}/network-health`) .then(res => (res.ok ? res.json() : null)) @@ -128,7 +132,8 @@ export function useNetworkConfig({ projectId, containers, showNotification }: Us console.error(e); } } - }); + }) + .finally(() => setLoaded(true)); }, [projectId, defaultVpcConfig]); const triggerArchitectureAudit = useCallback((configToValidate: NetworkConfig) => { @@ -172,5 +177,5 @@ export function useNetworkConfig({ projectId, containers, showNotification }: Us } }, [containers, showNotification, t]); - return { networkConfig, saveNetworkConfig, fetchNetworkConfig, triggerArchitectureAudit, interSubnetBlocked }; + return { networkConfig, loaded, saveNetworkConfig, fetchNetworkConfig, triggerArchitectureAudit, interSubnetBlocked }; } diff --git a/frontend/src/pages/ProjectsPage/ProjectsPage.test.tsx b/frontend/src/pages/ProjectsPage/ProjectsPage.test.tsx new file mode 100644 index 0000000..26bda28 --- /dev/null +++ b/frontend/src/pages/ProjectsPage/ProjectsPage.test.tsx @@ -0,0 +1,364 @@ +import '../../i18n'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'; +import ProjectsPage from './ProjectsPage'; +import { hasSeenLearningPitch } from '../../features/learning/onboarding'; +import type { Project } from '../../shared/types'; +import type { ProgressEntrySummary, RoadmapSummary } from '../../shared/types/roadmap'; + +const projects: Project[] = [ + { id: 'p1', name: 'Lab one', createdAt: '2026-07-01T10:00:00.000Z' }, + { id: 'p2', name: 'Lab two', createdAt: '2026-07-02T10:00:00.000Z' }, +]; + +const summaries: RoadmapSummary[] = [ + { + id: 'resilient-three-tier', + title: 'Deploy a resilient three-tier app', + description: 'Build a three-tier architecture.', + language: 'en', + difficulty: 'intermediate', + estimatedMinutes: 40, + stepCount: 10, + }, + { + id: 'cache-aside-redis', + title: 'Cache-aside with Redis', + description: 'Add a Redis cache-aside layer.', + language: 'en', + difficulty: 'intermediate', + estimatedMinutes: 30, + stepCount: 8, + }, + { + id: 'cache-aside-redis', + title: 'Cache-aside avec Redis', + description: 'Ajoutez une couche de cache Redis.', + language: 'fr', + difficulty: 'intermediate', + estimatedMinutes: 30, + stepCount: 8, + }, +]; + +const progressEntries: ProgressEntrySummary[] = [ + { projectId: 'p1', roadmapId: 'cache-aside-redis', updatedAt: '2026-07-20T10:00:00.000Z', completedSteps: 3 }, +]; + +/** What GET /api/learning/roadmaps/:id returns for the briefing page. */ +const cacheAsideRoadmap = { + schemaVersion: 1, + id: 'cache-aside-redis', + title: 'Cache-aside with Redis', + description: 'Add a Redis cache-aside layer.', + language: 'en', + difficulty: 'intermediate', + estimatedMinutes: 30, + steps: [ + { + id: 'reopen-the-store', + title: 'Reopen the store', + instruction: 'Start the web node.', + validators: [{ type: 'container_running', params: { node: 'web' } }], + }, + { + id: 'add-the-cache', + title: 'Enter Redis', + instruction: 'Add a Redis node.', + validators: [{ type: 'redis_key_exists', params: { node: 'cache', key: 'cache:books' } }], + }, + ], +}; + +function jsonResponse(ok: boolean, body: unknown): Response { + return { ok, json: () => Promise.resolve(body) } as Response; +} + +/** + * The page fires parallel fetches on mount (projects, roadmaps, progress) — + * route by URL, never by call order. + */ +function buildFetchMock(handlers: { + projects?: () => Promise | Response; + createProject?: (body: unknown) => Response; + roadmaps?: () => Response; + roadmapDetail?: () => Response; + progress?: () => Response; + stepProgress?: () => Response; +} = {}) { + return vi.fn((url: string, init?: RequestInit) => { + if (url.includes('/health')) { + return Promise.resolve(jsonResponse(true, { status: 'ok', checks: { docker: { status: 'ok' } } })); + } + // One roadmap (briefing page) before the catalogue — both share a prefix. + if (url.includes('/api/learning/roadmaps/')) { + return Promise.resolve(handlers.roadmapDetail?.() ?? jsonResponse(true, cacheAsideRoadmap)); + } + if (url.includes('/api/learning/roadmaps')) { + return Promise.resolve(handlers.roadmaps?.() ?? jsonResponse(true, summaries)); + } + if (url.includes('/api/learning/progress/')) { + return Promise.resolve( + handlers.stepProgress?.() ?? + jsonResponse(true, { steps: { 'reopen-the-store': { passed: true, attempts: 1, revealedHints: 0 } } }) + ); + } + if (url.includes('/api/learning/progress')) { + return Promise.resolve(handlers.progress?.() ?? jsonResponse(true, { entries: progressEntries })); + } + if (url.includes('/api/projects')) { + if (init?.method === 'POST') { + const body = JSON.parse(String(init.body)); + return Promise.resolve( + handlers.createProject?.(body) ?? + jsonResponse(true, { id: 'new1', name: body.name, createdAt: '2026-07-22T10:00:00.000Z' }) + ); + } + return Promise.resolve(handlers.projects?.() ?? jsonResponse(true, { projects })); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); +} + +/** The learning view lives behind the side rail's Learning item. */ +function goToLearning() { + fireEvent.click(screen.getByRole('button', { name: 'Learning' })); +} + +/** + * A roadmap card opens the briefing page; launching happens from there. The + * resume card at the top of the page repeats the title of the roadmap in + * progress, so always take the last match — the one in the catalogue grid. + */ +async function openBriefing(title: string) { + const matches = await screen.findAllByText(title); + fireEvent.click(matches[matches.length - 1]); + return screen.findByRole('button', { name: /Launch lab|Continue · step/ }); +} + +describe('ProjectsPage', () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('shows skeleton cards while projects are loading', async () => { + // A projects request that never settles keeps the section in its loading state. + vi.stubGlobal('fetch', buildFetchMock({ projects: () => new Promise(() => {}) })); + render(); + + const section = screen.getByLabelText('Loading projects'); + expect(section.getAttribute('aria-busy')).toBe('true'); + expect(section.querySelectorAll('.skeleton').length).toBeGreaterThan(0); + }); + + it('shows a visible error block whose Retry refetches the projects', async () => { + let failures = 0; + const fetchMock = buildFetchMock({ + projects: () => { + failures += 1; + return failures === 1 + ? Promise.reject(new Error('network down')) + : jsonResponse(true, { projects }); + }, + }); + vi.stubGlobal('fetch', fetchMock); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + render(); + + expect(await screen.findByText('Could not load projects. Is the backend running?')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + expect(await screen.findByText('Lab one')).toBeInTheDocument(); + + errorSpy.mockRestore(); + }); + + it('renders project cards as buttons with a locale-formatted date', async () => { + vi.stubGlobal('fetch', buildFetchMock()); + const onSelectProject = vi.fn(); + render(); + + const card = await screen.findByRole('button', { name: /Lab one/ }); + expect(card.tagName).toBe('BUTTON'); + expect(screen.getByText('Jul 1, 2026')).toBeInTheDocument(); + + fireEvent.click(card); + expect(onSelectProject).toHaveBeenCalledWith('p1', 'Lab one'); + }); + + it('opens on the projects view and switches to learning through the side rail', async () => { + vi.stubGlobal('fetch', buildFetchMock()); + render(); + + expect(await screen.findByText('Lab one')).toBeInTheDocument(); + expect(screen.queryByText('Resume')).toBeNull(); + + goToLearning(); + + // With saved progress the page opens on the resume card, not the pitch. + expect(await screen.findByText('Resume')).toBeInTheDocument(); + expect(screen.queryByText('Lab one')).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: 'Projects' })); + expect(await screen.findByText('Lab one')).toBeInTheDocument(); + }); + + it('sends the first-run hero "Start learning" to the learning view', async () => { + vi.stubGlobal('fetch', buildFetchMock({ projects: () => jsonResponse(true, { projects: [] }) })); + render(); + + expect(await screen.findByText('Build real infrastructure on your machine.')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Start learning' })); + + expect(await screen.findByText('Resume')).toBeInTheDocument(); + }); + + it('lists roadmaps of the UI language only, started ones first with a continue label', async () => { + vi.stubGlobal('fetch', buildFetchMock()); + render(); + goToLearning(); + + const cards = await screen.findAllByText('Cache-aside with Redis'); + const started = cards[cards.length - 1]; + expect(screen.queryByText('Cache-aside avec Redis')).toBeNull(); + expect(screen.getByText('Continue · step 4 of 8')).toBeInTheDocument(); + + const other = screen.getByText('Deploy a resilient three-tier app'); + // Started roadmap sorts before the untouched one. + expect( + started.compareDocumentPosition(other) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + }); + + it('opens the briefing page from a roadmap card, with a breadcrumb back to the catalogue', async () => { + vi.stubGlobal('fetch', buildFetchMock()); + render(); + goToLearning(); + + await openBriefing('Cache-aside with Redis'); + // Real content of the roadmap file, not the catalogue summary. + expect(screen.getByText('Reopen the store')).toBeInTheDocument(); + expect(screen.getByText('Enter Redis')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Roadmaps' })); + expect(await screen.findByText('Resume')).toBeInTheDocument(); + }); + + it('deep-links a started roadmap straight into the project it was played in', async () => { + vi.stubGlobal('fetch', buildFetchMock()); + const onSelectProject = vi.fn(); + render(); + goToLearning(); + + fireEvent.click(await openBriefing('Cache-aside with Redis')); + + expect(onSelectProject).toHaveBeenCalledWith('p1', 'Lab one', { + roadmap: { id: 'cache-aside-redis', language: 'en' }, + }); + }); + + it('asks which project to use for an unstarted roadmap when several exist', async () => { + vi.stubGlobal('fetch', buildFetchMock()); + const onSelectProject = vi.fn(); + render(); + goToLearning(); + + fireEvent.click(await openBriefing('Deploy a resilient three-tier app')); + + const modalTitle = await screen.findByText('Choose a project'); + const modalPanel = modalTitle.parentElement as HTMLElement; + fireEvent.click(within(modalPanel).getByRole('button', { name: /Lab two/ })); + + expect(onSelectProject).toHaveBeenCalledWith('p2', 'Lab two', { + roadmap: { id: 'resilient-three-tier', language: 'en' }, + }); + }); + + it('shows the pitch and the sample receipt only until a roadmap is started', async () => { + vi.stubGlobal('fetch', buildFetchMock({ progress: () => jsonResponse(true, { entries: [] }) })); + const { unmount } = render(); + goToLearning(); + + expect(await screen.findByText('Learn system design by running real infrastructure.')).toBeInTheDocument(); + expect(screen.getByText('Sample validation receipt')).toBeInTheDocument(); + expect(screen.queryByText('Resume')).toBeNull(); + + unmount(); + vi.stubGlobal('fetch', buildFetchMock()); + render(); + goToLearning(); + + expect(await screen.findByText('Resume')).toBeInTheDocument(); + expect(screen.queryByText('Learn system design by running real infrastructure.')).toBeNull(); + expect(screen.queryByText('Sample validation receipt')).toBeNull(); + }); + + it('creates "My first lab" when a roadmap is started with zero projects', async () => { + const fetchMock = buildFetchMock({ + projects: () => jsonResponse(true, { projects: [] }), + progress: () => jsonResponse(true, { entries: [] }), + }); + vi.stubGlobal('fetch', fetchMock); + const onSelectProject = vi.fn(); + render(); + goToLearning(); + + fireEvent.click(await openBriefing('Cache-aside with Redis')); + + await waitFor(() => { + expect(onSelectProject).toHaveBeenCalledWith('new1', 'My first lab', { + roadmap: { id: 'cache-aside-redis', language: 'en' }, + }); + }); + const createCall = fetchMock.mock.calls.find(call => call[1]?.method === 'POST'); + expect(JSON.parse(String(createCall?.[1]?.body))).toEqual({ name: 'My first lab' }); + }); + + it('pitches the first roadmap of the catalogue, not the one touched last', async () => { + // A play-through with zero passed steps keeps this a first run while + // sorting cache-aside on top of the list: the pitch must ignore that and + // open the roadmap the catalogue leads with. + vi.stubGlobal('fetch', buildFetchMock({ + projects: () => jsonResponse(true, { projects: [] }), + progress: () => jsonResponse(true, { + entries: [{ projectId: 'p1', roadmapId: 'cache-aside-redis', updatedAt: '2026-07-20T10:00:00.000Z', completedSteps: 0 }], + }), + })); + render(); + goToLearning(); + + fireEvent.click(await screen.findByRole('button', { name: 'View the roadmap' })); + + expect( + await screen.findByRole('heading', { name: 'Deploy a resilient three-tier app' }) + ).toBeInTheDocument(); + }); + + it('never pitches again once a roadmap has been launched', async () => { + const noProgress = { + projects: () => jsonResponse(true, { projects: [] }), + progress: () => jsonResponse(true, { entries: [] }), + }; + vi.stubGlobal('fetch', buildFetchMock(noProgress)); + const { unmount } = render(); + goToLearning(); + + fireEvent.click(await openBriefing('Cache-aside with Redis')); + await waitFor(() => expect(hasSeenLearningPitch()).toBe(true)); + + // Same blank slate on the backend — nothing was validated — but the pitch + // has been acted on, so the page opens straight on the catalogue. + unmount(); + vi.stubGlobal('fetch', buildFetchMock(noProgress)); + render(); + goToLearning(); + + expect(await screen.findByText('Roadmaps')).toBeInTheDocument(); + expect(screen.queryByText('Learn system design by running real infrastructure.')).toBeNull(); + expect(screen.queryByText('Sample validation receipt')).toBeNull(); + }); +}); diff --git a/frontend/src/pages/ProjectsPage/ProjectsPage.tsx b/frontend/src/pages/ProjectsPage/ProjectsPage.tsx index f68fa7f..1a73b64 100644 --- a/frontend/src/pages/ProjectsPage/ProjectsPage.tsx +++ b/frontend/src/pages/ProjectsPage/ProjectsPage.tsx @@ -1,42 +1,62 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { AlertTriangle, Plus, X } from 'lucide-react'; +import { AlertTriangle, X } from 'lucide-react'; import InputModal from '../../shared/components/InputModal'; import ConfirmModal from '../../shared/components/ConfirmModal'; -import ProjectCard from './components/ProjectCard'; -import EmptyState from './components/EmptyState'; +import PageHeader from './components/PageHeader'; +import SideRail from './components/SideRail'; +import ProjectsSection from './components/ProjectsSection'; +import type { HomeView } from './components/SideRail'; +import ProjectPickerModal from './components/ProjectPickerModal'; +import LearningSection from './components/learning/LearningSection'; +import RoadmapDetailPage from './components/learning/detail/RoadmapDetailPage'; +import { filterByUiLanguage } from '../../features/learning/roadmapLanguage'; +import { markLearningPitchSeen } from '../../features/learning/onboarding'; import { API_BASE } from '../../shared/types'; -import type { Project } from '../../shared/types'; -import logo from '../../assets/logo.png'; +import type { LearningIntent, Project } from '../../shared/types'; +import type { ProgressEntrySummary, RoadmapSummary } from '../../shared/types/roadmap'; interface ProjectsPageProps { - onSelectProject: (id: string, name: string) => void; + onSelectProject: (id: string, name: string, intent?: LearningIntent) => void; } -declare const __APP_VERSION__: string; +/** + * Where the home shell is. The roadmap briefing is a sub-page of Learning: + * the rail stays on Learning while it is open, and the header shows a + * breadcrumb back to the catalogue. + */ +type HomeRoute = + | { kind: 'projects' } + | { kind: 'learning' } + | { kind: 'roadmap'; summary: RoadmapSummary; progress?: ProgressEntrySummary }; export default function ProjectsPage({ onSelectProject }: ProjectsPageProps) { const { t, i18n } = useTranslation(); + // Which home view the side rail is on; session-only, Projects first. + const [route, setRoute] = useState({ kind: 'projects' }); const [projects, setProjects] = useState([]); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); const [deletingIds, setDeletingIds] = useState([]); const [storeRecovered, setStoreRecovered] = useState(false); - - const toggleLanguage = () => { - const nextLang = i18n.language === 'fr' ? 'en' : 'fr'; - i18n.changeLanguage(nextLang); - localStorage.setItem('torollo_lang', nextLang); - }; + // Roadmap waiting for a project choice in the picker modal. + const [pickerTarget, setPickerTarget] = useState(null); + // Learning intent to apply to the next project created through the input + // modal (picker → "New project", or a failed auto-create fallback). + const pendingIntentRef = useRef(null); const fetchProjects = async () => { try { setLoading(true); + setLoadError(false); const res = await fetch(`${API_BASE}/api/projects`); const data = await res.json(); - if (Array.isArray(data?.projects)) { + if (res.ok && Array.isArray(data?.projects)) { setProjects(data.projects); + } else { + setLoadError(true); } // One-shot notice from the backend: the projects file was unreadable // and has been moved aside — keep the banner up until dismissed. @@ -44,7 +64,8 @@ export default function ProjectsPage({ onSelectProject }: ProjectsPageProps) { setStoreRecovered(true); } } catch (err) { - console.error(err); + console.error('Failed to fetch projects:', err); + setLoadError(true); } finally { setLoading(false); } @@ -54,19 +75,54 @@ export default function ProjectsPage({ onSelectProject }: ProjectsPageProps) { fetchProjects(); }, []); + // A roadmap file holds one language, so switching the UI language must swap + // the open briefing for its translation — or leave it when there is none. + useEffect(() => { + if (route.kind !== 'roadmap' || route.summary.language === i18n.language) return; + let cancelled = false; + (async () => { + try { + const res = await fetch(`${API_BASE}/api/learning/roadmaps`); + const data = await res.json(); + if (cancelled || !res.ok || !Array.isArray(data)) return; + const translated = filterByUiLanguage(data as RoadmapSummary[], i18n.language).find( + summary => summary.id === route.summary.id + ); + setRoute(current => + current.kind === 'roadmap' && current.summary.id === route.summary.id + ? translated + ? { ...current, summary: translated } + : { kind: 'learning' } + : current + ); + } catch (err) { + console.error('Failed to resolve the roadmap translation:', err); + } + })(); + return () => { + cancelled = true; + }; + }, [route, i18n.language]); + const handleCreateProject = async (name: string) => { const res = await fetch(`${API_BASE}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }), }); - if (res.ok) { - fetchProjects(); - setShowCreateModal(false); - } else { + if (!res.ok) { const errorData = await res.json(); throw new Error(errorData.error || 'Failed to create project'); } + const project: Project = await res.json(); + const intent = pendingIntentRef.current; + pendingIntentRef.current = null; + setShowCreateModal(false); + if (intent) { + onSelectProject(project.id, project.name, intent); + return; + } + fetchProjects(); }; const handleDeleteConfirmed = async () => { @@ -83,74 +139,130 @@ export default function ProjectsPage({ onSelectProject }: ProjectsPageProps) { console.error(err); } finally { await fetchProjects(); - setDeletingIds(prev => prev.filter((x) => x !== id)); + setDeletingIds(prev => prev.filter(x => x !== id)); } }; - const handleDeleteClick = (project: Project, event: React.MouseEvent) => { - event.stopPropagation(); - setDeleteTarget(project); + /** Creates a project without a dialog (DESIGN §4.1's "Start learning" path). */ + const autoCreateAndOpen = async (intent: LearningIntent) => { + try { + const res = await fetch(`${API_BASE}/api/projects`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: t('projects.firstLabName') }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const project: Project = await res.json(); + onSelectProject(project.id, project.name, intent); + } catch (err) { + // Fall back to the standard dialog, which surfaces submit errors itself. + console.error('Failed to auto-create a project:', err); + pendingIntentRef.current = intent; + setShowCreateModal(true); + } }; - return ( -
- {/* Page header */} -
-
-
- Logo -
-
-
-

{t('projects.title')}

- v{__APP_VERSION__} -
-

{t('projects.subtitle')}

-
-
-
- - -
-
+ /** + * A roadmap runs against one project's containers. Resolution order: + * resume where it was last played → the only project → ask → create one. + */ + const startRoadmap = (summary: RoadmapSummary, progress?: ProgressEntrySummary) => { + const intent: LearningIntent = { roadmap: { id: summary.id, language: summary.language } }; + // Whatever happens next — picker, auto-create, resume — the first-run pitch + // has been acted on and must never be proposed again. + markLearningPitchSeen(); + if (progress) { + const played = projects.find(p => p.id === progress.projectId); + if (played) { + onSelectProject(played.id, played.name, intent); + return; + } + } + if (projects.length === 1) { + onSelectProject(projects[0].id, projects[0].name, intent); + return; + } + if (projects.length === 0) { + autoCreateAndOpen(intent); + return; + } + setPickerTarget(summary); + }; - {storeRecovered && ( -
- - {t('projects.storeRecovered')} - -
- )} + const goToLearning = () => setRoute({ kind: 'learning' }); - {loading &&

{t('projects.loading')}

} + return ( +
+ setRoute({ kind: view })} + /> - {/* Project grid */} -
- {projects.map((p) => ( - +
+ setShowCreateModal(true)} + breadcrumb={ + route.kind === 'roadmap' + ? [ + { label: t('learning.detail.breadcrumbLearning'), onClick: goToLearning }, + { label: t('learning.detail.breadcrumbRoadmaps'), onClick: goToLearning }, + { label: route.summary.title }, + ] + : undefined + } /> - ))} - {!loading && projects.length === 0 && } + + {route.kind === 'projects' ? ( + <> + {storeRecovered && ( +
+ + {t('projects.storeRecovered')} + +
+ )} + + setDeleteTarget(project)} + deletingIds={deletingIds} + onStartLearning={goToLearning} + onStartScratch={() => setShowCreateModal(true)} + /> + + ) : route.kind === 'learning' ? ( + setRoute({ kind: 'roadmap', summary, progress })} + /> + ) : ( + p.id === route.progress?.projectId)?.name} + onLaunch={() => startRoadmap(route.summary, route.progress)} + onProgressCleared={() => + setRoute(current => + current.kind === 'roadmap' ? { ...current, progress: undefined } : current + ) + } + /> + )} +
{/* Modals */} @@ -161,111 +273,82 @@ export default function ProjectsPage({ onSelectProject }: ProjectsPageProps) { placeholder={t('projects.createPlaceholder')} submitText={t('projects.createSubmit')} onSubmit={handleCreateProject} - onCancel={() => setShowCreateModal(false)} + onCancel={() => { + pendingIntentRef.current = null; + setShowCreateModal(false); + }} /> )} {deleteTarget && ( setDeleteTarget(null)} /> )} + + {pickerTarget && ( + { + const target = pickerTarget; + setPickerTarget(null); + onSelectProject(project.id, project.name, { + roadmap: { id: target.id, language: target.language }, + }); + }} + onNewProject={() => { + pendingIntentRef.current = { + roadmap: { id: pickerTarget.id, language: pickerTarget.language }, + }; + setPickerTarget(null); + setShowCreateModal(true); + }} + onCancel={() => setPickerTarget(null)} + /> + )}
); } const styles: Record = { + shell: { + display: 'flex', + height: '100%', + minHeight: 0, + }, + content: { + flex: 1, + minWidth: 0, + overflowY: 'auto', + }, container: { - padding: '48px 60px', + padding: 'var(--space-8) 60px', maxWidth: '1200px', margin: '0 auto', width: '100%', boxSizing: 'border-box', - overflowY: 'auto', - height: '100%', - }, - header: { - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: '40px', - }, - logoRow: { - display: 'flex', - alignItems: 'center', - gap: '14px', - }, - iconWrap: { - width: '44px', - height: '44px', - borderRadius: '50%', - background: 'var(--bg-surface-solid)', - border: '1px solid rgba(0, 0, 0, 0.08)', - boxShadow: '0 2px 8px rgba(0, 0, 0, 0.05)', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - }, - title: { - fontSize: '24px', - fontWeight: 700, - color: 'var(--color-text-primary)', - margin: 0, - letterSpacing: '-0.5px', - }, - subtitle: { - fontSize: '13px', - color: 'var(--color-text-muted)', - margin: '2px 0 0 0', - }, - badge: { - fontSize: '11px', - fontWeight: 600, - backgroundColor: 'var(--color-accent-glow)', - color: 'var(--color-accent)', - padding: '2px 10px', - borderRadius: '12px', - border: '1px solid color-mix(in srgb, var(--color-accent) 20%, transparent)', - marginTop: '4px', - }, - createBtn: { - backgroundColor: 'var(--color-accent)', - color: 'var(--color-white)', - border: 'none', - borderRadius: '10px', - padding: '0 20px', - height: '42px', - fontSize: '13px', - fontWeight: 600, - fontFamily: 'var(--font-sans)', - cursor: 'pointer', display: 'flex', - alignItems: 'center', - transition: 'all 0.2s', - boxShadow: '0 1px 3px color-mix(in srgb, var(--color-accent) 30%, transparent)', - }, - loading: { - color: 'var(--color-text-secondary)', - fontSize: '14px', + flexDirection: 'column', + gap: 'var(--space-6)', }, noticeBox: { display: 'flex', alignItems: 'flex-start', - gap: '6px', - padding: '8px 10px', + gap: 'var(--space-2)', + padding: 'var(--space-2) var(--space-3)', border: '1px solid var(--color-warning)', - borderRadius: '6px', + borderRadius: 'var(--radius-sm)', backgroundColor: 'var(--color-warning-glow)', - marginBottom: '20px', }, noticeText: { flex: 1, - fontSize: '12px', + fontSize: 'var(--text-sm)', color: 'var(--color-warning-strong)', lineHeight: 1.5, }, @@ -279,9 +362,4 @@ const styles: Record = { cursor: 'pointer', color: 'var(--color-warning-strong)', }, - grid: { - display: 'grid', - gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', - gap: '20px', - }, }; diff --git a/frontend/src/pages/ProjectsPage/components/EmptyState.tsx b/frontend/src/pages/ProjectsPage/components/EmptyState.tsx deleted file mode 100644 index f386407..0000000 --- a/frontend/src/pages/ProjectsPage/components/EmptyState.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Folder } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; - -export default function EmptyState() { - const { t } = useTranslation(); - return ( -
-
- -
-

{t('projects.emptyTitle')}

-

- {t('projects.emptyDesc')} -

-
- ); -} - -const styles: Record = { - empty: { - gridColumn: '1 / -1', - textAlign: 'center', - padding: '80px 0', - }, - emptyIcon: { - marginBottom: '16px', - }, - emptyTitle: { - fontSize: '16px', - fontWeight: 600, - color: 'var(--color-text-primary)', - margin: '0 0 6px 0', - }, - emptyDesc: { - fontSize: '13px', - color: 'var(--color-text-muted)', - margin: 0, - }, -}; diff --git a/frontend/src/pages/ProjectsPage/components/FirstRunHero.tsx b/frontend/src/pages/ProjectsPage/components/FirstRunHero.tsx new file mode 100644 index 0000000..79ab5a2 --- /dev/null +++ b/frontend/src/pages/ProjectsPage/components/FirstRunHero.tsx @@ -0,0 +1,128 @@ +import { useTranslation } from 'react-i18next'; +import { Database } from 'lucide-react'; +import Button from '../../../shared/components/Button'; +import Receipt from '../../../shared/components/Receipt'; + +interface FirstRunHeroProps { + onStartLearning: () => void; + onStartScratch: () => void; +} + +/** + * Shown instead of the project grid while no project exists: the page must + * *be* the pitch, not assume the user already knows Torollo (DESIGN §4.1). + */ +export default function FirstRunHero({ onStartLearning, onStartScratch }: FirstRunHeroProps) { + const { t } = useTranslation(); + return ( +
+
+

{t('projects.firstRun.title')}

+

{t('projects.firstRun.body')}

+
+ + +
+
+ {/* Mini-canvas vignette in the product's real visual language: one + node card with a live status dot, plus a receipt. Decorative. */} + +
+ ); +} + +const styles: Record = { + hero: { + display: 'flex', + alignItems: 'center', + gap: 'var(--space-8)', + flexWrap: 'wrap', + padding: 'var(--space-8)', + background: 'var(--bg-surface-solid)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-lg)', + }, + copy: { + flex: '2 1 380px', + display: 'flex', + flexDirection: 'column', + gap: 'var(--space-4)', + }, + title: { + fontSize: 'var(--text-2xl)', + fontWeight: 700, + color: 'var(--color-text-primary)', + margin: 0, + letterSpacing: '-0.5px', + }, + body: { + fontSize: 'var(--text-md)', + color: 'var(--color-text-secondary)', + lineHeight: 1.6, + margin: 0, + maxWidth: '520px', + }, + ctaRow: { + display: 'flex', + gap: 'var(--space-3)', + flexWrap: 'wrap', + }, + visual: { + flex: '1 1 260px', + display: 'flex', + flexDirection: 'column', + gap: 'var(--space-3)', + maxWidth: '320px', + }, + nodeCard: { + display: 'flex', + alignItems: 'center', + gap: 'var(--space-3)', + padding: 'var(--space-3) var(--space-4)', + background: 'var(--bg-surface-solid)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-lg)', + }, + nodeIcon: { + width: '32px', + height: '32px', + borderRadius: 'var(--radius-md)', + background: 'var(--bg-subtle)', + border: '1px solid var(--border-color)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + nodeName: { + fontSize: 'var(--text-md)', + fontWeight: 600, + color: 'var(--color-text-primary)', + }, + nodeSub: { + fontSize: 'var(--text-xs)', + color: 'var(--color-text-muted)', + }, + statusDot: { + width: '8px', + height: '8px', + borderRadius: '50%', + background: 'var(--color-success)', + marginLeft: 'auto', + }, +}; diff --git a/frontend/src/pages/ProjectsPage/components/PageHeader.tsx b/frontend/src/pages/ProjectsPage/components/PageHeader.tsx new file mode 100644 index 0000000..2be1c37 --- /dev/null +++ b/frontend/src/pages/ProjectsPage/components/PageHeader.tsx @@ -0,0 +1,181 @@ +import { useTranslation } from 'react-i18next'; +import { BookOpen, ChevronRight, Plus } from 'lucide-react'; +import Button from '../../../shared/components/Button'; +import logo from '../../../assets/logo.png'; + +export interface BreadcrumbItem { + label: string; + /** Navigates when set; the last crumb (the current page) has none. */ + onClick?: () => void; +} + +interface PageHeaderProps { + onNewProject: () => void; + /** Replaces the logo + title block when the shell is on a sub-page. */ + breadcrumb?: BreadcrumbItem[]; +} + +declare const __APP_VERSION__: string; + +const DOCS_URL = 'https://github.com/Derssa/torollo#readme'; + +export default function PageHeader({ onNewProject, breadcrumb }: PageHeaderProps) { + const { t, i18n } = useTranslation(); + + const toggleLanguage = () => { + const nextLang = i18n.language === 'fr' ? 'en' : 'fr'; + i18n.changeLanguage(nextLang); + localStorage.setItem('torollo_lang', nextLang); + }; + + return ( +
+ ); +} + +const styles: Record = { + header: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + gap: 'var(--space-4)', + flexWrap: 'wrap', + }, + logoRow: { + display: 'flex', + alignItems: 'center', + gap: 'var(--space-4)', + }, + breadcrumb: { + display: 'flex', + alignItems: 'center', + flexWrap: 'wrap', + gap: 'var(--space-1)', + minWidth: 0, + }, + crumbRow: { + display: 'inline-flex', + alignItems: 'center', + gap: 'var(--space-1)', + minWidth: 0, + }, + crumbLink: { + background: 'none', + border: 'none', + padding: 'var(--space-1) var(--space-2)', + borderRadius: 'var(--radius-sm)', + color: 'var(--color-accent)', + fontSize: 'var(--text-md)', + fontWeight: 600, + cursor: 'pointer', + }, + crumbCurrent: { + padding: 'var(--space-1) var(--space-2)', + fontSize: 'var(--text-md)', + fontWeight: 600, + color: 'var(--color-text-primary)', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + iconWrap: { + width: '44px', + height: '44px', + borderRadius: '50%', + background: 'var(--bg-surface-solid)', + border: '1px solid var(--border-color)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + logoImg: { + width: '28px', + height: '28px', + objectFit: 'contain', + }, + titleRow: { + display: 'flex', + alignItems: 'center', + gap: 'var(--space-3)', + }, + title: { + fontSize: 'var(--text-2xl)', + fontWeight: 700, + color: 'var(--color-text-primary)', + margin: 0, + letterSpacing: '-0.5px', + }, + subtitle: { + fontSize: 'var(--text-md)', + color: 'var(--color-text-muted)', + margin: '2px 0 0 0', + }, + // The version is a footnote, not a status — plain muted text, no pill. + badge: { + fontSize: 'var(--text-xs)', + fontWeight: 500, + color: 'var(--color-text-muted)', + }, + actions: { + display: 'flex', + gap: 'var(--space-3)', + alignItems: 'center', + }, +}; diff --git a/frontend/src/pages/ProjectsPage/components/ProjectCard.tsx b/frontend/src/pages/ProjectsPage/components/ProjectCard.tsx index 515f3b3..fc72d77 100644 --- a/frontend/src/pages/ProjectsPage/components/ProjectCard.tsx +++ b/frontend/src/pages/ProjectsPage/components/ProjectCard.tsx @@ -5,96 +5,100 @@ import type { Project } from '../../../shared/types'; interface ProjectCardProps { project: Project; onSelect: (id: string, name: string) => void; - onDelete: (project: Project, event: React.MouseEvent) => void; + onDelete: (project: Project) => void; isDeleting?: boolean; } export default function ProjectCard({ project, onSelect, onDelete, isDeleting }: ProjectCardProps) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); return ( -
!isDeleting && onSelect(project.id, project.name)} - style={{ - ...styles.card, - position: 'relative', - opacity: isDeleting ? 0.7 : 1, - pointerEvents: isDeleting ? 'none' : 'auto', - }} - id={`project-card-${project.id}`} - > + // Wrapper so the delete control can be a *sibling* of the card button — + // a button nested inside a button is invalid HTML and breaks focus order. +
+ + {isDeleting && (
- {t('projects.deleting')} + {t('projects.deleting')}
)} -
-
- -
- -
-
-

{project.name}

-

- {new Date(project.createdAt).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - })} -

-
-
- {t('projects.openStack')} - -
); } const styles: Record = { + wrapper: { + position: 'relative', + }, card: { - padding: '24px', - borderRadius: '16px', + width: '100%', + padding: 'var(--space-6)', + borderRadius: 'var(--radius-lg)', cursor: 'pointer', - transition: 'transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s', + transition: 'border-color 0.2s ease', display: 'flex', flexDirection: 'column', + alignItems: 'stretch', + textAlign: 'left', height: '190px', justifyContent: 'space-between', backgroundColor: 'var(--bg-surface-solid)', border: '1px solid var(--border-color)', - boxShadow: '0 1px 3px rgba(0, 0, 0, 0.04)', - }, - cardHeader: { - display: 'flex', - justifyContent: 'space-between', - alignItems: 'flex-start', + fontFamily: 'var(--font-sans)', }, cardIcon: { - width: '40px', - height: '40px', - borderRadius: '10px', - background: 'var(--color-accent-glow)', + width: '34px', + height: '34px', + borderRadius: 'var(--radius-md)', + background: 'var(--bg-subtle)', + border: '1px solid var(--border-color)', display: 'flex', alignItems: 'center', justifyContent: 'center', }, deleteBtn: { + position: 'absolute', + top: 'var(--space-5)', + right: 'var(--space-5)', background: 'none', border: 'none', color: 'var(--color-text-muted)', cursor: 'pointer', - padding: '6px', - borderRadius: '6px', + padding: 'var(--space-2)', + borderRadius: 'var(--radius-sm)', display: 'flex', alignItems: 'center', transition: 'color 0.2s, background 0.2s', @@ -109,11 +113,11 @@ const styles: Record = { fontSize: '16px', fontWeight: 600, color: 'var(--color-text-primary)', - margin: '0 0 4px 0', + margin: '0 0 var(--space-1) 0', letterSpacing: '-0.2px', }, projectMeta: { - fontSize: '12px', + fontSize: 'var(--text-sm)', color: 'var(--color-text-muted)', margin: 0, }, @@ -121,21 +125,24 @@ const styles: Record = { display: 'flex', alignItems: 'center', color: 'var(--color-accent)', - fontSize: '13px', + fontSize: 'var(--text-md)', fontWeight: 500, }, loadingOverlay: { position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: 'rgba(255, 255, 255, 0.75)', + inset: 0, + backgroundColor: 'var(--bg-surface)', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', - borderRadius: '16px', + borderRadius: 'var(--radius-lg)', zIndex: 10, }, + deletingLabel: { + fontSize: 'var(--text-xs)', + marginTop: 'var(--space-2)', + color: 'var(--color-text-secondary)', + fontWeight: 600, + }, }; diff --git a/frontend/src/pages/ProjectsPage/components/ProjectCardSkeleton.tsx b/frontend/src/pages/ProjectsPage/components/ProjectCardSkeleton.tsx new file mode 100644 index 0000000..0949b2e --- /dev/null +++ b/frontend/src/pages/ProjectsPage/components/ProjectCardSkeleton.tsx @@ -0,0 +1,34 @@ +import Skeleton from '../../../shared/components/Skeleton'; + +/** Same 190px frame as ProjectCard, sketched with placeholder blocks. */ +export default function ProjectCardSkeleton() { + return ( + + ); +} + +const styles: Record = { + card: { + padding: 'var(--space-6)', + borderRadius: 'var(--radius-lg)', + display: 'flex', + flexDirection: 'column', + height: '190px', + justifyContent: 'space-between', + backgroundColor: 'var(--bg-surface-solid)', + border: '1px solid var(--border-color)', + boxSizing: 'border-box', + }, + body: { + display: 'flex', + flexDirection: 'column', + gap: 'var(--space-2)', + }, +}; diff --git a/frontend/src/pages/ProjectsPage/components/ProjectPickerModal.tsx b/frontend/src/pages/ProjectsPage/components/ProjectPickerModal.tsx new file mode 100644 index 0000000..9cfdc80 --- /dev/null +++ b/frontend/src/pages/ProjectsPage/components/ProjectPickerModal.tsx @@ -0,0 +1,96 @@ +import { useTranslation } from 'react-i18next'; +import { ArrowRight, Folder, Plus } from 'lucide-react'; +import Modal from '../../../shared/components/Modal'; +import Button from '../../../shared/components/Button'; +import type { Project } from '../../../shared/types'; + +interface ProjectPickerModalProps { + projects: Project[]; + roadmapTitle: string; + onPick: (project: Project) => void; + onNewProject: () => void; + onCancel: () => void; +} + +/** A roadmap validates against one project's containers — ask which one. */ +export default function ProjectPickerModal({ + projects, + roadmapTitle, + onPick, + onNewProject, + onCancel, +}: ProjectPickerModalProps) { + const { t } = useTranslation(); + return ( + +

{t('learning.landing.pickProjectTitle')}

+

{t('learning.landing.pickProjectBody', { roadmap: roadmapTitle })}

+
+ {projects.map(project => ( + + ))} +
+
+ + +
+
+ ); +} + +const styles: Record = { + title: { + fontSize: 'var(--text-xl)', + fontWeight: 700, + color: 'var(--color-text-primary)', + margin: '0 0 var(--space-2) 0', + }, + body: { + fontSize: 'var(--text-md)', + color: 'var(--color-text-secondary)', + lineHeight: 1.5, + margin: '0 0 var(--space-4) 0', + }, + list: { + display: 'flex', + flexDirection: 'column', + gap: 'var(--space-2)', + maxHeight: '260px', + overflowY: 'auto', + marginBottom: 'var(--space-4)', + }, + row: { + display: 'flex', + alignItems: 'center', + gap: 'var(--space-3)', + padding: 'var(--space-3) var(--space-4)', + background: 'var(--bg-surface-solid)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-md)', + cursor: 'pointer', + textAlign: 'left', + fontFamily: 'var(--font-sans)', + transition: 'border-color 0.15s ease', + }, + rowName: { + flex: 1, + fontSize: 'var(--text-md)', + fontWeight: 600, + color: 'var(--color-text-primary)', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + footer: { + display: 'flex', + justifyContent: 'space-between', + gap: 'var(--space-3)', + }, +}; diff --git a/frontend/src/pages/ProjectsPage/components/ProjectsSection.tsx b/frontend/src/pages/ProjectsPage/components/ProjectsSection.tsx new file mode 100644 index 0000000..4d681d7 --- /dev/null +++ b/frontend/src/pages/ProjectsPage/components/ProjectsSection.tsx @@ -0,0 +1,113 @@ +import { useTranslation } from 'react-i18next'; +import Button from '../../../shared/components/Button'; +import ProjectCard from './ProjectCard'; +import ProjectCardSkeleton from './ProjectCardSkeleton'; +import FirstRunHero from './FirstRunHero'; +import type { Project } from '../../../shared/types'; + +interface ProjectsSectionProps { + projects: Project[]; + loading: boolean; + error: boolean; + onRetry: () => void; + onSelect: (id: string, name: string) => void; + onDelete: (project: Project) => void; + deletingIds: string[]; + onStartLearning: () => void; + onStartScratch: () => void; +} + +export default function ProjectsSection({ + projects, + loading, + error, + onRetry, + onSelect, + onDelete, + deletingIds, + onStartLearning, + onStartScratch, +}: ProjectsSectionProps) { + const { t } = useTranslation(); + + if (loading) { + return ( +
+ {t('projects.sectionTitle')} +
+ + + +
+
+ ); + } + + if (error) { + return ( +
+ {t('projects.sectionTitle')} +
+ {t('projects.loadError')} + +
+
+ ); + } + + if (projects.length === 0) { + return ( +
+ +
+ ); + } + + return ( +
+ {t('projects.sectionTitle')} +
+ {projects.map(p => ( + + ))} +
+
+ ); +} + +const styles: Record = { + eyebrow: { + display: 'block', + fontSize: 'var(--text-xs)', + fontWeight: 500, + textTransform: 'uppercase', + letterSpacing: '0.5px', + color: 'var(--color-text-muted)', + marginBottom: 'var(--space-3)', + }, + grid: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', + gap: 'var(--space-5)', + }, + errorBox: { + display: 'flex', + alignItems: 'center', + gap: 'var(--space-4)', + padding: 'var(--space-4) var(--space-5)', + border: '1px solid color-mix(in srgb, var(--color-danger) 30%, transparent)', + background: 'var(--color-danger-glow)', + borderRadius: 'var(--radius-md)', + }, + errorText: { + flex: 1, + fontSize: 'var(--text-md)', + color: 'var(--color-text-primary)', + }, +}; diff --git a/frontend/src/pages/ProjectsPage/components/SideRail.tsx b/frontend/src/pages/ProjectsPage/components/SideRail.tsx new file mode 100644 index 0000000..d9cd548 --- /dev/null +++ b/frontend/src/pages/ProjectsPage/components/SideRail.tsx @@ -0,0 +1,75 @@ +import { useTranslation } from 'react-i18next'; +import { LayoutGrid, GraduationCap } from 'lucide-react'; +import logo from '../../../assets/logo.png'; + +export type HomeView = 'projects' | 'learning'; + +interface SideRailProps { + view: HomeView; + onNavigate: (view: HomeView) => void; +} + +/** Dark icon rail on the home shell — switches between the two home views. */ +export default function SideRail({ view, onNavigate }: SideRailProps) { + const { t } = useTranslation(); + + const items = [ + { key: 'projects', label: t('nav.projects'), Icon: LayoutGrid }, + { key: 'learning', label: t('nav.learning'), Icon: GraduationCap }, + ] as const; + + return ( + + ); +} + +const styles: Record = { + rail: { + width: '56px', + flexShrink: 0, + background: 'var(--bg-rail)', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + padding: 'var(--space-4) 0', + gap: 'var(--space-6)', + }, + logoTile: { + width: '36px', + height: '36px', + borderRadius: 'var(--radius-md)', + background: 'var(--bg-surface-solid)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + logoImg: { + width: '24px', + height: '24px', + objectFit: 'contain', + }, + items: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: 'var(--space-2)', + }, +}; diff --git a/frontend/src/pages/ProjectsPage/components/learning/LearningHero.tsx b/frontend/src/pages/ProjectsPage/components/learning/LearningHero.tsx new file mode 100644 index 0000000..954e068 --- /dev/null +++ b/frontend/src/pages/ProjectsPage/components/learning/LearningHero.tsx @@ -0,0 +1,81 @@ +import { useTranslation } from 'react-i18next'; +import { ArrowRight, GraduationCap } from 'lucide-react'; +import Button from '../../../../shared/components/Button'; + +interface LearningHeroProps { + onStart: () => void; + onBrowse: () => void; +} + +/** + * The promise and the one action that follows from it. The sample receipt + * lives in the why-panel next door — showing it twice on one screen taught + * nothing the second time. + */ +export default function LearningHero({ onStart, onBrowse }: LearningHeroProps) { + const { t } = useTranslation(); + return ( +
+

{t('learning.landing.heroTitle')}

+

{t('learning.landing.heroBody')}

+
+ + +
+
+ ); +} + +const styles: Record = { + hero: { + display: 'flex', + flexDirection: 'column', + gap: 'var(--space-3)', + padding: 'var(--space-6)', + background: 'var(--bg-surface-solid)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-lg)', + }, + title: { + fontSize: 'var(--text-2xl)', + fontWeight: 700, + color: 'var(--color-text-primary)', + margin: 0, + letterSpacing: '-0.5px', + maxWidth: '620px', + }, + body: { + fontSize: 'var(--text-md)', + color: 'var(--color-text-secondary)', + lineHeight: 1.6, + margin: 0, + maxWidth: '560px', + }, + ctaRow: { + display: 'flex', + alignItems: 'center', + gap: 'var(--space-4)', + flexWrap: 'wrap', + marginTop: 'var(--space-1)', + }, + browseLink: { + display: 'inline-flex', + alignItems: 'center', + gap: 'var(--space-1)', + background: 'none', + border: 'none', + padding: 'var(--space-2)', + color: 'var(--color-accent)', + fontSize: 'var(--text-md)', + fontWeight: 600, + fontFamily: 'var(--font-sans)', + cursor: 'pointer', + borderRadius: 'var(--radius-sm)', + }, +}; diff --git a/frontend/src/pages/ProjectsPage/components/learning/LearningSection.tsx b/frontend/src/pages/ProjectsPage/components/learning/LearningSection.tsx new file mode 100644 index 0000000..cf6bbcb --- /dev/null +++ b/frontend/src/pages/ProjectsPage/components/learning/LearningSection.tsx @@ -0,0 +1,222 @@ +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { BookOpen, GraduationCap } from 'lucide-react'; +import Button from '../../../../shared/components/Button'; +import { useRoadmaps } from '../../../../features/learning/hooks/useRoadmaps'; +import { useLearningProgressSummaries } from '../../../../features/learning/hooks/useLearningProgressSummaries'; +import { filterByUiLanguage } from '../../../../features/learning/roadmapLanguage'; +import { hasSeenLearningPitch } from '../../../../features/learning/onboarding'; +import LearningHero from './LearningHero'; +import RoadmapShowcaseCard from './RoadmapShowcaseCard'; +import WhyPanel from './WhyPanel'; +import ResumeCard from './ResumeCard'; +import Receipt from '../../../../shared/components/Receipt'; +import { SAMPLE_CHECKS, SAMPLE_CONTEXT, SAMPLE_FOOTER, SAMPLE_RECEIPT_TEXT } from './sampleReceipt'; +import Skeleton from '../../../../shared/components/Skeleton'; +import type { ProgressEntrySummary, RoadmapSummary } from '../../../../shared/types/roadmap'; + +interface LearningSectionProps { + /** Opens the roadmap's briefing page — launching happens from there. */ + onOpenRoadmap: (summary: RoadmapSummary, progress?: ProgressEntrySummary) => void; +} + +export default function LearningSection({ onOpenRoadmap }: LearningSectionProps) { + const { t, i18n } = useTranslation(); + const { summaries, loading, error, fetchRoadmaps } = useRoadmaps(); + const { byRoadmapId, fetchProgress } = useLearningProgressSummaries(); + const roadmapsPanelRef = useRef(null); + // Read once per mount: launching a roadmap sets the flag, and the hero must + // not vanish under the click that used it. + const [pitchSeen] = useState(hasSeenLearningPitch); + + useEffect(() => { + fetchRoadmaps(); + fetchProgress(); + }, [fetchRoadmaps, fetchProgress]); + + const visible = filterByUiLanguage(summaries, i18n.language); + // Started roadmaps first, most recently played on top; the rest keep + // catalogue order (DESIGN §4.2). + const sorted = [...visible].sort((a, b) => { + const pa = byRoadmapId[a.id]?.updatedAt ?? ''; + const pb = byRoadmapId[b.id]?.updatedAt ?? ''; + if (pa !== pb) return pb.localeCompare(pa); + return 0; + }); + + // Two audiences, two pages. Someone who has already started one roadmap + // gets a single question answered — "where was I?" — and the catalogue. + // Someone who has never started anything gets the pitch instead: the hero, + // the why-panel and the sample receipt. Showing both to everyone buried the + // resume link below the fold. + const resumeTarget = sorted.find(s => { + const p = byRoadmapId[s.id]; + return p && p.completedSteps > 0 && p.completedSteps < s.stepCount; + }); + const isFirstRun = + !pitchSeen && !sorted.some(s => (byRoadmapId[s.id]?.completedSteps ?? 0) > 0); + // The pitch always opens on the same roadmap: the first of the catalogue, + // which the backend serves in its curated order (`CURATED_ROADMAP_ORDER`). + // `sorted` is the progress-first order and would pitch whatever was touched + // last — right for the list, wrong for an introduction. + const flagship = visible[0]; + + const browseToRoadmaps = () => { + roadmapsPanelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + roadmapsPanelRef.current?.focus({ preventScroll: true }); + }; + + return ( +
+
+ +

{t('learning.landing.title')}

+
+

{t('learning.landing.subtitle')}

+ +
+
+ {isFirstRun && flagship && ( + onOpenRoadmap(flagship, byRoadmapId[flagship.id])} + onBrowse={browseToRoadmaps} + /> + )} + + {/* The evidence card needs the wide column: its value column only + aligns when nothing wraps (DESIGN §2). */} + {isFirstRun && ( + + )} + + {resumeTarget && ( + onOpenRoadmap(resumeTarget, byRoadmapId[resumeTarget.id])} + /> + )} + +
+
+ +

{t('learning.landing.roadmapsTitle')}

+
+ {loading ? ( +
+ + +
+ ) : error ? ( +
+ {t('learning.catalog.error')} + +
+ ) : sorted.length === 0 ? ( +
{t('learning.catalog.empty')}
+ ) : ( +
+ {sorted.map(summary => ( + onOpenRoadmap(summary, byRoadmapId[summary.id])} + /> + ))} +
+ )} +
+
+ + {isFirstRun && ( +
+ +
+ )} +
+
+ ); +} + +const styles: Record = { + titleRow: { + display: 'flex', + alignItems: 'center', + gap: 'var(--space-2)', + }, + title: { + fontSize: 'var(--text-xl)', + fontWeight: 700, + color: 'var(--color-text-primary)', + margin: 0, + letterSpacing: '-0.3px', + }, + subtitle: { + fontSize: 'var(--text-md)', + color: 'var(--color-text-secondary)', + margin: 'var(--space-1) 0 var(--space-4) 0', + }, + columns: { + display: 'flex', + gap: 'var(--space-5)', + alignItems: 'flex-start', + flexWrap: 'wrap', + }, + main: { + flex: '2 1 480px', + display: 'flex', + flexDirection: 'column', + gap: 'var(--space-5)', + minWidth: 0, + }, + side: { + flex: '1 1 280px', + minWidth: 0, + }, + // Returning users get one column: resume card, then the catalogue. + single: { + display: 'flex', + flexDirection: 'column', + gap: 'var(--space-5)', + }, + // The roadmap cards sit directly on the page background (mockup look) — + // no wrapping panel chrome, just the header row and the grid. + roadmapsPanel: { + outline: 'none', + }, + roadmapsTitleRow: { + display: 'flex', + alignItems: 'center', + gap: 'var(--space-2)', + margin: '0 0 var(--space-4) 0', + }, + roadmapsTitle: { + fontSize: 'var(--text-lg)', + fontWeight: 700, + color: 'var(--color-text-primary)', + margin: 0, + }, + cardList: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', + gap: 'var(--space-4)', + }, + status: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: 'var(--space-3)', + padding: 'var(--space-6) var(--space-4)', + fontSize: 'var(--text-sm)', + color: 'var(--color-text-muted)', + textAlign: 'center', + }, +}; diff --git a/frontend/src/pages/ProjectsPage/components/learning/ResumeCard.tsx b/frontend/src/pages/ProjectsPage/components/learning/ResumeCard.tsx new file mode 100644 index 0000000..1f4e763 --- /dev/null +++ b/frontend/src/pages/ProjectsPage/components/learning/ResumeCard.tsx @@ -0,0 +1,122 @@ +import { useTranslation } from 'react-i18next'; +import { ArrowRight } from 'lucide-react'; +import Button from '../../../../shared/components/Button'; +import ProgressBar from '../../../../shared/components/ProgressBar'; +import { roadmapVisual } from './roadmapVisual'; +import type { ProgressEntrySummary, RoadmapSummary } from '../../../../shared/types/roadmap'; + +interface ResumeCardProps { + summary: RoadmapSummary; + progress: ProgressEntrySummary; + onResume: () => void; +} + +/** + * Top of the learning page for anyone who has already started something: the + * one question a returning user has is "where was I?", so the answer gets the + * first card and the only primary button on the screen. The pitch (hero, + * why-panel, sample receipt) is shown to first-timers instead — see + * LearningSection. + */ +export default function ResumeCard({ summary, progress, onResume }: ResumeCardProps) { + const { t } = useTranslation(); + const { Icon } = roadmapVisual(summary.id); + // The player resumes at the true first incomplete step; this is the label. + const current = Math.min(progress.completedSteps + 1, summary.stepCount); + + return ( +
+ {t('learning.landing.resumeLabel')} +
+ + + +
+

{summary.title}

+
+
+ +
+ + {t('learning.landing.stepOf', { current, total: summary.stepCount })} + +
+
+ +
+
+ ); +} + +const styles: Record = { + card: { + display: 'flex', + flexDirection: 'column', + gap: 'var(--space-3)', + padding: 'var(--space-5)', + background: 'var(--bg-surface-solid)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-lg)', + }, + eyebrow: { + fontSize: 'var(--text-xs)', + fontWeight: 500, + textTransform: 'uppercase', + letterSpacing: '0.5px', + color: 'var(--color-text-muted)', + }, + row: { + display: 'flex', + alignItems: 'center', + gap: 'var(--space-4)', + flexWrap: 'wrap', + }, + iconTile: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: 40, + height: 40, + flexShrink: 0, + borderRadius: 'var(--radius-md)', + background: 'var(--bg-subtle)', + border: '1px solid var(--border-color)', + color: 'var(--color-text-secondary)', + }, + body: { + display: 'flex', + flexDirection: 'column', + gap: 'var(--space-2)', + flex: '1 1 240px', + minWidth: 0, + }, + title: { + fontSize: 'var(--text-lg)', + fontWeight: 700, + color: 'var(--color-text-primary)', + margin: 0, + lineHeight: 1.3, + }, + progressRow: { + display: 'flex', + alignItems: 'center', + gap: 'var(--space-3)', + }, + progressTrack: { + flex: 1, + minWidth: 0, + }, + stepLabel: { + fontSize: 'var(--text-sm)', + color: 'var(--color-text-secondary)', + whiteSpace: 'nowrap', + }, +}; diff --git a/frontend/src/pages/ProjectsPage/components/learning/RoadmapShowcaseCard.tsx b/frontend/src/pages/ProjectsPage/components/learning/RoadmapShowcaseCard.tsx new file mode 100644 index 0000000..53f8682 --- /dev/null +++ b/frontend/src/pages/ProjectsPage/components/learning/RoadmapShowcaseCard.tsx @@ -0,0 +1,148 @@ +import { useTranslation } from 'react-i18next'; +import { CheckCircle2, ClipboardList, Clock } from 'lucide-react'; +import ProgressBar from '../../../../shared/components/ProgressBar'; +import DifficultyChip from '../../../../features/learning/components/DifficultyChip'; +import { roadmapVisual } from './roadmapVisual'; +import type { ProgressEntrySummary, RoadmapSummary } from '../../../../shared/types/roadmap'; + +interface RoadmapShowcaseCardProps { + summary: RoadmapSummary; + /** Most recent play-through of this roadmap across all projects, if any. */ + progress?: ProgressEntrySummary; + onOpen: () => void; +} + +export default function RoadmapShowcaseCard({ summary, progress, onOpen }: RoadmapShowcaseCardProps) { + const { t } = useTranslation(); + const started = (progress?.completedSteps ?? 0) > 0; + const completed = started && (progress as ProgressEntrySummary).completedSteps >= summary.stepCount; + const { Icon } = roadmapVisual(summary.id); + + return ( + + ); +} + +const styles: Record = { + card: { + display: 'flex', + flexDirection: 'column', + gap: 'var(--space-3)', + padding: 'var(--space-4)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-lg)', + background: 'var(--bg-surface-solid)', + cursor: 'pointer', + textAlign: 'left', + fontFamily: 'var(--font-sans)', + transition: 'border-color 0.15s ease', + }, + header: { + display: 'flex', + alignItems: 'center', + gap: 'var(--space-3)', + }, + iconTile: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: 34, + height: 34, + flexShrink: 0, + borderRadius: 'var(--radius-md)', + background: 'var(--bg-subtle)', + border: '1px solid var(--border-color)', + color: 'var(--color-text-secondary)', + }, + title: { + fontSize: 'var(--text-md)', + fontWeight: 600, + color: 'var(--color-text-primary)', + lineHeight: 1.3, + }, + description: { + fontSize: 'var(--text-sm)', + color: 'var(--color-text-secondary)', + lineHeight: 1.5, + display: '-webkit-box', + WebkitLineClamp: 2, + WebkitBoxOrient: 'vertical', + overflow: 'hidden', + }, + meta: { + display: 'flex', + gap: 'var(--space-4)', + fontSize: 'var(--text-xs)', + color: 'var(--color-text-muted)', + // Pushes the meta/progress footer to the card bottom so rows align + // across cards with different description lengths. + marginTop: 'auto', + paddingTop: 'var(--space-2)', + borderTop: '1px solid var(--border-color)', + }, + metaItem: { + display: 'inline-flex', + alignItems: 'center', + gap: 'var(--space-1)', + }, + progressBlock: { + display: 'flex', + flexDirection: 'column', + gap: 'var(--space-2)', + }, + progressLabel: { + fontSize: 'var(--text-xs)', + fontWeight: 600, + }, +}; diff --git a/frontend/src/pages/ProjectsPage/components/learning/WhyPanel.tsx b/frontend/src/pages/ProjectsPage/components/learning/WhyPanel.tsx new file mode 100644 index 0000000..2d40882 --- /dev/null +++ b/frontend/src/pages/ProjectsPage/components/learning/WhyPanel.tsx @@ -0,0 +1,81 @@ +import { useTranslation } from 'react-i18next'; +import { Box, ReceiptText, Terminal } from 'lucide-react'; + +const POINTS = [ + { icon: Box, titleKey: 'learning.landing.whyContainersTitle', bodyKey: 'learning.landing.whyContainersBody' }, + { icon: Terminal, titleKey: 'learning.landing.whyCommandsTitle', bodyKey: 'learning.landing.whyCommandsBody' }, + { icon: ReceiptText, titleKey: 'learning.landing.whyReceiptsTitle', bodyKey: 'learning.landing.whyReceiptsBody' }, +] as const; + +export default function WhyPanel() { + const { t } = useTranslation(); + return ( +
+

{t('learning.landing.whyTitle')}

+
+ {POINTS.map(({ icon: Icon, titleKey, bodyKey }) => ( +
+
+ +
+
+
{t(titleKey)}
+
{t(bodyKey)}
+
+
+ ))} +
+
+ ); +} + +const styles: Record = { + panel: { + display: 'flex', + flexDirection: 'column', + gap: 'var(--space-4)', + padding: 'var(--space-5)', + background: 'var(--bg-surface-solid)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-lg)', + alignSelf: 'flex-start', + }, + title: { + fontSize: 'var(--text-lg)', + fontWeight: 700, + color: 'var(--color-text-primary)', + margin: 0, + }, + points: { + display: 'flex', + flexDirection: 'column', + gap: 'var(--space-4)', + }, + point: { + display: 'flex', + gap: 'var(--space-3)', + alignItems: 'flex-start', + }, + pointIcon: { + width: '26px', + height: '26px', + borderRadius: 'var(--radius-sm)', + background: 'var(--bg-subtle)', + border: '1px solid var(--border-color)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + }, + pointTitle: { + fontSize: 'var(--text-md)', + fontWeight: 600, + color: 'var(--color-text-primary)', + }, + pointBody: { + fontSize: 'var(--text-sm)', + color: 'var(--color-text-secondary)', + lineHeight: 1.5, + marginTop: '2px', + }, +}; diff --git a/frontend/src/pages/ProjectsPage/components/learning/detail/ArchitecturePreview.tsx b/frontend/src/pages/ProjectsPage/components/learning/detail/ArchitecturePreview.tsx new file mode 100644 index 0000000..76dd8a0 --- /dev/null +++ b/frontend/src/pages/ProjectsPage/components/learning/detail/ArchitecturePreview.tsx @@ -0,0 +1,190 @@ +import { useTranslation } from 'react-i18next'; +import { ArrowRight, Ban } from 'lucide-react'; +import { ROLE_VISUALS } from './nodeRoleVisual'; +import type { RoadmapTopology } from '../../../../../features/learning/roadmapTopology'; + +interface ArchitecturePreviewProps { + topology: RoadmapTopology; +} + +/** + * "What you'll build", read off the roadmap's own checks rather than a + * declared diagram: the nodes it asserts, and the connections it requires + * open or blocked. Names and ports are the real ones the learner must use, so + * they render in mono. + */ +export default function ArchitecturePreview({ topology }: ArchitecturePreviewProps) { + const { t } = useTranslation(); + const { nodes, links } = topology; + + if (nodes.length === 0) return null; + + return ( +
+

{t('learning.detail.build.title')}

+

{t('learning.detail.build.subtitle')}

+ +
    + {nodes.map(node => { + const { Icon, color } = ROLE_VISUALS[node.role]; + return ( +
  • + {/* Icon keeps the node's identity hue — the same one its card + wears on the canvas — but sits on a neutral tile. */} + + + + + {node.name} + {t(`learning.detail.role.${node.role}`)} + +
  • + ); + })} +
+ + {links.length > 0 && ( + <> + {t('learning.detail.build.linksTitle')} +
    + {links.map(link => { + const denied = link.mode === 'deny'; + const color = denied ? 'var(--color-danger)' : 'var(--color-success)'; + return ( +
  • + {denied ? ( + + ) : ( + + )} + + {link.source} → {link.target} + + + {link.port != null ? `:${link.port}` : t('learning.detail.build.anyPort')} + + + {denied ? t('learning.detail.build.denied') : t('learning.detail.build.allowed')} + +
  • + ); + })} +
+ + )} +
+ ); +} + +const styles: Record = { + panel: { + padding: 'var(--space-5)', + background: 'var(--bg-surface-solid)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-lg)', + }, + title: { + fontSize: 'var(--text-lg)', + fontWeight: 700, + color: 'var(--color-text-primary)', + margin: 0, + }, + subtitle: { + fontSize: 'var(--text-sm)', + color: 'var(--color-text-secondary)', + margin: 'var(--space-1) 0 var(--space-4) 0', + }, + nodes: { + // Grid, not flex: a lone node on the last row must keep the column width + // of the others rather than stretching across the panel. + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', + gap: 'var(--space-3)', + listStyle: 'none', + margin: 0, + padding: 0, + }, + node: { + display: 'flex', + alignItems: 'center', + gap: 'var(--space-3)', + minWidth: 0, + padding: 'var(--space-3)', + background: 'var(--bg-subtle)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-md)', + }, + nodeIcon: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: '30px', + height: '30px', + flexShrink: 0, + borderRadius: 'var(--radius-sm)', + background: 'var(--bg-surface-solid)', + border: '1px solid var(--border-color)', + }, + nodeText: { + display: 'flex', + flexDirection: 'column', + minWidth: 0, + }, + nodeName: { + fontFamily: 'var(--font-mono)', + fontSize: 'var(--text-md)', + fontWeight: 600, + color: 'var(--color-text-primary)', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + nodeRole: { + fontSize: 'var(--text-xs)', + color: 'var(--color-text-muted)', + }, + linksTitle: { + display: 'block', + fontSize: 'var(--text-xs)', + fontWeight: 500, + textTransform: 'uppercase', + letterSpacing: '0.5px', + color: 'var(--color-text-muted)', + margin: 'var(--space-4) 0 var(--space-2) 0', + }, + links: { + display: 'flex', + flexDirection: 'column', + gap: 'var(--space-2)', + listStyle: 'none', + margin: 0, + padding: 0, + }, + link: { + display: 'flex', + alignItems: 'center', + gap: 'var(--space-2)', + flexWrap: 'wrap', + }, + linkIcon: { + flexShrink: 0, + }, + linkEndpoints: { + fontFamily: 'var(--font-mono)', + fontSize: 'var(--text-sm)', + color: 'var(--color-text-primary)', + }, + linkPort: { + fontFamily: 'var(--font-mono)', + fontSize: 'var(--text-xs)', + color: 'var(--color-text-secondary)', + background: 'var(--bg-subtle)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-sm)', + padding: '1px var(--space-2)', + }, + linkMode: { + fontSize: 'var(--text-xs)', + fontWeight: 600, + }, +}; diff --git a/frontend/src/pages/ProjectsPage/components/learning/detail/LaunchFooter.tsx b/frontend/src/pages/ProjectsPage/components/learning/detail/LaunchFooter.tsx new file mode 100644 index 0000000..08edacc --- /dev/null +++ b/frontend/src/pages/ProjectsPage/components/learning/detail/LaunchFooter.tsx @@ -0,0 +1,140 @@ +import { useTranslation } from 'react-i18next'; +import { Play, RotateCcw } from 'lucide-react'; +import Button from '../../../../../shared/components/Button'; +import type { DockerHealth } from '../../../../../shared/hooks/useDockerHealth'; + +interface LaunchFooterProps { + /** Resume position, when the roadmap has been played; absent on a fresh start. */ + resume?: { current: number; total: number; projectName: string }; + docker: DockerHealth; + onRecheckDocker: () => void; + onLaunch: () => void; + /** Absent when there is no progress to clear. */ + onRestart?: () => void; + /** Server-provided message of a failed restart, or '' for a generic failure. */ + restartError?: string | null; +} + +/** + * The page's commitment point. Docker readiness is stated here rather than + * after the first failure, but never blocks the launch: the probe can be + * momentarily wrong, and the canvas surfaces daemon problems on its own. + */ +export default function LaunchFooter({ + resume, + docker, + onRecheckDocker, + onLaunch, + onRestart, + restartError, +}: LaunchFooterProps) { + const { t } = useTranslation(); + + return ( +
+ {restartError != null && ( + {restartError || t('learning.detail.launch.restartError')} + )} + + {/* Primary first: in this narrow column the two buttons wrap onto their + own rows, and the launch action must be the one on top. */} +
+ + {onRestart && ( + + )} +
+ +

+ {resume + ? t('learning.detail.launch.continueCaption', { project: resume.projectName }) + : t('learning.detail.launch.startCaption')} +

+ + {docker !== 'unknown' && ( +

+ + + {docker === 'ok' ? t('learning.detail.launch.dockerOk') : t('learning.detail.launch.dockerDown')} + + {docker === 'down' && ( + + )} +

+ )} +
+ ); +} + +const styles: Record = { + footer: { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-end', + gap: 'var(--space-2)', + }, + actions: { + display: 'flex', + gap: 'var(--space-3)', + flexWrap: 'wrap', + justifyContent: 'flex-end', + }, + caption: { + fontSize: 'var(--text-sm)', + color: 'var(--color-text-muted)', + margin: 0, + textAlign: 'right', + }, + docker: { + display: 'flex', + alignItems: 'center', + gap: 'var(--space-2)', + flexWrap: 'wrap', + justifyContent: 'flex-end', + fontSize: 'var(--text-sm)', + margin: 0, + }, + dot: { + width: '7px', + height: '7px', + borderRadius: '50%', + flexShrink: 0, + }, + dockerOk: { + color: 'var(--color-text-secondary)', + }, + dockerDown: { + color: 'var(--color-warning-strong)', + }, + recheck: { + background: 'none', + border: 'none', + padding: 'var(--space-1)', + color: 'var(--color-accent)', + fontSize: 'var(--text-sm)', + fontWeight: 600, + cursor: 'pointer', + borderRadius: 'var(--radius-sm)', + }, + error: { + fontSize: 'var(--text-sm)', + color: 'var(--color-danger)', + textAlign: 'right', + }, +}; diff --git a/frontend/src/pages/ProjectsPage/components/learning/detail/PrerequisitesPanel.tsx b/frontend/src/pages/ProjectsPage/components/learning/detail/PrerequisitesPanel.tsx new file mode 100644 index 0000000..79be657 --- /dev/null +++ b/frontend/src/pages/ProjectsPage/components/learning/detail/PrerequisitesPanel.tsx @@ -0,0 +1,68 @@ +import { useTranslation } from 'react-i18next'; + +interface PrerequisitesPanelProps { + /** The roadmap's declared prerequisites, in the author's order. */ + prerequisites: string[]; +} + +/** What the learner needs in place before launching. Free-form author text. */ +export default function PrerequisitesPanel({ prerequisites }: PrerequisitesPanelProps) { + const { t } = useTranslation(); + if (prerequisites.length === 0) return null; + + return ( +
+

{t('learning.detail.prerequisites.title')}

+
    + {prerequisites.map(item => ( +
  • + + {item} +
  • + ))} +
+
+ ); +} + +const styles: Record = { + panel: { + display: 'flex', + flexDirection: 'column', + gap: 'var(--space-3)', + padding: 'var(--space-5)', + background: 'var(--bg-surface-solid)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-lg)', + }, + title: { + fontSize: 'var(--text-lg)', + fontWeight: 700, + color: 'var(--color-text-primary)', + margin: 0, + }, + list: { + display: 'flex', + flexDirection: 'column', + gap: 'var(--space-2)', + listStyle: 'none', + margin: 0, + padding: 0, + }, + item: { + display: 'flex', + alignItems: 'flex-start', + gap: 'var(--space-3)', + fontSize: 'var(--text-sm)', + color: 'var(--color-text-secondary)', + lineHeight: 1.5, + }, + bullet: { + width: '5px', + height: '5px', + marginTop: '7px', + flexShrink: 0, + borderRadius: '50%', + background: 'var(--color-text-muted)', + }, +}; diff --git a/frontend/src/pages/ProjectsPage/components/learning/detail/RoadmapDetailHero.tsx b/frontend/src/pages/ProjectsPage/components/learning/detail/RoadmapDetailHero.tsx new file mode 100644 index 0000000..a8d4881 --- /dev/null +++ b/frontend/src/pages/ProjectsPage/components/learning/detail/RoadmapDetailHero.tsx @@ -0,0 +1,68 @@ +import { roadmapVisual } from '../roadmapVisual'; +import type { RoadmapSummary } from '../../../../../shared/types/roadmap'; + +interface RoadmapDetailHeroProps { + summary: RoadmapSummary; + /** The roadmap file's description once loaded; the catalogue's until then. */ + description: string; +} + +/** + * Identity block of the briefing page: icon, title, pitch. Difficulty is not + * repeated here — the stats strip right below states it once. + */ +export default function RoadmapDetailHero({ summary, description }: RoadmapDetailHeroProps) { + const { Icon } = roadmapVisual(summary.id); + + return ( +
+ + + +
+

{summary.title}

+

{description}

+
+
+ ); +} + +const styles: Record = { + hero: { + display: 'flex', + alignItems: 'flex-start', + gap: 'var(--space-5)', + }, + iconTile: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: '52px', + height: '52px', + flexShrink: 0, + borderRadius: 'var(--radius-md)', + background: 'var(--bg-subtle)', + border: '1px solid var(--border-color)', + color: 'var(--color-text-secondary)', + }, + copy: { + display: 'flex', + flexDirection: 'column', + gap: 'var(--space-2)', + minWidth: 0, + }, + title: { + fontSize: 'var(--text-2xl)', + fontWeight: 700, + color: 'var(--color-text-primary)', + margin: 0, + letterSpacing: '-0.5px', + }, + description: { + fontSize: 'var(--text-md)', + color: 'var(--color-text-secondary)', + lineHeight: 1.6, + margin: 0, + maxWidth: '640px', + }, +}; diff --git a/frontend/src/pages/ProjectsPage/components/learning/detail/RoadmapDetailPage.test.tsx b/frontend/src/pages/ProjectsPage/components/learning/detail/RoadmapDetailPage.test.tsx new file mode 100644 index 0000000..6dfb183 --- /dev/null +++ b/frontend/src/pages/ProjectsPage/components/learning/detail/RoadmapDetailPage.test.tsx @@ -0,0 +1,229 @@ +import '../../../../../i18n'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'; +import RoadmapDetailPage from './RoadmapDetailPage'; +import type { ProgressEntrySummary, RoadmapSummary } from '../../../../../shared/types/roadmap'; + +const summary: RoadmapSummary = { + id: 'cache-aside-redis', + title: 'Cache-aside with Redis', + description: 'Add a Redis cache-aside layer.', + language: 'en', + difficulty: 'intermediate', + estimatedMinutes: 30, + stepCount: 3, +}; + +const roadmap = { + schemaVersion: 1, + id: 'cache-aside-redis', + title: 'Cache-aside with Redis', + description: 'Feel a slow store on real containers, then fix it.', + language: 'en', + difficulty: 'intermediate', + estimatedMinutes: 30, + prerequisites: ['Docker installed and running'], + steps: [ + { + id: 'reopen-the-store', + title: 'Reopen the store', + instruction: 'Start the web node.', + validators: [{ type: 'container_running', params: { node: 'web' } }], + }, + { + id: 'the-catalog', + title: 'The catalog', + instruction: 'Create the books table.', + validators: [{ type: 'table_exists', params: { node: 'db', table: 'books' } }], + }, + { + id: 'add-the-cache', + title: 'Enter Redis', + instruction: 'Add a Redis node.', + validators: [ + { type: 'redis_key_exists', params: { node: 'cache', key: 'cache:books' } }, + { type: 'port_denied', params: { source: 'cache', target: 'db', port: 5432 } }, + ], + }, + ], +}; + +const progress: ProgressEntrySummary = { + projectId: 'p1', + roadmapId: 'cache-aside-redis', + updatedAt: '2026-07-20T10:00:00.000Z', + completedSteps: 1, +}; + +function jsonResponse(ok: boolean, body: unknown): Response { + return { ok, json: () => Promise.resolve(body) } as Response; +} + +/** The page fetches the roadmap, its step progress and Docker health in parallel. */ +function buildFetchMock(handlers: { + roadmap?: () => Promise | Response; + stepProgress?: () => Response; + docker?: () => Response; + deleteProgress?: () => Response; +} = {}) { + return vi.fn((url: string, init?: RequestInit) => { + if (url.includes('/health')) { + return Promise.resolve( + handlers.docker?.() ?? jsonResponse(true, { status: 'ok', checks: { docker: { status: 'ok' } } }) + ); + } + if (url.includes('/api/learning/roadmaps/')) { + return Promise.resolve(handlers.roadmap?.() ?? jsonResponse(true, roadmap)); + } + if (url.includes('/api/learning/progress/')) { + if (init?.method === 'DELETE') { + return Promise.resolve(handlers.deleteProgress?.() ?? jsonResponse(true, {})); + } + return Promise.resolve( + handlers.stepProgress?.() ?? + jsonResponse(true, { + steps: { + 'reopen-the-store': { + passed: true, + attempts: 2, + revealedHints: 0, + lastCheckedAt: '2026-07-20T10:00:00.000Z', + }, + }, + }) + ); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); +} + +function renderPage(props: Partial> = {}) { + const onLaunch = vi.fn(); + const onProgressCleared = vi.fn(); + render( + + ); + return { onLaunch, onProgressCleared }; +} + +describe('RoadmapDetailPage', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('shows a skeleton while the roadmap loads', () => { + vi.stubGlobal( + 'fetch', + buildFetchMock({ roadmap: () => new Promise(() => {}) }) + ); + renderPage(); + + const section = screen.getByLabelText('Loading roadmap'); + expect(section.getAttribute('aria-busy')).toBe('true'); + expect(section.querySelectorAll('.skeleton').length).toBeGreaterThan(0); + }); + + it('shows a visible error block whose Retry refetches the roadmap', async () => { + let attempts = 0; + const fetchMock = buildFetchMock({ + roadmap: () => { + attempts += 1; + return attempts === 1 ? Promise.reject(new Error('network down')) : jsonResponse(true, roadmap); + }, + }); + vi.stubGlobal('fetch', fetchMock); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + renderPage(); + + expect(await screen.findByText('Could not load this roadmap. Is the backend running?')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + + expect(await screen.findByText('Reopen the store')).toBeInTheDocument(); + errorSpy.mockRestore(); + }); + + it('describes the roadmap from its own file and validators', async () => { + vi.stubGlobal('fetch', buildFetchMock()); + renderPage(); + + // Description, steps and prerequisites come from the roadmap file. + expect(await screen.findByText('Feel a slow store on real containers, then fix it.')).toBeInTheDocument(); + expect(screen.getByText('Enter Redis')).toBeInTheDocument(); + expect(screen.getByText('Docker installed and running')).toBeInTheDocument(); + + // Topology is read off the validators: roles, and the link the roadmap + // requires blocked. + const build = screen.getByRole('heading', { name: "What you'll build" }).closest('section') as HTMLElement; + expect(within(build).getByText('cache')).toBeInTheDocument(); + expect(within(build).getByText('PostgreSQL')).toBeInTheDocument(); + expect(within(build).getByText('Redis')).toBeInTheDocument(); + expect(within(build).getByText('cache → db')).toBeInTheDocument(); + expect(within(build).getByText('must be blocked')).toBeInTheDocument(); + // ...and so are the skills. + expect(screen.getByText('Security groups')).toBeInTheDocument(); + + // Never played: the receipt is a labelled sample of the real checks. + expect(screen.getByText('Sample validation receipt')).toBeInTheDocument(); + expect(screen.getByText('check: container "web" is running')).toBeInTheDocument(); + }); + + it('launches a fresh roadmap', async () => { + vi.stubGlobal('fetch', buildFetchMock()); + const { onLaunch } = renderPage(); + + fireEvent.click(await screen.findByRole('button', { name: /Launch lab/ })); + expect(onLaunch).toHaveBeenCalledTimes(1); + }); + + it('resumes a started roadmap and shows its real last run', async () => { + vi.stubGlobal('fetch', buildFetchMock()); + const { onLaunch } = renderPage({ progress, projectName: 'Lab one' }); + + const resume = await screen.findByRole('button', { name: /Continue · step 2 of 3/ }); + expect(screen.getByText('You are here')).toBeInTheDocument(); + expect(screen.getByText('Your last run · Lab one')).toBeInTheDocument(); + expect(screen.getByText('1 of 3 steps passing')).toBeInTheDocument(); + expect(screen.getByText('next: "The catalog"')).toBeInTheDocument(); + + fireEvent.click(resume); + expect(onLaunch).toHaveBeenCalledTimes(1); + }); + + it('clears the progress after the restart is confirmed', async () => { + const fetchMock = buildFetchMock(); + vi.stubGlobal('fetch', fetchMock); + const { onProgressCleared } = renderPage({ progress, projectName: 'Lab one' }); + + fireEvent.click(await screen.findByRole('button', { name: /Restart roadmap/ })); + // The footer trigger and the modal's confirm share a label, by design — + // an action keeps its name through the flow. + const modalTitle = await screen.findByRole('heading', { name: 'Restart roadmap' }); + const modal = modalTitle.parentElement as HTMLElement; + fireEvent.click(within(modal).getByRole('button', { name: 'Restart roadmap' })); + + await waitFor(() => expect(onProgressCleared).toHaveBeenCalledTimes(1)); + const deleteCall = fetchMock.mock.calls.find(call => call[1]?.method === 'DELETE'); + expect(String(deleteCall?.[0])).toContain('/api/learning/progress/p1/cache-aside-redis'); + }); + + it('warns before launching when Docker is not running', async () => { + vi.stubGlobal( + 'fetch', + buildFetchMock({ + docker: () => jsonResponse(false, { status: 'degraded', checks: { docker: { status: 'unreachable' } } }), + }) + ); + renderPage(); + + expect( + await screen.findByText("Docker isn't running — start Docker to launch containers.") + ).toBeInTheDocument(); + // The probe informs, it never blocks the launch. + expect(screen.getByRole('button', { name: /Launch lab/ })).not.toBeDisabled(); + }); +}); diff --git a/frontend/src/pages/ProjectsPage/components/learning/detail/RoadmapDetailPage.tsx b/frontend/src/pages/ProjectsPage/components/learning/detail/RoadmapDetailPage.tsx new file mode 100644 index 0000000..0e39fd9 --- /dev/null +++ b/frontend/src/pages/ProjectsPage/components/learning/detail/RoadmapDetailPage.tsx @@ -0,0 +1,246 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import Button from '../../../../../shared/components/Button'; +import ConfirmModal from '../../../../../shared/components/ConfirmModal'; +import Skeleton from '../../../../../shared/components/Skeleton'; +import { useDockerHealth } from '../../../../../shared/hooks/useDockerHealth'; +import { useRoadmapDetail } from '../../../../../features/learning/hooks/useRoadmapDetail'; +import { deriveTopology } from '../../../../../features/learning/roadmapTopology'; +import { readErrorMessage } from '../../../../../shared/utils/readErrorMessage'; +import { API_BASE } from '../../../../../shared/types'; +import ArchitecturePreview from './ArchitecturePreview'; +import LaunchFooter from './LaunchFooter'; +import PrerequisitesPanel from './PrerequisitesPanel'; +import RoadmapDetailHero from './RoadmapDetailHero'; +import RoadmapStatsStrip from './RoadmapStatsStrip'; +import StepOutline from './StepOutline'; +import VerificationPanel from './VerificationPanel'; +import type { LastRun } from './VerificationPanel'; +import type { ProgressEntrySummary, RoadmapSummary } from '../../../../../shared/types/roadmap'; + +interface RoadmapDetailPageProps { + summary: RoadmapSummary; + /** Most recent play-through of this roadmap, across all projects. */ + progress?: ProgressEntrySummary; + /** Name of the project in `progress`, when it still exists. */ + projectName?: string; + onLaunch: () => void; + /** Called after progress was cleared, so the catalogue can refresh. */ + onProgressCleared: () => void; +} + +/** + * The briefing a learner reads before spinning up containers: what the + * roadmap builds, how it is graded, what they need, and one launch action. + * + * Everything shown is real — step titles come from the roadmap file, the + * topology and skills are read off its validators, and progress comes from + * the learner's own play-through. + */ +export default function RoadmapDetailPage({ + summary, + progress, + projectName, + onLaunch, + onProgressCleared, +}: RoadmapDetailPageProps) { + const { t } = useTranslation(); + const { roadmap, stepProgress, loading, error, fetchDetail } = useRoadmapDetail(); + const { status: docker, check: checkDocker } = useDockerHealth(); + const [confirmRestart, setConfirmRestart] = useState(false); + const [restartError, setRestartError] = useState(null); + + const projectId = progress?.projectId; + + useEffect(() => { + fetchDetail({ id: summary.id, language: summary.language, projectId }); + }, [fetchDetail, summary.id, summary.language, projectId]); + + useEffect(() => { + checkDocker(); + }, [checkDocker]); + + const topology = useMemo(() => (roadmap ? deriveTopology(roadmap) : null), [roadmap]); + + const steps = useMemo(() => roadmap?.steps ?? [], [roadmap]); + const passedStepIds = useMemo(() => { + const passed: Record = {}; + // Walking the roadmap's steps drops progress of step ids the file no + // longer contains — same rule as the player. + for (const step of steps) { + if (stepProgress[step.id]?.passed) passed[step.id] = true; + } + return passed; + }, [steps, stepProgress]); + + const passedCount = Object.keys(passedStepIds).length; + const started = passedCount > 0; + // Where the player would resume: the first step not yet passed. + const resumeIndex = steps.findIndex(step => !passedStepIds[step.id]); + const currentIndex = started ? (resumeIndex === -1 ? steps.length - 1 : resumeIndex) : -1; + + const lastRun: LastRun | undefined = + started && projectName + ? { + projectName, + passed: passedCount, + total: steps.length, + lastCheckedAt: latestCheck(stepProgress), + nextStepTitle: resumeIndex === -1 ? undefined : steps[resumeIndex]?.title, + } + : undefined; + + const handleRestart = async () => { + if (!projectId) return; + setConfirmRestart(false); + setRestartError(null); + try { + const res = await fetch( + `${API_BASE}/api/learning/progress/${encodeURIComponent(projectId)}/${encodeURIComponent(summary.id)}`, + { method: 'DELETE' } + ); + if (!res.ok) { + setRestartError(await readErrorMessage(res, '')); + return; + } + onProgressCleared(); + fetchDetail({ id: summary.id, language: summary.language }); + } catch (err) { + console.error('Failed to reset roadmap progress:', err); + setRestartError(''); + } + }; + + if (loading) { + return ( +
+ + +
+
+ + +
+
+ +
+
+
+ ); + } + + if (error || !roadmap || !topology) { + return ( +
+ {t('learning.detail.loadError')} + +
+ ); + } + + return ( +
+ + + + +
+
+ + +
+ +
+ + + = 0 + ? { current: currentIndex + 1, total: steps.length, projectName: lastRun.projectName } + : undefined + } + docker={docker} + onRecheckDocker={checkDocker} + onLaunch={onLaunch} + onRestart={started && projectId ? () => setConfirmRestart(true) : undefined} + restartError={restartError} + /> +
+
+ + {confirmRestart && ( + setConfirmRestart(false)} + /> + )} +
+ ); +} + +/** Most recent validation across a roadmap's steps, ISO-comparable strings. */ +function latestCheck(stepProgress: Record): string | undefined { + let latest: string | undefined; + for (const entry of Object.values(stepProgress)) { + if (entry.lastCheckedAt && (!latest || entry.lastCheckedAt > latest)) { + latest = entry.lastCheckedAt; + } + } + return latest; +} + +const styles: Record = { + page: { + display: 'flex', + flexDirection: 'column', + gap: 'var(--space-5)', + }, + columns: { + display: 'flex', + gap: 'var(--space-5)', + alignItems: 'flex-start', + flexWrap: 'wrap', + }, + main: { + flex: '3 1 420px', + display: 'flex', + flexDirection: 'column', + gap: 'var(--space-5)', + minWidth: 0, + }, + side: { + flex: '2 1 320px', + display: 'flex', + flexDirection: 'column', + gap: 'var(--space-5)', + minWidth: 0, + }, + errorBox: { + display: 'flex', + alignItems: 'center', + gap: 'var(--space-4)', + padding: 'var(--space-4) var(--space-5)', + border: '1px solid color-mix(in srgb, var(--color-danger) 30%, transparent)', + background: 'var(--color-danger-glow)', + borderRadius: 'var(--radius-md)', + }, + errorText: { + flex: 1, + fontSize: 'var(--text-md)', + color: 'var(--color-text-primary)', + }, +}; diff --git a/frontend/src/pages/ProjectsPage/components/learning/detail/RoadmapStatsStrip.tsx b/frontend/src/pages/ProjectsPage/components/learning/detail/RoadmapStatsStrip.tsx new file mode 100644 index 0000000..045eb25 --- /dev/null +++ b/frontend/src/pages/ProjectsPage/components/learning/detail/RoadmapStatsStrip.tsx @@ -0,0 +1,121 @@ +import { useTranslation } from 'react-i18next'; +import type { SkillKey } from '../../../../../features/learning/roadmapTopology'; +import type { RoadmapDifficulty } from '../../../../../shared/types/roadmap'; + +interface RoadmapStatsStripProps { + difficulty?: RoadmapDifficulty; + stepCount: number; + estimatedMinutes?: number; + skills: SkillKey[]; +} + +/** + * The four facts a learner weighs before committing, on one row — label above + * value, no icons or color coding, since none of the four is a status. + * Optional roadmap fields simply drop their cell: a roadmap that declares no + * duration shows three cells, never an empty one. + */ +export default function RoadmapStatsStrip({ + difficulty, + stepCount, + estimatedMinutes, + skills, +}: RoadmapStatsStripProps) { + const { t } = useTranslation(); + + return ( +
+ {difficulty && ( + + {t(`learning.catalog.difficulty.${difficulty}`)} + + )} + + + {stepCount} + + + {estimatedMinutes != null && ( + + {t('learning.catalog.minutes', { count: estimatedMinutes })} + + )} + + {skills.length > 0 && ( + // Chips need more room than a one-line stat, or they wrap three deep. + + + {skills.map(skill => ( + + {t(`learning.detail.skill.${skill}`)} + + ))} + + + )} +
+ ); +} + +function Cell({ + label, + children, + grow = 1, + basis = '150px', +}: { + label: string; + children: React.ReactNode; + grow?: number; + basis?: string; +}) { + return ( +
+ {label} + {children} +
+ ); +} + +const styles: Record = { + strip: { + display: 'flex', + flexWrap: 'wrap', + gap: 'var(--space-5)', + padding: 'var(--space-4) var(--space-5)', + background: 'var(--bg-surface-solid)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-lg)', + }, + cell: { + display: 'flex', + flexDirection: 'column', + gap: 'var(--space-2)', + minWidth: 0, + }, + label: { + fontSize: 'var(--text-xs)', + fontWeight: 500, + textTransform: 'uppercase', + letterSpacing: '0.5px', + color: 'var(--color-text-muted)', + }, + value: { + fontSize: 'var(--text-md)', + fontWeight: 600, + color: 'var(--color-text-primary)', + }, + skills: { + display: 'flex', + flexWrap: 'wrap', + gap: 'var(--space-1)', + }, + skill: { + fontSize: 'var(--text-xs)', + fontWeight: 600, + color: 'var(--color-text-secondary)', + background: 'var(--bg-subtle)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-sm)', + padding: '2px var(--space-2)', + }, +}; diff --git a/frontend/src/pages/ProjectsPage/components/learning/detail/StepOutline.tsx b/frontend/src/pages/ProjectsPage/components/learning/detail/StepOutline.tsx new file mode 100644 index 0000000..95e5eca --- /dev/null +++ b/frontend/src/pages/ProjectsPage/components/learning/detail/StepOutline.tsx @@ -0,0 +1,125 @@ +import { useTranslation } from 'react-i18next'; +import { Check } from 'lucide-react'; +import type { RoadmapStep } from '../../../../../shared/types/roadmap'; + +interface StepOutlineProps { + steps: RoadmapStep[]; + /** Step ids whose latest validation passed. */ + passedStepIds: Record; + /** Index of the step the learner would resume on, or -1 when not started. */ + currentIndex: number; +} + +/** + * The roadmap's real step titles, in order. Titles only: instructions, hints + * and solutions belong to the player, where the pedagogy lives. + */ +export default function StepOutline({ steps, passedStepIds, currentIndex }: StepOutlineProps) { + const { t } = useTranslation(); + + return ( +
+

{t('learning.detail.outline.title')}

+

{t('learning.detail.outline.subtitle')}

+ +
    + {steps.map((step, index) => { + const passed = passedStepIds[step.id] === true; + const current = index === currentIndex; + return ( +
  1. + + {passed ? : index + 1} + + {step.title} + {current && {t('learning.detail.outline.current')}} +
  2. + ); + })} +
+
+ ); +} + +const styles: Record = { + panel: { + padding: 'var(--space-5)', + background: 'var(--bg-surface-solid)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-lg)', + }, + title: { + fontSize: 'var(--text-lg)', + fontWeight: 700, + color: 'var(--color-text-primary)', + margin: 0, + }, + subtitle: { + fontSize: 'var(--text-sm)', + color: 'var(--color-text-secondary)', + margin: 'var(--space-1) 0 var(--space-4) 0', + }, + list: { + display: 'flex', + flexDirection: 'column', + gap: 'var(--space-1)', + listStyle: 'none', + margin: 0, + padding: 0, + }, + item: { + display: 'flex', + alignItems: 'center', + gap: 'var(--space-3)', + padding: 'var(--space-2) var(--space-3)', + borderRadius: 'var(--radius-md)', + }, + // The resume step is marked by a plain grey band plus its accent marker — + // enough to locate, without a tinted row shouting across the list. + itemCurrent: { + background: 'var(--bg-subtle)', + }, + marker: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: '22px', + height: '22px', + flexShrink: 0, + borderRadius: '50%', + border: '1px solid var(--border-color)', + background: 'var(--bg-subtle)', + fontSize: 'var(--text-xs)', + fontWeight: 600, + color: 'var(--color-text-muted)', + }, + markerPassed: { + border: '1px solid color-mix(in srgb, var(--color-success) 40%, transparent)', + background: 'var(--color-success-glow)', + color: 'var(--color-success)', + }, + markerCurrent: { + border: '2px solid var(--color-accent)', + background: 'var(--bg-surface-solid)', + color: 'var(--color-accent)', + }, + stepTitle: { + flex: 1, + minWidth: 0, + fontSize: 'var(--text-md)', + color: 'var(--color-text-primary)', + lineHeight: 1.4, + }, + currentLabel: { + fontSize: 'var(--text-xs)', + fontWeight: 600, + color: 'var(--color-accent)', + whiteSpace: 'nowrap', + }, +}; diff --git a/frontend/src/pages/ProjectsPage/components/learning/detail/VerificationPanel.tsx b/frontend/src/pages/ProjectsPage/components/learning/detail/VerificationPanel.tsx new file mode 100644 index 0000000..d8e6d29 --- /dev/null +++ b/frontend/src/pages/ProjectsPage/components/learning/detail/VerificationPanel.tsx @@ -0,0 +1,148 @@ +import { useTranslation } from 'react-i18next'; +import { ReceiptText, ScanSearch, Terminal } from 'lucide-react'; +import Receipt from '../../../../../shared/components/Receipt'; +import { deriveSampleChecks } from '../../../../../features/learning/roadmapChecks'; +import type { Roadmap } from '../../../../../shared/types/roadmap'; + +/** Evidence of a real play-through, when this roadmap has one. */ +export interface LastRun { + projectName: string; + passed: number; + total: number; + /** Most recent validation across the roadmap's steps, if any ran. */ + lastCheckedAt?: string; + /** Title of the step the learner would resume on; absent once complete. */ + nextStepTitle?: string; +} + +interface VerificationPanelProps { + roadmap: Roadmap; + lastRun?: LastRun; +} + +const POINTS = [ + { Icon: ScanSearch, titleKey: 'learning.detail.verify.liveTitle', bodyKey: 'learning.detail.verify.liveBody' }, + { + Icon: ReceiptText, + titleKey: 'learning.detail.verify.receiptsTitle', + bodyKey: 'learning.detail.verify.receiptsBody', + }, + { + Icon: Terminal, + titleKey: 'learning.detail.verify.commandsTitle', + bodyKey: 'learning.detail.verify.commandsBody', + }, +] as const; + +/** + * How the grading works, ending in a receipt (DESIGN §2). Before the first + * play-through the receipt is a sample built from this roadmap's own checks + * and labelled as such; afterwards it is the learner's real progress. + */ +export default function VerificationPanel({ roadmap, lastRun }: VerificationPanelProps) { + const { t, i18n } = useTranslation(); + + const sampleLines = deriveSampleChecks(roadmap).map(line => t(line.key, line.params)); + const lines = lastRun ? lastRunLines(lastRun) : sampleLines; + + function lastRunLines(run: LastRun): string[] { + const out = [t('learning.detail.receipt.stepsPassing', { passed: run.passed, total: run.total })]; + if (run.lastCheckedAt) { + const date = new Date(run.lastCheckedAt).toLocaleString(i18n.language, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + out.push(t('learning.detail.receipt.lastChecked', { date })); + } + out.push( + run.nextStepTitle + ? t('learning.detail.receipt.nextStep', { title: run.nextStepTitle }) + : t('learning.detail.receipt.allPassing') + ); + return out; + } + + return ( +
+

{t('learning.detail.verify.title')}

+ +
+ {POINTS.map(({ Icon, titleKey, bodyKey }) => ( +
+ + + +
+
{t(titleKey)}
+
{t(bodyKey)}
+
+
+ ))} +
+ + {lines.length > 0 && ( + + )} +
+ ); +} + +const styles: Record = { + panel: { + display: 'flex', + flexDirection: 'column', + gap: 'var(--space-4)', + padding: 'var(--space-5)', + background: 'var(--bg-surface-solid)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-lg)', + }, + title: { + fontSize: 'var(--text-lg)', + fontWeight: 700, + color: 'var(--color-text-primary)', + margin: 0, + }, + points: { + display: 'flex', + flexDirection: 'column', + gap: 'var(--space-4)', + }, + point: { + display: 'flex', + gap: 'var(--space-3)', + alignItems: 'flex-start', + }, + pointIcon: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: '26px', + height: '26px', + flexShrink: 0, + borderRadius: 'var(--radius-sm)', + background: 'var(--bg-subtle)', + border: '1px solid var(--border-color)', + }, + pointTitle: { + fontSize: 'var(--text-md)', + fontWeight: 600, + color: 'var(--color-text-primary)', + }, + pointBody: { + fontSize: 'var(--text-sm)', + color: 'var(--color-text-secondary)', + lineHeight: 1.5, + marginTop: '2px', + }, +}; diff --git a/frontend/src/pages/ProjectsPage/components/learning/detail/nodeRoleVisual.ts b/frontend/src/pages/ProjectsPage/components/learning/detail/nodeRoleVisual.ts new file mode 100644 index 0000000..78bba1e --- /dev/null +++ b/frontend/src/pages/ProjectsPage/components/learning/detail/nodeRoleVisual.ts @@ -0,0 +1,20 @@ +import { Box, Braces, Database, GitFork, Globe, Layers, Zap } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; +import type { NodeRole } from '../../../../../features/learning/roadmapTopology'; + +interface RoleVisual { + Icon: LucideIcon; + /** Node identity color token — decorative, never a semantic status. */ + color: string; +} + +/** Icon and identity color per derived node role, for the topology chips. */ +export const ROLE_VISUALS: Record = { + postgres: { Icon: Database, color: 'var(--node-postgres)' }, + redis: { Icon: Zap, color: 'var(--node-redis)' }, + mongo: { Icon: Braces, color: 'var(--node-mongo)' }, + loadBalancer: { Icon: GitFork, color: 'var(--node-load-balancer)' }, + autoScaling: { Icon: Layers, color: 'var(--node-auto-scaling)' }, + httpService: { Icon: Globe, color: 'var(--node-http)' }, + container: { Icon: Box, color: 'var(--color-text-muted)' }, +}; diff --git a/frontend/src/pages/ProjectsPage/components/learning/roadmapVisual.ts b/frontend/src/pages/ProjectsPage/components/learning/roadmapVisual.ts new file mode 100644 index 0000000..0811d46 --- /dev/null +++ b/frontend/src/pages/ProjectsPage/components/learning/roadmapVisual.ts @@ -0,0 +1,22 @@ +import { Database, Map, Network } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; + +export interface RoadmapVisual { + Icon: LucideIcon; +} + +/** + * Icon per known roadmap, for the showcase card and briefing tiles. Shape + * only — the tile is neutral, since a roadmap's hue never meant anything. + * Community roadmaps (unknown ids) fall back to a generic visual. + */ +const VISUALS: Record = { + 'cache-aside-redis': { Icon: Database }, + 'resilient-three-tier': { Icon: Network }, +}; + +const FALLBACK: RoadmapVisual = { Icon: Map }; + +export function roadmapVisual(roadmapId: string): RoadmapVisual { + return VISUALS[roadmapId] ?? FALLBACK; +} diff --git a/frontend/src/pages/ProjectsPage/components/learning/sampleReceipt.ts b/frontend/src/pages/ProjectsPage/components/learning/sampleReceipt.ts new file mode 100644 index 0000000..c6a8c03 --- /dev/null +++ b/frontend/src/pages/ProjectsPage/components/learning/sampleReceipt.ts @@ -0,0 +1,33 @@ +import type { ReceiptCheck } from '../../../../shared/components/Receipt'; + +/** + * The sample receipt shown on the learning page. Always presented under a + * "Sample validation receipt" title — it illustrates what a real receipt looks + * like and must never masquerade as a live result. One per screen. + * + * Every row mirrors an actual validator of the `cache-aside-redis` roadmap + * (`container_running`, `edge_exists`, `port_denied`, `http_get_contains`, + * `redis_key_exists`) — nothing here is a capability Torollo doesn't have. + * + * The transcript is deliberately **not translated**: it is machine output, and + * the terse tokens are what keeps the value column narrow enough to align. + * Only the chrome around it (title, verdict) goes through i18n. + */ +export const SAMPLE_CONTEXT = 'cache-aside-redis · step 6/8 · cache-aside'; + +export const SAMPLE_CHECKS: ReceiptCheck[] = [ + { label: 'container "cache" running', value: 'up 4m' }, + { label: 'web → cache :6379', value: 'ALLOW' }, + { label: 'cache → db :5432', value: 'DENY' }, + { label: 'GET / contains "Nimbus Books"', value: '200 OK' }, + { label: 'redis key "cache:books"', value: 'EXISTS' }, +]; + +export const SAMPLE_FOOTER = `${SAMPLE_CHECKS.length}/${SAMPLE_CHECKS.length} checks passed`; + +/** The same receipt as plain text, for the copy button. */ +export const SAMPLE_RECEIPT_TEXT = [ + SAMPLE_CONTEXT, + ...SAMPLE_CHECKS.map(({ label, value }) => `✓ ${label.padEnd(34)}${value}`), + SAMPLE_FOOTER, +].join('\n'); diff --git a/frontend/src/shared/components/Button.tsx b/frontend/src/shared/components/Button.tsx new file mode 100644 index 0000000..f35467f --- /dev/null +++ b/frontend/src/shared/components/Button.tsx @@ -0,0 +1,24 @@ +interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: 'primary' | 'outline'; + size?: 'md' | 'lg'; +} + +/** + * The one button treatment of the app (DESIGN §4.4). Visuals live in the + * `.btn*` classes in index.css so hover states stay in CSS; link-shaped + * actions can reuse those classes directly on an . + */ +export default function Button({ + variant = 'outline', + size = 'md', + className, + children, + ...rest +}: ButtonProps) { + const classes = ['btn', `btn-${variant}`, `btn-${size}`, className].filter(Boolean).join(' '); + return ( + + ); +} diff --git a/frontend/src/shared/components/ProgressBar.tsx b/frontend/src/shared/components/ProgressBar.tsx new file mode 100644 index 0000000..505ac76 --- /dev/null +++ b/frontend/src/shared/components/ProgressBar.tsx @@ -0,0 +1,39 @@ +interface ProgressBarProps { + value: number; + max: number; + tone?: 'accent' | 'success'; + ariaLabel: string; +} + +/** Thin determinate progress bar (DESIGN §4.4) — 4px track, semantic fill. */ +export default function ProgressBar({ value, max, tone = 'accent', ariaLabel }: ProgressBarProps) { + const safeMax = Math.max(1, max); + const clamped = Math.min(Math.max(0, value), safeMax); + const color = tone === 'success' ? 'var(--color-success)' : 'var(--color-accent)'; + return ( +
+
+
+ ); +} + +const styles: Record = { + track: { + height: '4px', + borderRadius: 'var(--radius-sm)', + background: 'var(--bg-main)', + overflow: 'hidden', + }, + fill: { + height: '100%', + borderRadius: 'var(--radius-sm)', + transition: 'width 0.2s ease-out', + }, +}; diff --git a/frontend/src/shared/components/Receipt.test.tsx b/frontend/src/shared/components/Receipt.test.tsx new file mode 100644 index 0000000..3e66e86 --- /dev/null +++ b/frontend/src/shared/components/Receipt.test.tsx @@ -0,0 +1,61 @@ +import '../../i18n'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import Receipt from './Receipt'; + +describe('Receipt', () => { + it('renders every line and the label eyebrow', () => { + render( + + ); + + expect(screen.getByText('Sample validation receipt')).toBeTruthy(); + expect(screen.getByText('checked: redis-cli GET session:demo')).toBeTruthy(); + expect(screen.getByText('→ "ok"')).toBeTruthy(); + }); + + it('renders structured checks with their observed values, verdict and footer', () => { + render( + + ); + + expect(screen.getByText('Step passed')).toBeTruthy(); + expect(screen.getByText('cache-aside-redis · step 6 of 8')).toBeTruthy(); + expect(screen.getByText('container "cache" running')).toBeTruthy(); + expect(screen.getByText('up 4m')).toBeTruthy(); + expect(screen.getByText('DENY')).toBeTruthy(); + expect(screen.getByText('2 checks passed')).toBeTruthy(); + }); + + it('shows no copy button without copyText', () => { + render(); + + expect(screen.queryByRole('button')).toBeNull(); + }); + + it('copies the given text to the clipboard and confirms', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Copy' })); + + expect(writeText).toHaveBeenCalledWith('checked: something'); + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Copied' })).toBeTruthy(); + }); + }); +}); diff --git a/frontend/src/shared/components/Receipt.tsx b/frontend/src/shared/components/Receipt.tsx new file mode 100644 index 0000000..24aaa13 --- /dev/null +++ b/frontend/src/shared/components/Receipt.tsx @@ -0,0 +1,275 @@ +import { Fragment, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Check, Copy } from 'lucide-react'; + +/** One validated check: what was looked at, and what was observed. */ +export interface ReceiptCheck { + /** What the check asserted — the real validator's subject, never a paraphrase. */ + label: string; + /** What was observed, right-aligned so a column of results reads at a glance. */ + value: string; +} + +interface ReceiptProps { + /** Plain transcript rows, rendered as-is. Use `checks` when results have a value column. */ + lines?: string[]; + /** Structured rows: a pass glyph, the assertion, and the observed value. */ + checks?: ReceiptCheck[]; + /** Small sans eyebrow above the block (e.g. "Sample validation receipt"). */ + label?: string; + /** Verdict shown next to the eyebrow. Only ever set when every check passed. */ + verdict?: string; + /** Muted mono line at the top of the block — what this receipt is about. */ + context?: string; + /** Muted mono line in the footer, opposite the copy button. */ + footer?: string; + /** Shows a copy button when set. */ + copyText?: string; +} + +/** + * The signature evidence block (DESIGN §2): a quiet monospace transcript of + * the real thing that happened under the hood. Fades in once, never animates + * for attention. The only color it carries is the pass glyph and the verdict — + * both genuine statuses; every other fact stays neutral. + */ +export default function Receipt({ + lines, + checks, + label, + verdict, + context, + footer, + copyText, +}: ReceiptProps) { + const { t } = useTranslation(); + const [copied, setCopied] = useState(false); + const resetTimer = useRef(undefined); + + useEffect(() => () => window.clearTimeout(resetTimer.current), []); + + const handleCopy = async () => { + if (!copyText) return; + try { + await navigator.clipboard.writeText(copyText); + setCopied(true); + window.clearTimeout(resetTimer.current); + resetTimer.current = window.setTimeout(() => setCopied(false), 1500); + } catch { + // Clipboard access denied — the button simply stays in its idle state. + } + }; + + const copyButton = copyText && ( + + ); + + // The footer row owns the copy button as soon as the receipt has one, so the + // transcript itself is never crowded by chrome. + const hasFooterRow = Boolean(footer) || (Boolean(copyText) && Boolean(checks)); + + // The checks variant is the standalone evidence card of DESIGN §2: the + // header lives inside it and the transcript sits on the card itself, so the + // page never stacks a box inside a box. + const isCard = Boolean(checks); + + return ( +
+ {(label || verdict) && ( +
+ {label && {label}} + {verdict && ( + + + {verdict} + + )} +
+ )} +
+ {context &&
{context}
} + + {checks && ( +
+ {checks.map(({ label: checkLabel, value }, i) => ( + + + {checkLabel} + {value} + + ))} +
+ )} + + {lines && ( +
+
+ {lines.map((line, i) => ( + + {line} + + ))} +
+ {!hasFooterRow && copyButton} +
+ )} + + {hasFooterRow && ( +
+ {footer} + {copyButton} +
+ )} +
+
+ ); +} + +const styles: Record = { + card: { + background: 'var(--bg-surface-solid)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-lg)', + padding: 'var(--space-5)', + animation: 'receiptFadeIn 120ms ease-out', + }, + cardTitle: { + fontSize: 'var(--text-lg)', + fontWeight: 700, + color: 'var(--color-text-primary)', + }, + verdictPill: { + display: 'inline-flex', + alignItems: 'center', + gap: '5px', + padding: '3px 10px', + borderRadius: '999px', + background: 'var(--color-success-glow)', + fontSize: 'var(--text-xs)', + fontWeight: 600, + color: 'var(--color-success)', + whiteSpace: 'nowrap', + }, + head: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 'var(--space-3)', + marginBottom: 'var(--space-2)', + }, + cardHead: { + marginBottom: 'var(--space-4)', + }, + label: { + fontSize: 'var(--text-xs)', + fontWeight: 500, + textTransform: 'uppercase', + letterSpacing: '0.5px', + color: 'var(--color-text-muted)', + }, + verdict: { + display: 'inline-flex', + alignItems: 'center', + gap: 'var(--space-1)', + fontSize: 'var(--text-xs)', + fontWeight: 600, + color: 'var(--color-success)', + whiteSpace: 'nowrap', + }, + block: { + background: 'var(--bg-subtle)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-sm)', + padding: 'var(--space-3)', + animation: 'receiptFadeIn 120ms ease-out', + }, + context: { + fontFamily: 'var(--font-mono)', + fontSize: 'var(--text-sm)', + color: 'var(--color-text-muted)', + marginBottom: 'var(--space-3)', + }, + checks: { + display: 'grid', + gridTemplateColumns: 'auto minmax(0, 1fr) auto', + columnGap: 'var(--space-4)', + rowGap: 'var(--space-2)', + alignItems: 'baseline', + }, + checkGlyph: { + alignSelf: 'center', + }, + checkLabel: { + fontFamily: 'var(--font-mono)', + fontSize: 'var(--text-sm)', + color: 'var(--color-text-primary)', + lineHeight: 1.6, + overflowWrap: 'anywhere', + }, + checkValue: { + fontFamily: 'var(--font-mono)', + fontSize: 'var(--text-sm)', + color: 'var(--color-text-secondary)', + lineHeight: 1.6, + whiteSpace: 'nowrap', + }, + linesRow: { + display: 'flex', + alignItems: 'flex-start', + gap: 'var(--space-2)', + }, + lines: { + display: 'flex', + flexDirection: 'column', + gap: 'var(--space-1)', + minWidth: 0, + flex: 1, + }, + line: { + fontFamily: 'var(--font-mono)', + fontSize: 'var(--text-xs)', + color: 'var(--color-text-secondary)', + lineHeight: 1.6, + overflowWrap: 'anywhere', + }, + footer: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 'var(--space-3)', + marginTop: 'var(--space-4)', + paddingTop: 'var(--space-3)', + borderTop: '1px solid var(--border-color)', + }, + footerText: { + fontFamily: 'var(--font-mono)', + fontSize: 'var(--text-sm)', + color: 'var(--color-text-muted)', + overflowWrap: 'anywhere', + }, + copyBtn: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: '24px', + height: '24px', + flexShrink: 0, + background: 'transparent', + border: 'none', + borderRadius: 'var(--radius-sm)', + cursor: 'pointer', + padding: 0, + }, +}; diff --git a/frontend/src/shared/components/Skeleton.tsx b/frontend/src/shared/components/Skeleton.tsx new file mode 100644 index 0000000..c4e154a --- /dev/null +++ b/frontend/src/shared/components/Skeleton.tsx @@ -0,0 +1,16 @@ +interface SkeletonProps { + width?: string; + height?: string; + radius?: string; +} + +/** One loading placeholder block — compose several to sketch a card's layout. */ +export default function Skeleton({ width = '100%', height = '14px', radius }: SkeletonProps) { + return ( +