Files
system_update/docs/superpowers/plans/2026-06-05-jalon2-polish-design-system.md
T
gilles 0fbca06d3d docs: roadmap tâches 1.9-8 (briefs, gates de validation, designs tâche 2) + plans d'implémentation
Cartographie complète (liste_taches/coherence_taches), briefs tacheN + gates
validation_tacheN, design tâche 2 (docs/design/tache2/), specs/plans jalon 1-2
et tâche 1.9/2 (Phase 1, Phase 2, SJ-0→3). Validations consignées (1.9 , 2-8 🟡).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:50:25 +02:00

30 KiB

Jalon 2 — Polish design system — Implementation Plan

⚠️ STATUT (2026-06-05) : ABSORBÉ PAR LA TÂCHE 3. La roadmap liste_taches.md / coherence_taches.md regroupe tout le frontend (layout, tuiles, volet Hermes, terminal, paramètres, thème, status bar, icônes) dans la tâche 3 (design frontend), gate validation_tache3.md. Ce plan jalon-2 reste valide comme matériau d'implémentation du polish : le wiring DS (exports ESM + Font Awesome + polices, Tasks 1-4) est déjà commité et acquis ; les Tasks 5-12 (Header, StatusBar, refonte MachineTile/AddMachineModal/TerminalPanel/Dashboard/App) seront implémentées plus tard dans le cadre de la tâche 3, après validation de son design. Ne pas exécuter ce plan isolément.

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:

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 })();) :


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 :

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
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 :

    include: ["server/**/*.test.ts", "shared/**/*.test.ts", "client/**/*.test.ts"],
  • Step 2: Écrire le test (échec attendu)

client/src/lib/theme.test.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
// 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
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 :

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
// 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
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 :

@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
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

// 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
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

// 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
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

// 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
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

// 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
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

// 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
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

// 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
// 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
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

// 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
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 :

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.