Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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*
Expand Down
10 changes: 10 additions & 0 deletions STORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions src/app/components/LessonPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down Expand Up @@ -392,6 +392,7 @@ function LessonContent({ mod, lesson, moduleId, lessonId }: {
username={terminalUsername}
environment={selectedEnv}
initialState={buildInitialState}
reserveKeyBarEnd={isAiTutorEnabled()}
/>
</div>
</div>
Expand Down Expand Up @@ -478,7 +479,8 @@ export function LessonPage() {
<AiTutorPanel
lang="fr"
role={role}
// Raise the FAB above the terminal mobile key bar on touch (THI-307).
// Raise the FAB above the lesson nav footer; on touch it then sits at the
// end of the terminal key bar, which keeps that end free (reserveKeyBarEnd).
liftAboveMobileBar
lessonContext={{
moduleSlug: moduleId,
Expand Down
58 changes: 58 additions & 0 deletions src/app/components/TerminalColumns.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';

/**
* A table printed in the terminal (`ps`, `Get-Process`, `ls -l`), kept
* unwrapped so its columns stay aligned. When it is wider than the terminal it
* scrolls sideways: iOS shows no scrollbar at rest, so a fade on the right edge
* says more is there, and the block becomes a focusable region so a keyboard
* can scroll it. A table that fits adds no tab stop.
*/
export function TerminalColumns({ label, children }: { label: string; children: ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(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 (
<div className="relative">
<div
ref={ref}
tabIndex={overflows ? 0 : undefined}
role={overflows ? 'region' : undefined}
aria-label={overflows ? `Tableau : ${label}` : undefined}
className="overflow-x-auto overscroll-x-contain pb-1.5 [scrollbar-width:thin] rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
>
<div ref={contentRef} className="w-max min-w-full space-y-0.5">{children}</div>
</div>
{overflows && !atEnd && (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-[var(--github-bg)] to-transparent"
/>
)}
</div>
);
}
54 changes: 35 additions & 19 deletions src/app/components/TerminalEmulator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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;
}

/**
Expand All @@ -115,6 +119,14 @@ interface TerminalEmulatorProps {
*/
const OUTPUT_TEXT = 'whitespace-pre-wrap break-words [word-break:break-word]';

const LINE_COLOR: Record<TerminalLine['type'], string> = {
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;

Expand Down Expand Up @@ -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<TerminalState>(initialState ?? createInitialState);
const [lines, setLines] = useState<TerminalLine[]>(() => {
const welcome = welcomeMessage ?? ENV_MOTD[environment];
Expand Down Expand Up @@ -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 (
<div
Expand All @@ -352,35 +365,37 @@ export function TerminalEmulator({ onCommand, welcomeMessage, className = '', us

{/* Output area */}
<div ref={outputRef} className="flex-1 overflow-y-auto p-4 space-y-0.5 font-mono text-sm min-h-0">
{lines.map((line) => (
<div key={line.id} className="leading-5">
{line.type === 'prompt' ? (
{segments.map((segment) =>
segment.kind === 'columns' ? (
// Columns stay aligned: no wrapping, the block scrolls sideways when narrow.
<TerminalColumns key={`columns-${segment.id}`} label={tableLabel(segment.lines[0].text)}>
{segment.lines.map((line) => (
<div key={line.id} className={`leading-5 min-h-5 whitespace-pre ${LINE_COLOR[line.type]}`}>{line.text}</div>
))}
</TerminalColumns>
) : segment.line.type === 'prompt' ? (
<div key={segment.line.id} className="leading-5">
<div className="flex items-start gap-1 flex-wrap">
<span className={`${promptColor} shrink-0`}>{line.prompt}</span>
<span className="text-[var(--github-text-primary)] break-all">{line.text}</span>
<span className={`${promptColor} shrink-0`}>{segment.line.prompt}</span>
<span className="text-[var(--github-text-primary)] break-all">{segment.line.text}</span>
</div>
) : line.type === 'error' ? (
<div className={`${OUTPUT_TEXT} text-[var(--github-red)]`}>{line.text}</div>
) : line.type === 'success' ? (
<div className={`${OUTPUT_TEXT} text-[#3fb950]`}>{line.text}</div>
) : line.type === 'info' ? (
<div className={`${OUTPUT_TEXT} text-[#58a6ff]`}>{line.text}</div>
) : (
<div className={`${OUTPUT_TEXT} text-[var(--github-text-primary)]`}>{line.text}</div>
)}
</div>
))}
</div>
) : (
// min-h keeps a blank line visible: an empty div would collapse to nothing.
<div key={segment.line.id} className={`leading-5 min-h-5 ${OUTPUT_TEXT} ${LINE_COLOR[segment.line.type]}`}>{segment.line.text}</div>
),
)}

{/* Input line */}
<form onSubmit={handleSubmit} className="flex items-center gap-1 mt-1">
<span className={`${promptColor} shrink-0 font-mono text-base md:text-sm`}>{prompt}</span>
<span className={`${promptColor} shrink-0 font-mono text-base md:pointer-fine:text-sm`}>{prompt}</span>
<input
ref={inputRef}
value={input}
onChange={(e) => 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"
Expand All @@ -398,6 +413,7 @@ export function TerminalEmulator({ onCommand, welcomeMessage, className = '', us
onTab={triggerTabCompletion}
onHistoryPrev={historyPrev}
onHistoryNext={historyNext}
reserveEnd={reserveKeyBarEnd}
/>
)}
</div>
Expand Down
11 changes: 8 additions & 3 deletions src/app/components/TerminalKeyBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<HTMLDivElement>(null);
const [atStart, setAtStart] = useState(true);
// Starts false: on a phone the key set always overflows, so the right fade
Expand Down Expand Up @@ -118,7 +123,7 @@ export function TerminalKeyBar({ onInsert, onTab, onHistoryPrev, onHistoryNext }
}, [updateEdges]);

return (
<div className="relative shrink-0 border-t border-[var(--github-border-primary)] bg-[var(--github-border-secondary)]">
<div className={`relative shrink-0 border-t border-[var(--github-border-primary)] bg-[var(--github-border-secondary)] ${reserveEnd ? 'pe-16' : ''}`}>
<div
ref={scrollRef}
role="toolbar"
Expand Down Expand Up @@ -150,7 +155,7 @@ export function TerminalKeyBar({ onInsert, onTab, onHistoryPrev, onHistoryNext }
{!atEnd && (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-[var(--github-border-secondary)] to-transparent"
className={`pointer-events-none absolute inset-y-0 ${reserveEnd ? 'right-16' : 'right-0'} w-6 bg-gradient-to-l from-[var(--github-border-secondary)] to-transparent`}
/>
)}
</div>
Expand Down
5 changes: 3 additions & 2 deletions src/app/components/ai/AiTutorPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}

Expand All @@ -82,7 +83,7 @@ function readStoredProvider(): Provider {
}

export function AiTutorPanel({ lang = 'fr', lessonContext, role, liftAboveMobileBar = false }: Props) {
const [enabled] = useState<boolean>(() => readEnabled());
const [enabled] = useState<boolean>(() => isAiTutorEnabled());
const [open, setOpen] = useState(false);
const [provider, setProviderState] = useState<Provider>(() => readStoredProvider());
const [hasStoredKey, setHasStoredKey] = useState(false);
Expand Down
71 changes: 71 additions & 0 deletions src/app/components/terminalLayout.ts
Original file line number Diff line number Diff line change
@@ -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<L extends LineLike> =
| { 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<L extends LineLike>(lines: L[]): OutputSegment<L>[] {
const segments: OutputSegment<L>[] = [];
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;
}
Loading
Loading