diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cba225..e3cac15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ --- +## 📱 29 septembre 2026 — Un terminal lisible sur téléphone comme sur ordinateur + +*Terminal des leçons · mobile 390 px, tablette 1024 px, ordinateur 1280 px* + +Thierry a demandé de vérifier chaque correction à l'écran, sur téléphone et sur ordinateur, et a autorisé les améliorations d'interface qui en découlent. Trois défauts sont apparus, et ils touchaient tous les élèves, pas seulement ceux des nouvelles commandes. + +- **Les tableaux gardent leurs colonnes.** `Get-Process`, `ps`, `Get-ScheduledTask` ou `Get-History` impriment des colonnes alignées. Le terminal les coupait à la largeur de l'écran : sur un téléphone, chaque ligne se cassait en trois morceaux illisibles, et même sur ordinateur la dernière colonne passait à la ligne. Un vrai terminal ne remet jamais un tableau en forme : il le laisse déborder. Le tableau garde donc ses colonnes et défile sur le côté quand il est trop large. Un léger dégradé sur le bord droit signale qu'il y a une suite, car un iPhone n'affiche pas de barre de défilement au repos. Le texte ordinaire continue de passer à la ligne. +- **Les lignes vides réapparaissent.** Une ligne vide ne prenait aucune hauteur à l'écran : `Get-Help` collait ses sections NAME, SYNOPSIS et REMARKS les unes aux autres, et les tableaux de PowerShell perdaient leur respiration. Les sorties ont retrouvé l'espacement du vrai shell. +- **Le bouton du tuteur IA ne cache plus de touches.** Sur téléphone et tablette, le bouton rond du tuteur couvrait les dernières touches de la barre de raccourcis (`|`, `>`, `$`…). La barre s'arrête maintenant avant le bouton, qui se range à son extrémité comme une touche de plus. +- **Plus de zoom surprise sur iPad.** Safari agrandit la page quand on touche un champ de texte écrit en moins de 16 px. Sur un écran tactile, la ligne de commande garde donc 16 px. + +--- + ## 🧪 29 septembre 2026 — Chaque exemple de la Référence fonctionne dans le terminal *Moteur du terminal · exemples de la page Référence en erreur : 32 → 0 · écarts de la théorie : 160 → 158 · blocs bash montrés sous Windows : 37 → 34* diff --git a/STORY.md b/STORY.md index e28cfc6..1963589 100644 --- a/STORY.md +++ b/STORY.md @@ -21,6 +21,16 @@ Ce projet a été construit avec l'aide de Claude — l'IA d'Anthropic, des mod --- +## Regarder l'écran, pas seulement les tests (29 septembre 2026) + +Tous les tests étaient verts quand Thierry m'a demandé de vérifier moi-même, dans un navigateur, sur téléphone et sur ordinateur, que chaque correction se voyait vraiment. J'ai tapé `Get-ScheduledTask` dans le terminal d'une leçon, en largeur de téléphone. Le tableau s'est effondré : trois colonnes coupées en morceaux, une ligne de tirets orpheline. Le moteur répondait exactement comme PowerShell ; c'est l'affichage qui trahissait la réponse. Aucun test ne pouvait le voir, puisque les tests lisent le texte, pas l'écran. + +Le même regard a trouvé deux autres défauts plus anciens. Les lignes vides disparaissaient, parce qu'un bloc vide ne prend aucune hauteur dans une page web. Et sur téléphone, le bouton rond du tuteur IA recouvrait les touches de raccourci que nous avions ajoutées pour ceux qui n'ont pas de clavier physique. J'ai d'abord remonté le bouton au-dessus de la barre ; il cachait alors la fin de la ligne de commande. La bonne réponse était ailleurs : laisser le bouton en place et arrêter la barre avant lui. + +Un point reste ouvert, et je le dis. Quand le clavier de l'iPhone est ouvert, Safari déplace l'écran d'une manière qu'aucune simulation ne reproduit fidèlement. Il faudra une capture sur un vrai iPhone pour confirmer que le bouton et la barre restent alignés. + +--- + ## Le simulateur contre la réalité (29 septembre 2026) La livraison précédente s'était arrêtée sur une liste honnête : 32 exemples de la page Référence échouaient encore, parce que notre terminal ne connaissait pas certaines commandes. Thierry m'a confié la suite sans condition : « s'il y a des lacunes à corriger sur le simulateur, tu les améliores ». La liste est vide. diff --git a/src/app/components/LessonPage.tsx b/src/app/components/LessonPage.tsx index ffd4ce5..3b36866 100644 --- a/src/app/components/LessonPage.tsx +++ b/src/app/components/LessonPage.tsx @@ -18,7 +18,7 @@ import { TerminalState, createInitialState } from '../data/terminalEngine'; import { exerciseAccepts } from '../data/validators'; import { TerminalEmulator } from './TerminalEmulator'; import { Button } from './ui/button'; -import { AiTutorPanel } from './ai/AiTutorPanel'; +import { AiTutorPanel, isAiTutorEnabled } from './ai/AiTutorPanel'; import { useUserRole } from '@/lib/hooks/useUserRole'; function BlockRenderer({ block, env = 'linux' }: { block: ContentBlock; env?: EnvId }) { @@ -392,6 +392,7 @@ function LessonContent({ mod, lesson, moduleId, lessonId }: { username={terminalUsername} environment={selectedEnv} initialState={buildInitialState} + reserveKeyBarEnd={isAiTutorEnabled()} /> @@ -478,7 +479,8 @@ export function LessonPage() { (null); + const contentRef = useRef(null); + const [overflows, setOverflows] = useState(false); + const [atEnd, setAtEnd] = useState(true); + + const update = useCallback(() => { + const el = ref.current; + if (!el) return; + const max = el.scrollWidth - el.clientWidth; + setOverflows(max > 1); + setAtEnd(el.scrollLeft >= max - 1); + }, []); + + useEffect(() => { + update(); + const el = ref.current; + if (!el) return; + el.addEventListener('scroll', update, { passive: true }); + const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(update); + observer?.observe(el); + // The content can widen without the box resizing (a font that loads late). + if (contentRef.current) observer?.observe(contentRef.current); + return () => { + el.removeEventListener('scroll', update); + observer?.disconnect(); + }; + }, [update]); + + return ( +
+
+
{children}
+
+ {overflows && !atEnd && ( +
+ ); +} diff --git a/src/app/components/TerminalEmulator.tsx b/src/app/components/TerminalEmulator.tsx index 5d7de26..f7d2ebd 100644 --- a/src/app/components/TerminalEmulator.tsx +++ b/src/app/components/TerminalEmulator.tsx @@ -3,6 +3,8 @@ import { TerminalState, OutputLine, processCommand, displayPathForEnv, getTabCom import type { SelectedEnvironment } from '../context/EnvironmentContext'; import { spliceAtSelection } from './terminalKeyInsert'; import { TerminalKeyBar } from './TerminalKeyBar'; +import { segmentOutput, tableLabel } from './terminalLayout'; +import { TerminalColumns } from './TerminalColumns'; // ─── Security helpers ───────────────────────────────────────────────────────── @@ -106,6 +108,8 @@ interface TerminalEmulatorProps { environment?: SelectedEnvironment; /** Builds the state the terminal starts from (lesson setup). Read once, on mount. */ initialState?: () => TerminalState; + /** Leave room at the end of the mobile key bar for the AI tutor button. */ + reserveKeyBarEnd?: boolean; } /** @@ -115,6 +119,14 @@ interface TerminalEmulatorProps { */ const OUTPUT_TEXT = 'whitespace-pre-wrap break-words [word-break:break-word]'; +const LINE_COLOR: Record = { + prompt: 'text-[var(--github-text-primary)]', + output: 'text-[var(--github-text-primary)]', + error: 'text-[var(--github-red)]', + success: 'text-[#3fb950]', + info: 'text-[#58a6ff]', +}; + let lineCounter = 0; const nextId = () => ++lineCounter; @@ -146,7 +158,7 @@ function useCoarsePointer(): boolean { return coarse; } -export function TerminalEmulator({ onCommand, welcomeMessage, className = '', username, environment = 'linux', initialState }: TerminalEmulatorProps) { +export function TerminalEmulator({ onCommand, welcomeMessage, className = '', username, environment = 'linux', initialState, reserveKeyBarEnd = false }: TerminalEmulatorProps) { const [termState, setTermState] = useState(initialState ?? createInitialState); const [lines, setLines] = useState(() => { const welcome = welcomeMessage ?? ENV_MOTD[environment]; @@ -329,6 +341,7 @@ export function TerminalEmulator({ onCommand, welcomeMessage, className = '', us const prompt = getEnvPrompt(activeState, environment); const promptColor = ENV_PROMPT_COLOR[environment]; + const segments = useMemo(() => segmentOutput(lines), [lines]); return (
- {lines.map((line) => ( -
- {line.type === 'prompt' ? ( + {segments.map((segment) => + segment.kind === 'columns' ? ( + // Columns stay aligned: no wrapping, the block scrolls sideways when narrow. + + {segment.lines.map((line) => ( +
{line.text}
+ ))} +
+ ) : segment.line.type === 'prompt' ? ( +
- {line.prompt} - {line.text} + {segment.line.prompt} + {segment.line.text}
- ) : line.type === 'error' ? ( -
{line.text}
- ) : line.type === 'success' ? ( -
{line.text}
- ) : line.type === 'info' ? ( -
{line.text}
- ) : ( -
{line.text}
- )} -
- ))} +
+ ) : ( + // min-h keeps a blank line visible: an empty div would collapse to nothing. +
{segment.line.text}
+ ), + )} {/* Input line */}
- {prompt} + {prompt} setInput(sanitiseInput(e.target.value))} maxLength={MAX_INPUT_LENGTH} onKeyDown={handleKeyDown} - className={`flex-1 bg-transparent text-[var(--github-text-primary)] font-mono text-base md:text-sm outline-none min-w-0 ${environment === 'windows' ? 'caret-[#56b6c2]' : environment === 'macos' ? 'caret-[#58a6ff]' : 'caret-[#3fb950]'}`} + className={`flex-1 bg-transparent text-[var(--github-text-primary)] font-mono text-base md:pointer-fine:text-sm outline-none min-w-0 ${environment === 'windows' ? 'caret-[#56b6c2]' : environment === 'macos' ? 'caret-[#58a6ff]' : 'caret-[#3fb950]'}`} aria-label="Commande terminal" autoComplete="off" autoCorrect="off" @@ -398,6 +413,7 @@ export function TerminalEmulator({ onCommand, welcomeMessage, className = '', us onTab={triggerTabCompletion} onHistoryPrev={historyPrev} onHistoryNext={historyNext} + reserveEnd={reserveKeyBarEnd} /> )}
diff --git a/src/app/components/TerminalKeyBar.tsx b/src/app/components/TerminalKeyBar.tsx index 838a263..0a9c782 100644 --- a/src/app/components/TerminalKeyBar.tsx +++ b/src/app/components/TerminalKeyBar.tsx @@ -28,6 +28,11 @@ interface TerminalKeyBarProps { onHistoryPrev: () => void; /** Recall the next command (history ↓). */ onHistoryNext: () => void; + /** + * Keep the right end free for the floating AI tutor button, which sits over + * the bar's last keys (bottom-right, same height). The keys scroll short of it. + */ + reserveEnd?: boolean; } interface InsertKey { @@ -89,7 +94,7 @@ function KeyButton({ ); } -export function TerminalKeyBar({ onInsert, onTab, onHistoryPrev, onHistoryNext }: TerminalKeyBarProps) { +export function TerminalKeyBar({ onInsert, onTab, onHistoryPrev, onHistoryNext, reserveEnd = false }: TerminalKeyBarProps) { const scrollRef = useRef(null); const [atStart, setAtStart] = useState(true); // Starts false: on a phone the key set always overflows, so the right fade @@ -118,7 +123,7 @@ export function TerminalKeyBar({ onInsert, onTab, onHistoryPrev, onHistoryNext } }, [updateEdges]); return ( -
+
diff --git a/src/app/components/ai/AiTutorPanel.tsx b/src/app/components/ai/AiTutorPanel.tsx index 1176b0d..795282a 100644 --- a/src/app/components/ai/AiTutorPanel.tsx +++ b/src/app/components/ai/AiTutorPanel.tsx @@ -67,7 +67,8 @@ interface Props { liftAboveMobileBar?: boolean; } -function readEnabled(): boolean { +/** The tutor (and its floating button) is shown only when the deploy enables it. */ +export function isAiTutorEnabled(): boolean { return import.meta.env.VITE_AI_TUTOR_ENABLED === 'true'; } @@ -82,7 +83,7 @@ function readStoredProvider(): Provider { } export function AiTutorPanel({ lang = 'fr', lessonContext, role, liftAboveMobileBar = false }: Props) { - const [enabled] = useState(() => readEnabled()); + const [enabled] = useState(() => isAiTutorEnabled()); const [open, setOpen] = useState(false); const [provider, setProviderState] = useState(() => readStoredProvider()); const [hasStoredKey, setHasStoredKey] = useState(false); diff --git a/src/app/components/terminalLayout.ts b/src/app/components/terminalLayout.ts new file mode 100644 index 0000000..ce8dfab --- /dev/null +++ b/src/app/components/terminalLayout.ts @@ -0,0 +1,71 @@ +/** + * Layout of the terminal output. A real terminal never reflows a table: `ps`, + * `Get-Process` or `ls -l` rows keep their columns, and a narrow window + * scrolls instead. Lines laid out in columns are therefore grouped so the + * view can show them unwrapped in one horizontally scrollable block, while + * prose keeps wrapping. + */ + +/** Two spaces or more between words (`Handles NPM(K)`). */ +const SPACED = /\S {2,}\S/; +/** A rule under a header: dashes, possibly in several runs (`------- ------`). */ +const RULE = /^-{2,}(?: +-{2,})*$/; + +/** + * A line laid out in columns. Lines that start with a tab are not tables but + * `git status` entries (`\tmodified: style.css`), which may wrap like prose. + */ +export function isColumnar(text: string): boolean { + if (text.startsWith('\t')) return false; + return SPACED.test(text) || RULE.test(text.trim()); +} + +interface LineLike { + id: number; + type: string; + text: string; +} + +export type OutputSegment = + | { kind: 'line'; line: L } + | { kind: 'columns'; id: number; lines: L[] }; + +/** + * Consecutive output lines in columns become one `columns` segment; prompts + * and prose stay single lines. A table has at least two such lines: a lone + * `ls` line (`documents projets notes.txt`) or a Tab-completion list is a + * list of names that may wrap, as a real terminal reflows them. A blank line + * inside a table (between two columnar lines) stays in it, as `Get-History` + * prints one. The segment id is its last line's, which stays stable when the + * scrollback cap drops lines from the top. + */ +export function segmentOutput(lines: L[]): OutputSegment[] { + const segments: OutputSegment[] = []; + let group: L[] = []; + const flush = () => { + // A trailing blank line belongs after the table, not inside it. + const trailing: L[] = []; + while (group.length && group[group.length - 1].text === '') trailing.unshift(group.pop() as L); + const rows = group.filter((l) => l.text !== '').length; + if (rows >= 2) segments.push({ kind: 'columns', id: group[group.length - 1].id, lines: group }); + else group.forEach((line) => segments.push({ kind: 'line', line })); + trailing.forEach((line) => segments.push({ kind: 'line', line })); + group = []; + }; + for (const line of lines) { + const inTable = line.type !== 'prompt' && (isColumnar(line.text) || (group.length > 0 && line.text === '')); + if (inTable) group.push(line); + else { + flush(); + segments.push({ kind: 'line', line }); + } + } + flush(); + return segments; +} + +/** A short name for a table, from its first row (`Handles NPM(K) PM(K)…`). */ +export function tableLabel(firstRow: string): string { + const words = firstRow.trim().replace(/\s+/g, ' '); + return words.length > 60 ? `${words.slice(0, 60)}…` : words; +} diff --git a/src/test/terminalLayout.test.tsx b/src/test/terminalLayout.test.tsx new file mode 100644 index 0000000..6e30389 --- /dev/null +++ b/src/test/terminalLayout.test.tsx @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { TerminalColumns } from '../app/components/TerminalColumns'; +import { isColumnar, segmentOutput, tableLabel } from '../app/components/terminalLayout'; +import { TerminalKeyBar } from '../app/components/TerminalKeyBar'; +import { processCommand, createInitialState } from '../app/data/terminalEngine'; + +let id = 0; +const line = (text: string, type = 'output') => ({ id: ++id, type, text }); + +describe('terminal output layout', () => { + it('recognises lines laid out in columns, not prose', () => { + expect(isColumnar('Handles NPM(K) PM(K)')).toBe(true); + expect(isColumnar('------- ------ -----')).toBe(true); + expect(isColumnar(' 256 14 4560')).toBe(true); + expect(isColumnar('Liste le contenu d\'un répertoire.')).toBe(false); + expect(isColumnar(' Get-ChildItem')).toBe(false); // an indent alone is not a column + expect(isColumnar('')).toBe(false); + // git status entries start with a tab; they are not tables. + expect(isColumnar('\tmodified: style.css')).toBe(false); + }); + + it('leaves a single line of names (ls, Tab completion) as prose that wraps', () => { + const ls = processCommand(createInitialState(), 'ls').lines.map((l) => line(l.text, l.type)); + expect(ls).toHaveLength(1); + expect(segmentOutput(ls).map((s) => s.kind)).toEqual(['line']); + expect(segmentOutput([line('documents/ projets/ notes.txt')]).map((s) => s.kind)).toEqual(['line']); + }); + + it('names a table after its header row', () => { + expect(tableLabel('Handles NPM(K) PM(K)')).toBe('Handles NPM(K) PM(K)'); + }); + + it('groups a table into one block and keeps prompts and prose as lines', () => { + const lines = [ + line('Get-Process', 'prompt'), + line('Handles NPM(K)'), + line('------- ------'), + line(' 256 14'), + line('fin du tableau'), + ]; + const segments = segmentOutput(lines); + expect(segments.map((s) => s.kind)).toEqual(['line', 'columns', 'line']); + const table = segments[1]; + expect(table.kind === 'columns' && table.lines.map((l) => l.text)).toEqual(['Handles NPM(K)', '------- ------', ' 256 14']); + }); + + it('keeps a blank line inside a table, and a trailing one after it', () => { + const lines = [line('a b'), line(''), line('c d'), line('')]; + const segments = segmentOutput(lines); + expect(segments.map((s) => s.kind)).toEqual(['columns', 'line']); + // The id is the last row's: stable when the scrollback cap drops the first. + expect(segments[0].kind === 'columns' && segments[0].id).toBe(lines[2].id); + expect(segments[0].kind === 'columns' && segments[0].lines).toHaveLength(3); + }); + + it('puts every row of the real Get-History table in one block', () => { + let s = createInitialState(); + s = processCommand(s, 'ls', 'windows').newState; + const out = processCommand(s, 'Get-History', 'windows').lines.map((l) => line(l.text, l.type)); + const segments = segmentOutput(out); + // Blank, table (header, rule, row), blank — PowerShell 7's layout. + expect(segments.map((x) => x.kind)).toEqual(['line', 'columns', 'line']); + }); +}); + +describe('TerminalKeyBar reserveEnd', () => { + const handlers = { onInsert: vi.fn(), onTab: vi.fn(), onHistoryPrev: vi.fn(), onHistoryNext: vi.fn() }; + + it('keeps the right end free for the AI tutor button when asked', () => { + const { container } = render(); + expect(container.firstElementChild).toHaveClass('pe-16'); + expect(screen.getByRole('toolbar')).toBeInTheDocument(); + }); + + it('uses the full width otherwise', () => { + const { container } = render(); + expect(container.firstElementChild).not.toHaveClass('pe-16'); + }); +}); + +describe('TerminalColumns', () => { + const sizes = (scrollWidth: number, clientWidth: number) => { + vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockReturnValue(scrollWidth); + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(clientWidth); + }; + afterEach(() => vi.restoreAllMocks()); + + it('adds no tab stop when the table fits', () => { + sizes(300, 300); + render(
a b
); + expect(screen.queryByRole('region')).toBeNull(); + }); + + it('becomes a focusable, labelled region with a fade when the table is wider', () => { + sizes(600, 300); + const { container } = render(
a b
); + const region = screen.getByRole('region', { name: 'Tableau : Handles NPM(K)' }); + expect(region).toHaveAttribute('tabindex', '0'); + expect(container.querySelector('[aria-hidden="true"]')).not.toBeNull(); + }); +});