docs: plan d'implémentation jalon 2 (polish design system)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 05:09:14 +02:00
parent 8d105b63ec
commit 1310bc1637
@@ -0,0 +1,894 @@
# Jalon 2 — Polish design system — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Refondre l'UI existante avec les composants du design system Gruvbox (Button, IconButton, StatusLed, Popup), brancher Font Awesome + les polices en offline, ajouter un header (titre + ajout + bascule thème) et une status bar tmux, et rendre le terminal non ambigu entre machines.
**Architecture:** Frontend React/Vite. Le `ui-kit.tsx` (design system) passe en exports ESM. L'état (machines, compteurs, machine sélectionnée, thème) remonte dans `App`, qui distribue en props au Header, au Dashboard (présentationnel), à la StatusBar et au TerminalPanel. Helpers purs (`theme`, `stats`) testés ; le reste est vérifié visuellement.
**Tech Stack:** React 19, Vite 6, @xterm/xterm, @fortawesome/fontawesome-free, @fontsource/{inter,jetbrains-mono,share-tech-mono}, vitest.
---
## File Structure
```
client/src/
├─ main.tsx # MODIF: imports CSS FA + polices
├─ App.tsx # MODIF: remontée d'état, header+statusbar+thème
├─ components/ui-kit.tsx # MODIF: ajout exports ESM (1 ligne)
├─ styles/app.css # MODIF: classes header/statusbar/input/term-header
├─ lib/
│ ├─ theme.ts # NOUVEAU: thème (getInitial/apply/next)
│ ├─ theme.test.ts # NOUVEAU
│ ├─ stats.ts # NOUVEAU: sumUpdates
│ └─ stats.test.ts # NOUVEAU
├─ panels/
│ ├─ Header.tsx # NOUVEAU
│ ├─ StatusBar.tsx # NOUVEAU
│ ├─ HermesPanel.tsx # MODIF: label + Icon
│ ├─ Dashboard.tsx # MODIF: présentationnel (props)
│ └─ TerminalPanel.tsx # MODIF: machine + en-tête + bannière
└─ features/machines/
├─ MachineTile.tsx # MODIF: StatusLed + IconButton
└─ AddMachineModal.tsx # MODIF: Popup + Button
vitest.config.ts # MODIF: inclure client/**/*.test.ts
package.json # MODIF: deps FA + fontsource
```
Le composant `ui-kit.tsx` ne doit JAMAIS être importé dans un test (il touche `window`/`document` au chargement → KO en environnement node). Les tests ne portent que sur `lib/theme.ts` et `lib/stats.ts` (purs, node-safe).
---
## Task 1: Brancher le design system (deps + exports + CSS)
**Files:**
- Modify: `package.json`
- Modify: `client/src/components/ui-kit.tsx` (fin de fichier)
- Modify: `client/src/main.tsx`
- [ ] **Step 1: Installer les dépendances**
Run:
```bash
rtk pnpm add @fortawesome/fontawesome-free @fontsource/inter @fontsource/jetbrains-mono @fontsource/share-tech-mono
```
Expected: 4 paquets ajoutés dans `dependencies`, `pnpm-lock.yaml` mis à jour, install OK.
- [ ] **Step 2: Exporter les composants du design system**
Ajouter à la toute fin de `client/src/components/ui-kit.tsx` (après le dernier `})();`) :
```ts
export {
Icon, Tooltip, IconButton, Toggle, StatusLed,
BatteryGauge, RadialGauge, BigRadialGauge,
Popup, Button, TreeNav, Sparkline, LineChart,
};
```
Ne rien supprimer (garder `// @ts-nocheck`, l'import React, le `Object.assign(window, …)`).
- [ ] **Step 3: Importer FA + polices dans main.tsx**
Remplacer le contenu de `client/src/main.tsx` par :
```tsx
import React from "react";
import { createRoot } from "react-dom/client";
import "@fortawesome/fontawesome-free/css/all.min.css";
import "@fontsource/inter";
import "@fontsource/jetbrains-mono";
import "@fontsource/share-tech-mono";
import "./styles/app.css";
import { App } from "./App.js";
createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
```
- [ ] **Step 4: Vérifier le build**
Run: `rtk pnpm check && rtk pnpm vite build`
Expected: `pnpm check` 0 erreur, build OK (`dist/client` produit, le CSS FA/polices intégré).
- [ ] **Step 5: Commit**
```bash
rtk git add package.json pnpm-lock.yaml client/src/components/ui-kit.tsx client/src/main.tsx
rtk git commit -m "feat(ui): brancher le design system (exports ESM, Font Awesome, polices offline)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 2: Helper thème (TDD)
**Files:**
- Modify: `vitest.config.ts`
- Create: `client/src/lib/theme.ts`, `client/src/lib/theme.test.ts`
- [ ] **Step 1: Inclure les tests client dans vitest**
Dans `vitest.config.ts`, remplacer la ligne `include` par :
```ts
include: ["server/**/*.test.ts", "shared/**/*.test.ts", "client/**/*.test.ts"],
```
- [ ] **Step 2: Écrire le test (échec attendu)**
`client/src/lib/theme.test.ts` :
```ts
import { describe, it, expect, beforeEach } from "vitest";
import { nextTheme, getInitialTheme } from "./theme.js";
describe("nextTheme", () => {
it("bascule dark <-> light", () => {
expect(nextTheme("dark")).toBe("light");
expect(nextTheme("light")).toBe("dark");
});
});
describe("getInitialTheme", () => {
beforeEach(() => {
// @ts-expect-error - environnement node sans localStorage
delete globalThis.localStorage;
});
it("retombe sur dark sans localStorage", () => {
expect(getInitialTheme()).toBe("dark");
});
it("lit la valeur persistée si présente", () => {
const store: Record<string, string> = { "su-theme": "light" };
// @ts-expect-error - stub minimal
globalThis.localStorage = { getItem: (k: string) => store[k] ?? null };
expect(getInitialTheme()).toBe("light");
});
});
```
- [ ] **Step 3: Lancer le test (échec)**
Run: `rtk pnpm vitest run client/src/lib/theme.test.ts`
Expected: FAIL — module `./theme.js` introuvable.
- [ ] **Step 4: Implémenter `client/src/lib/theme.ts`**
```ts
// client/src/lib/theme.ts
export type Theme = "dark" | "light";
const KEY = "su-theme";
export function nextTheme(t: Theme): Theme {
return t === "dark" ? "light" : "dark";
}
export function getInitialTheme(): Theme {
try {
const v = globalThis.localStorage?.getItem(KEY);
return v === "light" ? "light" : "dark";
} catch {
return "dark";
}
}
export function applyTheme(t: Theme): void {
try {
document.documentElement.dataset.theme = t;
globalThis.localStorage?.setItem(KEY, t);
} catch {
/* localStorage indisponible (mode privé) : on ignore la persistance */
}
}
```
- [ ] **Step 5: Lancer le test (succès)**
Run: `rtk pnpm vitest run client/src/lib/theme.test.ts`
Expected: PASS (3 tests).
- [ ] **Step 6: Commit**
```bash
rtk git add vitest.config.ts client/src/lib/theme.ts client/src/lib/theme.test.ts
rtk git commit -m "feat(ui): helper de thème dark/light persisté (TDD)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 3: Helper stats (TDD)
**Files:**
- Create: `client/src/lib/stats.ts`, `client/src/lib/stats.test.ts`
- [ ] **Step 1: Écrire le test (échec attendu)**
`client/src/lib/stats.test.ts` :
```ts
import { describe, it, expect } from "vitest";
import { sumUpdates } from "./stats.js";
describe("sumUpdates", () => {
it("somme les compteurs", () => {
expect(sumUpdates({ a: 2, b: 3, c: 0 })).toBe(5);
});
it("retourne 0 pour un objet vide", () => {
expect(sumUpdates({})).toBe(0);
});
});
```
- [ ] **Step 2: Lancer le test (échec)**
Run: `rtk pnpm vitest run client/src/lib/stats.test.ts`
Expected: FAIL — module introuvable.
- [ ] **Step 3: Implémenter `client/src/lib/stats.ts`**
```ts
// client/src/lib/stats.ts
export function sumUpdates(counts: Record<string, number>): number {
return Object.values(counts).reduce((acc, n) => acc + n, 0);
}
```
- [ ] **Step 4: Lancer le test (succès)**
Run: `rtk pnpm vitest run client/src/lib/stats.test.ts`
Expected: PASS (2 tests).
- [ ] **Step 5: Commit**
```bash
rtk git add client/src/lib/stats.ts client/src/lib/stats.test.ts
rtk git commit -m "feat(ui): helper sumUpdates (TDD)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 4: Classes CSS (header, status bar, inputs, en-tête terminal)
**Files:**
- Modify: `client/src/styles/app.css`
- [ ] **Step 1: Mettre à jour `client/src/styles/app.css`**
Remplacer tout le contenu par :
```css
@import "./tokens.css";
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; }
body {
font-family: var(--font-ui);
background: var(--bg-1);
color: var(--ink-1);
}
/* Ossature : header / rangée 3 volets / status bar */
.su-app { display: flex; flex-direction: column; height: 100vh; }
.su-row { flex: 1; display: flex; min-height: 0; }
.su-header {
height: 52px; flex: 0 0 52px;
display: flex; align-items: center; gap: 12px;
padding: 0 16px;
background: var(--bg-2);
border-bottom: 1px solid var(--border-1);
}
.su-header h1 { font-size: 15px; margin: 0; font-weight: 600; }
.su-spacer { flex: 1; }
.su-hermes { width: 280px; min-width: 200px; background: var(--bg-2); border-right: 1px solid var(--border-1); padding: 14px; overflow: auto; }
.su-center { flex: 1; overflow: auto; padding: 18px; }
.su-terminal-wrap { width: 360px; min-width: 320px; display: flex; flex-direction: column; background: var(--bg-0); border-left: 1px solid var(--border-1); }
.su-terminal-head { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--border-1); }
.su-terminal { flex: 1; min-height: 0; padding: 6px; }
.su-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; }
/* Status bar style tmux */
.su-statusbar {
height: 26px; flex: 0 0 26px;
display: flex; align-items: stretch;
background: var(--bg-2);
border-top: 1px solid var(--border-1);
font-family: var(--font-terminal);
font-size: 11px;
}
.su-statusbar .cell { display: flex; align-items: center; padding: 0 12px; border-right: 1px solid var(--border-1); color: var(--ink-2); }
.su-statusbar .cell.mode { background: var(--accent); color: var(--bg-1); font-weight: 700; letter-spacing: 0.08em; }
.su-statusbar .clock { margin-left: auto; border-right: none; border-left: 1px solid var(--border-1); }
/* Champs de formulaire tokenisés */
.su-field {
padding: 9px 12px; font-size: 13px; font-family: var(--font-ui);
background: var(--bg-1); color: var(--ink-1);
border: 1px solid var(--border-2); border-radius: 8px;
outline: none;
}
.su-field:focus { border-color: var(--accent-soft); }
```
- [ ] **Step 2: Vérifier le build**
Run: `rtk pnpm vite build`
Expected: build OK.
- [ ] **Step 3: Commit**
```bash
rtk git add client/src/styles/app.css
rtk git commit -m "feat(ui): classes layout header/statusbar/inputs/terminal
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 5: Composant Header
**Files:**
- Create: `client/src/panels/Header.tsx`
- [ ] **Step 1: Créer `client/src/panels/Header.tsx`**
```tsx
// client/src/panels/Header.tsx
import { Button, IconButton } from "../components/ui-kit.js";
import type { Theme } from "../lib/theme.js";
interface Props {
theme: Theme;
onToggleTheme: () => void;
onAdd: () => void;
}
export function Header({ theme, onToggleTheme, onAdd }: Props) {
return (
<header className="su-header">
<h1>System Update</h1>
<div className="su-spacer" />
<Button variant="primary" icon="plus" onClick={onAdd}>Ajouter</Button>
<IconButton
icon={theme === "dark" ? "sun" : "moon"}
label={theme === "dark" ? "Thème clair" : "Thème sombre"}
onClick={onToggleTheme}
/>
</header>
);
}
```
- [ ] **Step 2: Vérifier la compilation**
Run: `rtk pnpm check`
Expected: 0 erreur.
- [ ] **Step 3: Commit**
```bash
rtk git add client/src/panels/Header.tsx
rtk git commit -m "feat(ui): header (titre, ajout, bascule thème)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 6: Composant StatusBar
**Files:**
- Create: `client/src/panels/StatusBar.tsx`
- [ ] **Step 1: Créer `client/src/panels/StatusBar.tsx`**
```tsx
// client/src/panels/StatusBar.tsx
import { useEffect, useState } from "react";
import { sumUpdates } from "../lib/stats.js";
interface Props {
machineCount: number;
counts: Record<string, number>;
}
export function StatusBar({ machineCount, counts }: Props) {
const [clock, setClock] = useState(() => new Date().toLocaleTimeString("fr-FR"));
useEffect(() => {
const id = setInterval(() => setClock(new Date().toLocaleTimeString("fr-FR")), 1000);
return () => clearInterval(id);
}, []);
return (
<footer className="su-statusbar">
<div className="cell mode">SYSTEM UPDATE</div>
<div className="cell">{machineCount} machine{machineCount > 1 ? "s" : ""}</div>
<div className="cell">{sumUpdates(counts)} update{sumUpdates(counts) > 1 ? "s" : ""}</div>
<div className="cell clock">{clock}</div>
</footer>
);
}
```
- [ ] **Step 2: Vérifier la compilation**
Run: `rtk pnpm check`
Expected: 0 erreur.
- [ ] **Step 3: Commit**
```bash
rtk git add client/src/panels/StatusBar.tsx
rtk git commit -m "feat(ui): status bar tmux (mode, compteurs, horloge live)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 7: Refonte MachineTile (StatusLed + IconButton)
**Files:**
- Modify: `client/src/features/machines/MachineTile.tsx`
- [ ] **Step 1: Remplacer le contenu de `client/src/features/machines/MachineTile.tsx`**
```tsx
// client/src/features/machines/MachineTile.tsx
import type { MachineView } from "@shared/types.js";
import { StatusLed, IconButton } from "../../components/ui-kit.js";
interface Props {
machine: MachineView;
packageCount: number;
selected: boolean;
onSelect: (id: string) => void;
onRefresh: (id: string) => void;
onUpgrade: (id: string) => void;
onReboot: (id: string) => void;
}
// Map statut machine -> statut StatusLed du design system
const LED: Record<string, "ok" | "warn" | "err" | "info" | "off"> = {
ok: "ok", updates_available: "warn", error: "err", running: "info", unknown: "off",
};
export function MachineTile({ machine, packageCount, selected, onSelect, onRefresh, onUpgrade, onReboot }: Props) {
return (
<div
className="glass interactive"
style={{ padding: 16, borderRadius: 10, border: selected ? "1px solid var(--accent-soft)" : "1px solid transparent" }}
onClick={() => onSelect(machine.id)}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<StatusLed status={LED[machine.status] ?? "off"} pulse={machine.status === "running"} />
<strong>{machine.name}</strong>
</div>
<div className="mono" style={{ color: "var(--ink-3)", fontSize: 12, marginTop: 4 }}>
{machine.hostname}:{machine.port} · {machine.osFamily}
</div>
<div style={{ margin: "10px 0", fontSize: 13 }}>
<span className="label">UPDATES</span>{" "}
<span className="mono">{packageCount}</span>
</div>
<div style={{ display: "flex", gap: 6 }} onClick={(e) => e.stopPropagation()}>
<IconButton icon="refresh" label="Rafraîchir" onClick={() => onRefresh(machine.id)} size={30} />
<IconButton icon="download" label="Upgrade" onClick={() => onUpgrade(machine.id)} size={30} />
<IconButton icon="power" label="Redémarrer" danger onClick={() => onReboot(machine.id)} size={30} />
</div>
</div>
);
}
```
- [ ] **Step 2: Vérifier la compilation**
Run: `rtk pnpm check`
Expected: 0 erreur. (Le prop `selected` sera fourni par le Dashboard en Task 10.)
- [ ] **Step 3: Commit**
```bash
rtk git add client/src/features/machines/MachineTile.tsx
rtk git commit -m "feat(ui): tuile machine avec StatusLed + IconButton (tooltips)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 8: Refonte AddMachineModal (Popup + Button)
**Files:**
- Modify: `client/src/features/machines/AddMachineModal.tsx`
- [ ] **Step 1: Remplacer le contenu de `client/src/features/machines/AddMachineModal.tsx`**
```tsx
// client/src/features/machines/AddMachineModal.tsx
import { useState } from "react";
import { Popup, Button, StatusLed } from "../../components/ui-kit.js";
interface Props { onClose: () => void; onCreated: () => void; }
export function AddMachineModal({ onClose, onCreated }: Props) {
const [form, setForm] = useState({ name: "", hostname: "", port: 22, username: "", password: "", sudoPassword: "" });
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const set = (k: string, v: string | number) => setForm({ ...form, [k]: v });
async function submit() {
setBusy(true); setError(null);
try {
const res = await fetch("/api/machines", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ ...form, port: Number(form.port), sudoPassword: form.sudoPassword || null }),
});
if (!res.ok) throw new Error((await res.json()).error ?? "Échec");
onCreated(); onClose();
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
}
return (
<Popup
open
onClose={onClose}
title="Ajouter une machine"
width={400}
footer={
<>
<Button variant="ghost" onClick={onClose}>Annuler</Button>
<Button variant="primary" icon="download" onClick={submit}>{busy ? "Test…" : "Ajouter"}</Button>
</>
}
>
<div style={{ display: "grid", gap: 10 }}>
<input className="su-field" placeholder="nom" value={form.name} onChange={(e) => set("name", e.target.value)} />
<input className="su-field" placeholder="hostname / IP" value={form.hostname} onChange={(e) => set("hostname", e.target.value)} />
<input className="su-field" placeholder="username" value={form.username} onChange={(e) => set("username", e.target.value)} />
<input className="su-field" placeholder="port" type="number" value={form.port} onChange={(e) => set("port", e.target.value)} />
<input className="su-field" placeholder="password" type="password" value={form.password} onChange={(e) => set("password", e.target.value)} />
<input className="su-field" placeholder="sudo password (optionnel)" type="password" value={form.sudoPassword} onChange={(e) => set("sudoPassword", e.target.value)} />
{error && (
<div style={{ display: "flex", alignItems: "center", gap: 8, color: "var(--err)", fontSize: 12 }}>
<StatusLed status="err" /> {error}
</div>
)}
</div>
</Popup>
);
}
```
- [ ] **Step 2: Vérifier la compilation**
Run: `rtk pnpm check`
Expected: 0 erreur.
- [ ] **Step 3: Commit**
```bash
rtk git add client/src/features/machines/AddMachineModal.tsx
rtk git commit -m "feat(ui): modale d'ajout avec Popup + Button du design system
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 9: Refonte TerminalPanel (machine + en-tête + bannière de séparation)
**Files:**
- Modify: `client/src/panels/TerminalPanel.tsx`
- [ ] **Step 1: Remplacer le contenu de `client/src/panels/TerminalPanel.tsx`**
```tsx
// client/src/panels/TerminalPanel.tsx
import { useEffect, useRef } from "react";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import "@xterm/xterm/css/xterm.css";
import type { MachineView } from "@shared/types.js";
import { connectOutput } from "../lib/ws.js";
import { StatusLed } from "../components/ui-kit.js";
const LED: Record<string, "ok" | "warn" | "err" | "info" | "off"> = {
ok: "ok", updates_available: "warn", error: "err", running: "info", unknown: "off",
};
export function TerminalPanel({ machine }: { machine: MachineView | null }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current) return;
const term = new Terminal({
fontFamily: "'Share Tech Mono', monospace", fontSize: 12,
theme: { background: "#1d2021", foreground: "#ebdbb2" },
convertEol: true,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open(ref.current);
fit.fit();
if (machine) {
// Bannière de séparation franche entre machines (couleur accent ANSI 33).
const bar = "─".repeat(20);
term.writeln(`\x1b[33m${bar} ${machine.name} (${machine.hostname}) ${bar}\x1b[0m`);
} else {
term.writeln("# sélectionne une machine");
}
const disconnect = machine ? connectOutput(machine.id, (c) => term.write(c)) : () => {};
return () => { disconnect(); term.dispose(); };
}, [machine?.id]);
return (
<section className="su-terminal-wrap">
<div className="su-terminal-head">
<StatusLed status={machine ? (LED[machine.status] ?? "off") : "off"} />
<span className="label">TERMINAL</span>
{machine && <span className="mono" style={{ fontSize: 12 }}>{machine.name}</span>}
{machine && <span style={{ color: "var(--ink-3)", fontSize: 11 }}>{machine.hostname}</span>}
</div>
<div className="su-terminal" ref={ref} />
</section>
);
}
```
- [ ] **Step 2: Vérifier la compilation**
Run: `rtk pnpm check`
Expected: 0 erreur. (Le prop `machine` sera fourni par `App` en Task 11.)
- [ ] **Step 3: Commit**
```bash
rtk git add client/src/panels/TerminalPanel.tsx
rtk git commit -m "feat(ui): terminal identifie la machine + bannière de séparation
Répond au retour d'usage (amelioration.md): séparation franche entre machines.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 10: Dashboard présentationnel + HermesPanel
**Files:**
- Modify: `client/src/panels/Dashboard.tsx`
- Modify: `client/src/panels/HermesPanel.tsx`
- [ ] **Step 1: Remplacer le contenu de `client/src/panels/Dashboard.tsx`**
```tsx
// client/src/panels/Dashboard.tsx
import type { MachineView } from "@shared/types.js";
import { MachineTile } from "../features/machines/MachineTile.js";
interface Props {
machines: MachineView[];
counts: Record<string, number>;
selectedId: string | null;
onSelect: (id: string) => void;
onRefresh: (id: string) => void;
onUpgrade: (id: string) => void;
onReboot: (id: string) => void;
}
export function Dashboard({ machines, counts, selectedId, onSelect, onRefresh, onUpgrade, onReboot }: Props) {
return (
<main className="su-center">
<h2 style={{ margin: "0 0 16px" }}>Machines</h2>
{machines.length === 0 && (
<p style={{ color: "var(--ink-3)" }}>Aucune machine. Clique sur « Ajouter » dans l'en-tête.</p>
)}
<div className="su-tiles">
{machines.map((m) => (
<MachineTile
key={m.id}
machine={m}
packageCount={counts[m.id] ?? 0}
selected={selectedId === m.id}
onSelect={onSelect}
onRefresh={onRefresh}
onUpgrade={onUpgrade}
onReboot={onReboot}
/>
))}
</div>
</main>
);
}
```
- [ ] **Step 2: Remplacer le contenu de `client/src/panels/HermesPanel.tsx`**
```tsx
// client/src/panels/HermesPanel.tsx
import { Icon } from "../components/ui-kit.js";
export function HermesPanel() {
return (
<aside className="su-hermes">
<div className="label" style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
<Icon name="bell" size={13} /> HERMES
</div>
<p style={{ color: "var(--ink-3)", fontSize: 13 }}>
Copilote d'exploitation à venir. Analyse des mises à jour, plans et rapports
seront disponibles ici dans un prochain jalon.
</p>
</aside>
);
}
```
- [ ] **Step 3: Vérifier la compilation**
Run: `rtk pnpm check`
Expected: 0 erreur.
- [ ] **Step 4: Commit**
```bash
rtk git add client/src/panels/Dashboard.tsx client/src/panels/HermesPanel.tsx
rtk git commit -m "feat(ui): Dashboard présentationnel (props) + HermesPanel iconé
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 11: App — remontée d'état, thème, header + status bar
**Files:**
- Modify: `client/src/App.tsx`
- [ ] **Step 1: Remplacer le contenu de `client/src/App.tsx`**
```tsx
// client/src/App.tsx
import { useCallback, useEffect, useState } from "react";
import type { MachineView } from "@shared/types.js";
import { api } from "./lib/api.js";
import { getInitialTheme, applyTheme, nextTheme, type Theme } from "./lib/theme.js";
import { Header } from "./panels/Header.js";
import { StatusBar } from "./panels/StatusBar.js";
import { HermesPanel } from "./panels/HermesPanel.js";
import { Dashboard } from "./panels/Dashboard.js";
import { TerminalPanel } from "./panels/TerminalPanel.js";
import { AddMachineModal } from "./features/machines/AddMachineModal.js";
export function App() {
const [machines, setMachines] = useState<MachineView[]>([]);
const [counts, setCounts] = useState<Record<string, number>>({});
const [selectedId, setSelectedId] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const [theme, setTheme] = useState<Theme>("dark");
const load = useCallback(async () => {
const ms = await api.listMachines();
setMachines(ms);
const entries = await Promise.all(
ms.map(async (m) => {
try { return [m.id, (await api.snapshot(m.id)).apt.count] as const; }
catch { return [m.id, 0] as const; }
}),
);
setCounts(Object.fromEntries(entries));
}, []);
useEffect(() => {
const initial = getInitialTheme();
setTheme(initial);
applyTheme(initial);
void load();
}, [load]);
const toggleTheme = () => {
const t = nextTheme(theme);
setTheme(t);
applyTheme(t);
};
const onRefresh = (id: string) => { setSelectedId(id); void api.refresh(id).then(load); };
const onUpgrade = (id: string) => { setSelectedId(id); void api.runAction(id, "apt_full_upgrade"); };
const onReboot = (id: string) => { setSelectedId(id); void api.runAction(id, "reboot"); };
const selected = machines.find((m) => m.id === selectedId) ?? null;
return (
<div className="su-app">
<Header theme={theme} onToggleTheme={toggleTheme} onAdd={() => setAdding(true)} />
<div className="su-row">
<HermesPanel />
<Dashboard
machines={machines}
counts={counts}
selectedId={selectedId}
onSelect={setSelectedId}
onRefresh={onRefresh}
onUpgrade={onUpgrade}
onReboot={onReboot}
/>
<TerminalPanel machine={selected} />
</div>
<StatusBar machineCount={machines.length} counts={counts} />
{adding && <AddMachineModal onClose={() => setAdding(false)} onCreated={load} />}
</div>
);
}
```
- [ ] **Step 2: Vérifier compilation + build complet**
Run: `rtk pnpm check && rtk pnpm vite build`
Expected: 0 erreur TS, build OK.
- [ ] **Step 3: Commit**
```bash
rtk git add client/src/App.tsx
rtk git commit -m "feat(ui): App orchestre état+thème, header et status bar
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 12: Vérification finale (build + tests + deux thèmes)
**Files:** aucun (vérification).
- [ ] **Step 1: Suite complète**
Run: `rtk pnpm check && rtk pnpm test && rtk pnpm build`
Expected: `check` 0 erreur ; tests verts (serveur 19 + theme 3 + stats 2 = 24) ; build OK (`dist/index.js` + `dist/client`).
- [ ] **Step 2: Vérification visuelle manuelle (utilisateur, navigateur)**
Lancer `pnpm dev`, ouvrir `http://localhost:5173`, vérifier :
- Header avec titre, bouton « Ajouter » (icône +), bascule thème (soleil/lune) qui change l'apparence et persiste après rechargement (F5).
- Les **deux thèmes** (dark ET light) restent lisibles et cohérents.
- Icônes Font Awesome affichées (pas de carré vide), polices Inter/JetBrains Mono/Share Tech Mono appliquées.
- Tuiles : StatusLed colorée selon l'état, 3 IconButton avec **tooltips** au survol, **pas de hover** (pression 3D au clic seulement), sélection visible (bordure accent).
- Modale d'ajout = Popup (titre, bouton fermer, footer Annuler/Ajouter).
- Status bar en bas : « SYSTEM UPDATE » + nb machines + nb updates + **horloge qui avance**.
- Terminal : en-tête avec nom + hostname de la machine ; en sélectionnant une autre machine, **bannière de séparation** claire (ligne accent avec nom/hostname). Plus d'UUID affiché.
- [ ] **Step 3: Commit éventuel de finition**
S'il a fallu un ajustement après vérif visuelle, le committer :
```bash
rtk git add -A
rtk git commit -m "fix(ui): ajustements après vérification visuelle des deux thèmes
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Self-Review (couverture du spec)
- **Wiring DS (exports ESM + FA + polices)** → Task 1. ✓
- **ui-kit jamais importé en test** → tests uniquement sur lib/theme + lib/stats (Tasks 2, 3) ; vitest include ajoute `client/**` mais les seuls tests client sont ces helpers purs. ✓
- **Header (titre, ajout, bascule thème)** → Task 5, câblé en Task 11. ✓
- **Status bar tmux (mode, compteurs, horloge)** → Task 6, câblée Task 11. ✓
- **Thème dark/light persisté** → Task 2 (`lib/theme`), appliqué Task 11. ✓
- **MachineTile : StatusLed + IconButton (tooltips), danger reboot** → Task 7. ✓
- **AddMachineModal : Popup + Button** → Task 8. ✓
- **Dashboard présentationnel** → Task 10. ✓
- **TerminalPanel : machine nommée + bannière de séparation (retour amelioration.md)** → Task 9. ✓
- **Remontée d'état dans App** → Task 11. ✓
- **Deux thèmes vérifiés** → Task 12 step 2. ✓
- **Tests helpers + build** → Tasks 2, 3, 12. ✓
Pas de placeholder. Noms cohérents entre tâches : `MachineTile` reçoit `selected` (Task 7) fourni par Dashboard `selectedId===m.id` (Task 10) depuis App `selectedId` (Task 11) ; `TerminalPanel` reçoit `machine` (Task 9) fourni par App `selected` (Task 11) ; helpers `getInitialTheme`/`applyTheme`/`nextTheme`/`sumUpdates` utilisés tels que définis.