feat(domain,application,infrastructure,backend,web-server,app-tauri,frontend): lot utilisateur #325 #326 — icônes projet avec watermark CLI custom et localisation FR/EN (QA verte)

Lot en un seul commit: les deux features partagent les mêmes contrats
(domain/settings.rs, DTO, commandes Tauri/HTTP, web-server) et les mêmes
surfaces UI — la séparation par fichiers était impossible sans chirurgie
de hunks risquant des états intermédiaires non compilables (convention
lot des batches #310-#313 et #315).

#325 — icône configurable par projet, watermark CLI custom:
- domaine: ProjectIconSet (devicon, simple-icons, lucide, idea),
  ProjectIconRef validé (nom ASCII 1..=64, -_. internes), champ icon
  optionnel sur ProjectSettings; sérialisation camelCase et try_from
  strict
- application: UpdateProjectSettings.execute accepte un patch
  theme_color OU icon (absent = inchangé, null = défaut produit), tests
  in-dependants avec store mémoire
- backend/app-tauri/web-server: ProjectIconRefDto, UpdateProjectSettingsRequestDto
  avec icon optionnel (désérialisation additive), commandes
  update_project_settings alignées Tauri/HTTP, tests DTO et HTTP
  (défaut null, set, clear, nom invalide rejeté)
- frontend: catalogue projectIcons (recherche EN), ProjectIconPicker en
  grille multi-lignes dans Paramètres projet, affichage de l'icône dans
  ProjectTabs et watermark grand format à faible opacité en fond de CLI
  custom; ports ProjectSettings.icon, Partial<ProjectSettings>,
  adapters tauri/mock/http et tests alignés

#326 — localisation FR/EN et traduction des textes IdeA:
- domaine: IdeaLanguage (fr | en), champ language optionnel sur
  IdeaUserSettings (absence = défaut produit, série additive validée)
- backend/web-server: UserSettingsDto.language, update_user_settings
  accepte language, tests HTTP alignés; fixtures structured_launch_d3
  mises à jour
- frontend: i18n/ (catalogue FR/EN, I18nProvider, scan anti-régression
  des sources visibles), GeneralSettings avec sélecteur de langue dans
  les paramètres, setupI18n dans vitest; migration de l'ensemble des
  textes hardcodés vers le catalogue sur toutes les surfaces (agents,
  layout, projets, tickets, sprints, web, plugins, permissions, git,
  templates, skills, mémoire, model servers, embedder, first-run,
  devices, scheduled tasks, workstate, dialogs), FR conservé par défaut;
  recherche d'icônes #325 en anglais conformément au ticket

QA verte sur feature/batch-325-326-project-icons-localisation. Aucun push.
This commit is contained in:
2026-09-11 21:53:56 +02:00
parent cd744b5f18
commit 1a1429f10e
157 changed files with 4531 additions and 3129 deletions

View File

@ -22,6 +22,7 @@ import type {
} from "@/domain";
import { useEffect, useState } from "react";
import { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared";
import { useI18n } from "@/i18n";
import {
ModelServersPanel,
useModelServers,
@ -60,6 +61,7 @@ export function FirstRunWizard({
/** Render the wizard even when it is no longer the first run. */
forceOpen?: boolean;
}) {
const { tr } = useI18n();
// Mode is explicit (ticket #44), driven by the entry point — never inferred
// from `isFirstRun`: reopening from Settings (`forceOpen`) is the "edit" mode,
// which pre-loads and pre-selects the already-configured profiles.
@ -77,14 +79,13 @@ export function FirstRunWizard({
return (
<Panel
aria-label="first run setup"
aria-label={tr("Configuration initiale", "First run setup")}
className="border-primary/40"
title={
<div className="flex flex-col">
<h2 className="text-base font-semibold text-content">Welcome to IdeA</h2>
<h2 className="text-base font-semibold text-content">{tr("Bienvenue dans IdeA", "Welcome to IdeA")}</h2>
<p className="text-sm text-muted">
Choose which AI CLIs to configure. Commands are pre-filled and
editable.
{tr("Choisissez les CLI d’IA à configurer. Les commandes sont préremplies et modifiables.", "Choose which AI CLIs to configure. Commands are pre-filled and editable.")}
</p>
</div>
}
@ -104,11 +105,11 @@ export function FirstRunWizard({
disabled={vm.busy}
className="whitespace-nowrap"
>
Detect installed CLIs
{tr("Détecter les CLI installées", "Detect installed CLIs")}
</Button>
{vm.detecting && (
<span role="status" className="text-xs text-muted">
Detecting…
{tr("Détection…", "Detecting…")}
</span>
)}
{/* F36: declare several local OpenCode profiles. Each click clones the
@ -118,7 +119,7 @@ export function FirstRunWizard({
disabled={vm.busy}
className="whitespace-nowrap"
>
Add OpenCode profile
{tr("Ajouter un profil OpenCode", "Add OpenCode profile")}
</Button>
</Toolbar>
@ -141,7 +142,7 @@ export function FirstRunWizard({
entry.profile.structuredAdapter === "openCode"
? () =>
vm.addOpenCodeProfile({
name: `${entry.profile.name} (copy)`,
name: `${entry.profile.name} (${tr("copie", "copy")})`,
opencode: entry.profile.opencode,
})
: undefined
@ -152,7 +153,7 @@ export function FirstRunWizard({
<footer className="flex gap-2">
<Button variant="primary" onClick={() => void finish()} disabled={vm.busy}>
Save and continue
{tr("Enregistrer et continuer", "Save and continue")}
</Button>
</footer>
</div>
@ -181,6 +182,7 @@ function ProfileRow({
/** Present only for OpenCode rows: clone this row into a new profile (F36). */
onDuplicate?: () => void;
}) {
const { tr } = useI18n();
const { profile, selected, available } = entry;
const errors = validateProfile(profile);
const isOpenCode = profile.structuredAdapter === "openCode";
@ -203,13 +205,13 @@ function ProfileRow({
type="checkbox"
checked={selected}
onChange={onToggle}
aria-label={`use ${profile.name}`}
aria-label={tr(`Utiliser ${profile.name}`, `Use ${profile.name}`)}
className="accent-primary"
/>
<strong className="text-sm text-content">{profile.name}</strong>
</label>
<span
aria-label={`${profile.name} availability`}
aria-label={tr(`Disponibilité de ${profile.name}`, `${profile.name} availability`)}
className={cn(
"text-xs",
available === null
@ -219,21 +221,21 @@ function ProfileRow({
: "text-danger",
)}
>
{available === null ? "—" : available ? "✓ installed" : "✗ not found"}
{available === null ? "—" : available ? tr("✓ installée", "✓ installed") : tr("✗ introuvable", "✗ not found")}
</span>
<div className="ml-auto flex items-center gap-1">
{onDuplicate && (
<Button
size="sm"
aria-label={`duplicate ${profile.name}`}
aria-label={tr(`Dupliquer ${profile.name}`, `Duplicate ${profile.name}`)}
onClick={onDuplicate}
>
Duplicate
{tr("Dupliquer", "Duplicate")}
</Button>
)}
<IconButton
size="sm"
aria-label={`remove ${profile.name}`}
aria-label={tr(`Retirer ${profile.name}`, `Remove ${profile.name}`)}
onClick={onRemove}
>
×
@ -245,9 +247,9 @@ function ProfileRow({
it is editable per profile (several local models coexist). */}
{isOpenCode && (
<label className="flex flex-col gap-1">
<Caption>Name</Caption>
<Caption>{tr("Nom", "Name")}</Caption>
<Input
aria-label={`${profile.name} name`}
aria-label={tr(`Nom de ${profile.name}`, `Name for ${profile.name}`)}
value={profile.name}
invalid={Boolean(errors.name)}
onChange={(e) => onChange({ ...profile, name: e.target.value })}
@ -257,9 +259,9 @@ function ProfileRow({
)}
<label className="flex flex-col gap-1">
<Caption>Command</Caption>
<Caption>{tr("Commande", "Command")}</Caption>
<Input
aria-label={`${profile.name} command`}
aria-label={tr(`Commande de ${profile.name}`, `Command for ${profile.name}`)}
value={profile.command}
invalid={Boolean(errors.command)}
onChange={(e) => onChange({ ...profile, command: e.target.value })}
@ -268,9 +270,9 @@ function ProfileRow({
</label>
<label className="flex flex-col gap-1">
<Caption>Arguments</Caption>
<Caption>{tr("Arguments", "Arguments")}</Caption>
<Input
aria-label={`${profile.name} args`}
aria-label={tr(`Arguments de ${profile.name}`, `Arguments for ${profile.name}`)}
value={argsText}
onChange={(e) => setArgsText(e.target.value)}
onBlur={commitArgs}
@ -307,13 +309,14 @@ function CliModelField({
profile: AgentProfile;
onChange: (p: AgentProfile) => void;
}) {
const { tr } = useI18n();
return (
<label className="flex flex-col gap-1">
<Caption>Modèle</Caption>
<Caption>{tr("Modèle", "Model")}</Caption>
<Input
aria-label={`${profile.name} model`}
aria-label={tr(`Modèle de ${profile.name}`, `Model for ${profile.name}`)}
value={profile.model ?? ""}
placeholder="Laisser vide pour le modèle par défaut de la CLI"
placeholder={tr("Laisser vide pour le modèle par défaut de la CLI", "Leave empty for the CLI default model")}
onChange={(e) => {
const model = e.target.value.trim();
onChange({
@ -323,7 +326,7 @@ function CliModelField({
}}
/>
<small className="text-xs text-faint">
Optionnel. L'authentification Codex/Claude reste gérée par la CLI.
{tr("Facultatif. L’authentification Codex/Claude reste gérée par la CLI.", "Optional. Codex/Claude authentication remains managed by the CLI.")}
</small>
</label>
);
@ -352,6 +355,7 @@ function HttpChatFields({
errors: ProfileErrors;
onChange: (p: AgentProfile) => void;
}) {
const { tr } = useI18n();
const http = profile.chatHttp ?? defaultHttpChatConfig();
const patch = (next: Partial<HttpChatConfig>) =>
onChange({ ...profile, chatHttp: { ...http, ...next } });
@ -359,13 +363,13 @@ function HttpChatFields({
return (
<fieldset className="mt-1 flex flex-col gap-2 rounded-md border border-border/70 p-2">
<legend className="px-1 text-xs font-medium text-muted">
Local / LAN model (OpenAI-compatible)
{tr("Modèle local / réseau (compatible OpenAI)", "Local / LAN model (OpenAI-compatible)")}
</legend>
<label className="flex flex-col gap-1">
<Caption>Endpoint</Caption>
<Caption>{tr("Point d’accès", "Endpoint")}</Caption>
<Input
aria-label={`${profile.name} endpoint`}
aria-label={tr(`Point d’accès de ${profile.name}`, `Endpoint for ${profile.name}`)}
value={http.endpoint}
placeholder="http://localhost:11434/v1"
invalid={Boolean(errors.endpoint)}
@ -377,9 +381,9 @@ function HttpChatFields({
</label>
<label className="flex flex-col gap-1">
<Caption>Model</Caption>
<Caption>{tr("Modèle", "Model")}</Caption>
<Input
aria-label={`${profile.name} model`}
aria-label={tr(`Modèle de ${profile.name}`, `Model for ${profile.name}`)}
value={http.model}
placeholder="qwen2.5-coder"
invalid={Boolean(errors.model)}
@ -389,11 +393,11 @@ function HttpChatFields({
</label>
<label className="flex flex-col gap-1">
<Caption>API key — environment variable name (never the key itself)</Caption>
<Caption>{tr("Clé API — nom de la variable d’environnement (jamais la clé elle-même)", "API key — environment variable name (never the key itself)")}</Caption>
<Input
aria-label={`${profile.name} api key env`}
aria-label={tr(`Variable de clé API de ${profile.name}`, `API key environment variable for ${profile.name}`)}
value={http.apiKeyEnv ?? ""}
placeholder="e.g. OPENAI_API_KEY (variable name, not the secret)"
placeholder={tr("ex. OPENAI_API_KEY (nom de variable, pas le secret)", "e.g. OPENAI_API_KEY (variable name, not the secret)")}
invalid={Boolean(errors.apiKeyEnv)}
onChange={(e) => {
const v = e.target.value.trim();
@ -407,9 +411,9 @@ function HttpChatFields({
<div className="flex flex-wrap gap-2">
<label className="flex min-w-[8rem] flex-1 flex-col gap-1">
<Caption>Request timeout (ms)</Caption>
<Caption>{tr("Délai de requête (ms)", "Request timeout (ms)")}</Caption>
<Input
aria-label={`${profile.name} request timeout`}
aria-label={tr(`Délai de requête de ${profile.name}`, `Request timeout for ${profile.name}`)}
value={http.requestTimeoutMs?.toString() ?? ""}
inputMode="numeric"
invalid={Boolean(errors.requestTimeoutMs)}
@ -421,9 +425,9 @@ function HttpChatFields({
</label>
<label className="flex min-w-[8rem] flex-1 flex-col gap-1">
<Caption>Connect timeout (ms)</Caption>
<Caption>{tr("Délai de connexion (ms)", "Connect timeout (ms)")}</Caption>
<Input
aria-label={`${profile.name} connect timeout`}
aria-label={tr(`Délai de connexion de ${profile.name}`, `Connect timeout for ${profile.name}`)}
value={http.connectTimeoutMs?.toString() ?? ""}
inputMode="numeric"
invalid={Boolean(errors.connectTimeoutMs)}
@ -435,9 +439,9 @@ function HttpChatFields({
</label>
<label className="flex min-w-[8rem] flex-1 flex-col gap-1">
<Caption>Max tool iterations</Caption>
<Caption>{tr("Nombre maximal d’itérations d’outils", "Max tool iterations")}</Caption>
<Input
aria-label={`${profile.name} max tool iterations`}
aria-label={tr(`Itérations d’outils maximales de ${profile.name}`, `Max tool iterations for ${profile.name}`)}
value={http.maxToolIterations?.toString() ?? ""}
inputMode="numeric"
invalid={Boolean(errors.maxToolIterations)}