diff --git a/CHANGELOG.md b/CHANGELOG.md
index b827650..7ca8733 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,14 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi
## [Unreleased]
+## [0.30.0] — 2026-06-23 — polish UI
+### Added
+- Icônes **Lucide** (SVG) partout (chat, voix, review, nav) — fini le texte/emoji.
+- Contexte `appdata` : état backend (phase/outils) + assets en attente partagés ;
+ header affiche phase·outils, **badge de review** dans la nav, refresh après décision.
+### Changed
+- `Review` consomme `appdata` (plus de fetch local). Build OK, 0 vuln.
+
## [0.29.0] — 2026-06-23 — fin Phase 6 (voix v1)
### Added
- `useSpeech` : mode **mains-libres** + wake-word « CHLOVA » (`extractCommand`),
diff --git a/web/package-lock.json b/web/package-lock.json
index 825d9b1..92533fe 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -8,6 +8,7 @@
"name": "chlova-web",
"version": "0.1.0",
"dependencies": {
+ "lucide-react": "^1.21.0",
"react": "19.2.7",
"react-dom": "19.2.7",
"react-router-dom": "7.18.0"
@@ -4939,6 +4940,15 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/lucide-react": {
+ "version": "1.21.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.21.0.tgz",
+ "integrity": "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
diff --git a/web/package.json b/web/package.json
index b75b363..e04eb93 100644
--- a/web/package.json
+++ b/web/package.json
@@ -11,6 +11,7 @@
"typecheck": "tsc -b --noEmit"
},
"dependencies": {
+ "lucide-react": "1.21.0",
"react": "19.2.7",
"react-dom": "19.2.7",
"react-router-dom": "7.18.0"
diff --git a/web/src/App.tsx b/web/src/App.tsx
index c403f49..d68861a 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -1,24 +1,37 @@
import { BrowserRouter, Routes, Route, NavLink, Navigate } from "react-router-dom";
+import { MessageSquare, ShieldCheck, LogOut, Cpu } from "lucide-react";
import { useAuth } from "./auth";
+import { AppDataProvider, useAppData } from "./appdata";
import { Login } from "./pages/Login";
import { Chat } from "./pages/Chat";
import { Review } from "./pages/Review";
function Shell() {
const { logout } = useAuth();
+ const { phase, tools, assets } = useAppData();
const link = ({ isActive }: { isActive: boolean }): string =>
- `px-3 py-2 rounded-md text-sm ${isActive ? "bg-surface-2 text-accent" : "text-muted hover:text-fg"}`;
+ `flex items-center gap-1.5 px-3 py-2 rounded-md text-sm ${isActive ? "bg-surface-2 text-accent" : "text-muted hover:text-fg"}`;
return (
CHLOVA
-
@@ -36,7 +49,13 @@ export function App() {
const { token } = useAuth();
return (
- {token ? : }
+ {token ? (
+
+
+
+ ) : (
+
+ )}
);
}
diff --git a/web/src/appdata.tsx b/web/src/appdata.tsx
new file mode 100644
index 0000000..03d7a55
--- /dev/null
+++ b/web/src/appdata.tsx
@@ -0,0 +1,95 @@
+import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
+import { api, ApiError, type Asset } from "./api";
+import { useAuth } from "./auth";
+
+/**
+ * État applicatif partagé : phase/outils du backend + assets en attente de
+ * review (pour le badge de nav et la vue Review). Rafraîchi à la connexion et
+ * après chaque décision.
+ */
+interface AppData {
+ phase: string;
+ tools: number;
+ assets: Asset[];
+ loading: boolean;
+ error: string | null;
+ refresh: () => Promise;
+ approve: (id: string) => Promise;
+ refuse: (id: string) => Promise;
+}
+
+const Ctx = createContext(null);
+
+export function AppDataProvider({ children }: { children: ReactNode }) {
+ const { token, logout } = useAuth();
+ const [phase, setPhase] = useState("");
+ const [tools, setTools] = useState(0);
+ const [assets, setAssets] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const onErr = useCallback(
+ (err: unknown): void => {
+ if (err instanceof ApiError && err.status === 401) logout();
+ else setError(err instanceof Error ? err.message : "Erreur");
+ },
+ [logout],
+ );
+
+ const refresh = useCallback(async (): Promise => {
+ if (!token) return;
+ setLoading(true);
+ try {
+ const [s, r] = await Promise.all([api.state(token), api.review(token)]);
+ setPhase(s.phase);
+ setTools(s.tools);
+ setAssets(r.assets);
+ setError(null);
+ } catch (err) {
+ onErr(err);
+ } finally {
+ setLoading(false);
+ }
+ }, [token, onErr]);
+
+ const decide = useCallback(
+ async (id: string, action: "approve" | "refuse"): Promise => {
+ if (!token) return;
+ try {
+ if (action === "approve") await api.approve(token, id);
+ else await api.refuse(token, id);
+ await refresh();
+ } catch (err) {
+ onErr(err);
+ }
+ },
+ [token, refresh, onErr],
+ );
+
+ useEffect(() => {
+ void refresh();
+ }, [refresh]);
+
+ return (
+ decide(id, "approve"),
+ refuse: (id) => decide(id, "refuse"),
+ }}
+ >
+ {children}
+
+ );
+}
+
+export function useAppData(): AppData {
+ const ctx = useContext(Ctx);
+ if (!ctx) throw new Error("useAppData hors AppDataProvider");
+ return ctx;
+}
diff --git a/web/src/pages/Chat.tsx b/web/src/pages/Chat.tsx
index d5a2322..486bcdd 100644
--- a/web/src/pages/Chat.tsx
+++ b/web/src/pages/Chat.tsx
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
+import { Mic, Square, Volume2, VolumeX, Radio, Send } from "lucide-react";
import { useAuth } from "../auth";
import { api, ApiError } from "../api";
import { useSpeech } from "../useSpeech";
@@ -106,9 +107,9 @@ export function Chat() {
onClick={toggleSpeak}
aria-label={speakReplies ? "Couper la voix" : "Activer la voix"}
title={speakReplies ? "Voix activée" : "Voix coupée"}
- className={`rounded-md border px-3 py-2 text-sm cursor-pointer ring-accent ${speakReplies ? "border-accent text-accent" : "border-border text-muted"}`}
+ className={`rounded-md border px-3 py-2 cursor-pointer ring-accent ${speakReplies ? "border-accent text-accent" : "border-border text-muted"}`}
>
- {speakReplies ? "Voix ON" : "Voix OFF"}
+ {speakReplies ? : }
)}
- {speech.listening ? "Stop" : "Parler"}
+ {speech.listening ? : }
)}
{speech.sttSupported && (
@@ -134,17 +135,18 @@ export function Chat() {
onClick={toggleHandsFree}
aria-label={speech.handsFree ? "Couper les mains libres" : "Activer les mains libres"}
title="Mains libres (wake-word CHLOVA)"
- className={`rounded-md border px-3 py-2 text-sm cursor-pointer ring-accent ${speech.handsFree ? "border-accent text-accent" : "border-border text-muted"}`}
+ className={`rounded-md border px-3 py-2 cursor-pointer ring-accent ${speech.handsFree ? "border-accent text-accent" : "border-border text-muted"}`}
>
- {speech.handsFree ? "Libre ON" : "Libre"}
+
)}
- Envoyer
+ Envoyer
diff --git a/web/src/pages/Review.tsx b/web/src/pages/Review.tsx
index f4ff596..f60601d 100644
--- a/web/src/pages/Review.tsx
+++ b/web/src/pages/Review.tsx
@@ -1,60 +1,25 @@
-import { useCallback, useEffect, useState } from "react";
-import { useAuth } from "../auth";
-import { api, ApiError, type Asset } from "../api";
+import { Check, X, RefreshCw } from "lucide-react";
+import { useAppData } from "../appdata";
+import type { Asset } from "../api";
export function Review() {
- const { token, logout } = useAuth();
- const [assets, setAssets] = useState([]);
- const [error, setError] = useState(null);
- const [loading, setLoading] = useState(true);
+ const { assets, loading, error, refresh, approve, refuse } = useAppData();
- const guard = useCallback(
- (err: unknown): void => {
- if (err instanceof ApiError && err.status === 401) logout();
- else setError(err instanceof Error ? err.message : "Erreur");
- },
- [logout],
- );
-
- const refresh = useCallback(async (): Promise => {
- if (!token) return;
- setLoading(true);
- try {
- const { assets } = await api.review(token);
- setAssets(assets);
- setError(null);
- } catch (err) {
- guard(err);
- } finally {
- setLoading(false);
- }
- }, [token, guard]);
-
- useEffect(() => {
- void refresh();
- }, [refresh]);
-
- const decide = async (id: string, action: "approve" | "refuse"): Promise => {
- if (!token) return;
- if (action === "refuse" && !confirm(`Refuser définitivement ${id} ?`)) return;
- try {
- if (action === "approve") await api.approve(token, id);
- else await api.refuse(token, id);
- await refresh();
- } catch (err) {
- guard(err);
- }
+ const onRefuse = (id: string): void => {
+ if (confirm(`Refuser définitivement ${id} ?`)) void refuse(id);
};
- const badge = (a: Asset): string =>
- a.riskTier === "privileged" ? "text-danger" : "text-success";
+ const badge = (a: Asset): string => (a.riskTier === "privileged" ? "text-danger" : "text-success");
return (
Review
- void refresh()} className="ml-auto text-sm text-muted hover:text-fg cursor-pointer">
- Rafraîchir
+ void refresh()}
+ className="ml-auto flex items-center gap-1.5 text-sm text-muted hover:text-fg cursor-pointer"
+ >
+ Rafraîchir
@@ -76,16 +41,16 @@ export function Review() {
)}
void decide(a.id, "approve")}
- className="rounded-md bg-success/20 text-success border border-success/40 px-3 py-1 text-sm cursor-pointer ring-accent"
+ onClick={() => void approve(a.id)}
+ className="flex items-center gap-1 rounded-md bg-success/15 text-success border border-success/40 px-3 py-1 text-sm cursor-pointer ring-accent"
>
- Approuver
+ Approuver
void decide(a.id, "refuse")}
- className="rounded-md bg-danger/20 text-danger border border-danger/50 px-3 py-1 text-sm cursor-pointer ring-accent"
+ onClick={() => onRefuse(a.id)}
+ className="flex items-center gap-1 rounded-md bg-danger/15 text-danger border border-danger/50 px-3 py-1 text-sm cursor-pointer ring-accent"
>
- Refuser
+ Refuser