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

@ -17,7 +17,7 @@ describe("DockRegion", () => {
</DockRegion>,
);
expect(screen.getByText("docked body")).toBeTruthy();
const handle = screen.getByRole("separator", { name: "resize left dock" });
const handle = screen.getByRole("separator", { name: "redimensionner la zone ancrée à gauche" });
expect(handle.getAttribute("aria-orientation")).toBe("vertical");
});
@ -28,7 +28,7 @@ describe("DockRegion", () => {
<p>body</p>
</DockRegion>,
);
const handle = screen.getByRole("separator", { name: "resize left dock" });
const handle = screen.getByRole("separator", { name: "redimensionner la zone ancrée à gauche" });
fireEvent.pointerDown(handle, { pointerId: 1, clientX: 100 });
fireEvent.pointerMove(handle, { pointerId: 1, clientX: 160 });
expect(onResize).toHaveBeenLastCalledWith(360);
@ -47,7 +47,7 @@ describe("DockRegion", () => {
<p>body</p>
</DockRegion>,
);
const handle = screen.getByRole("separator", { name: "resize right dock" });
const handle = screen.getByRole("separator", { name: "redimensionner la zone ancrée à droite" });
// Dragging left (negative delta) grows a right dock.
fireEvent.pointerDown(handle, { pointerId: 1, clientX: 400 });
fireEvent.pointerMove(handle, { pointerId: 1, clientX: 320 });

View File

@ -20,6 +20,7 @@
import { useRef, type ReactNode } from "react";
import { cn } from "../lib/cn";
import { useI18n } from "@/i18n";
/** Which side of `<main>` the column is docked to. */
export type DockSide = "left" | "right";
@ -59,6 +60,7 @@ export function DockRegion({
"aria-label": ariaLabel,
children,
}: DockRegionProps) {
const { tr } = useI18n();
// Anchor the drag on pointer-down: remember where the pointer and the column
// width started, then map the signed pointer delta to a width delta. A
// left-docked column grows as the pointer moves right; a right-docked one
@ -94,7 +96,7 @@ export function DockRegion({
<div
role="separator"
aria-orientation="vertical"
aria-label={`resize ${side} dock`}
aria-label={tr(`redimensionner la zone ancrée à ${side === "left" ? "gauche" : "droite"}`, `resize ${side} dock`)}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
@ -105,7 +107,7 @@ export function DockRegion({
return (
<aside
aria-label={ariaLabel ?? `${side} dock`}
aria-label={ariaLabel ?? tr(`zone ancrée à ${side === "left" ? "gauche" : "droite"}`, `${side} dock`)}
className={cn(
"flex h-full shrink-0 bg-surface",
// Children come first in the DOM; the flex direction places the handle

View File

@ -73,7 +73,7 @@ describe("FloatingWindow (#16, G1)", () => {
);
// The first focusable element (the Close button in the header) receives focus.
expect(document.activeElement).toBe(
screen.getByRole("button", { name: "close Panel" }),
screen.getByRole("button", { name: "fermer Panel" }),
);
});
@ -83,7 +83,7 @@ describe("FloatingWindow (#16, G1)", () => {
<button>only</button>
</FloatingWindow>,
);
const closeBtn = screen.getByRole("button", { name: "close Panel" });
const closeBtn = screen.getByRole("button", { name: "fermer Panel" });
const onlyBtn = screen.getByRole("button", { name: "only" });
// Focus the last focusable, press Tab ⇒ wraps to the first (close button).
@ -112,7 +112,7 @@ describe("FloatingWindow (#16, G1)", () => {
fireEvent.mouseDown(dialog.parentElement as HTMLElement); // backdrop
expect(onClose).toHaveBeenCalledTimes(2);
fireEvent.click(screen.getByRole("button", { name: "close Panel" }));
fireEvent.click(screen.getByRole("button", { name: "fermer Panel" }));
expect(onClose).toHaveBeenCalledTimes(3);
});

View File

@ -19,6 +19,7 @@ import { useEffect, useRef, type ReactNode } from "react";
import { cn } from "../lib/cn";
import { Button } from "./Button";
import { zIndex } from "./zIndex";
import { useI18n } from "@/i18n";
/** Width presets; height is capped at 80vh with an internal scroll region. */
export type FloatingWindowSize = "sm" | "md" | "lg" | "xl";
@ -79,6 +80,7 @@ function FloatingWindowBody({
children,
}: Omit<FloatingWindowProps, "open">) {
const dialogRef = useRef<HTMLDivElement>(null);
const { tr } = useI18n();
// Keep the latest `onClose` in a ref so the mount-only effect below never
// re-runs when the caller passes an unstable `onClose` closure (a new
@ -168,10 +170,10 @@ function FloatingWindowBody({
<Button
size="sm"
variant="ghost"
aria-label={`close ${title}`}
aria-label={tr(`fermer ${title}`, `close ${title}`)}
onClick={onClose}
>
Close
{tr("Fermer", "Close")}
</Button>
</header>
<div className="min-h-0 flex-1 overflow-auto p-4">{children}</div>

View File

@ -8,6 +8,7 @@
import { cn } from "../lib/cn";
import { IconButton } from "./IconButton";
import type { ReactNode } from "react";
import { useI18n } from "@/i18n";
export interface TabItem {
/** Stable id, returned by `onSelect`/`onClose`. */
@ -35,6 +36,7 @@ export interface TabsProps {
/** A themed tab strip with selection and optional per-tab close. */
export function Tabs({ items, value, onSelect, onClose, className }: TabsProps) {
const { tr } = useI18n();
return (
<div
role="tablist"
@ -76,7 +78,7 @@ export function Tabs({ items, value, onSelect, onClose, className }: TabsProps)
{onClose && (
<IconButton
size="sm"
aria-label={`close ${tab.label}`}
aria-label={tr(`fermer ${tab.label}`, `close ${tab.label}`)}
onClick={() => onClose(tab.id)}
className="opacity-60 group-hover:opacity-100"
>

View File

@ -109,10 +109,10 @@ describe("Tabs", () => {
const { rerender } = render(
<Tabs items={items} value="a" onSelect={() => {}} />,
);
expect(screen.queryByRole("button", { name: "close alpha" })).toBeNull();
expect(screen.queryByRole("button", { name: "fermer alpha" })).toBeNull();
rerender(<Tabs items={items} value="a" onSelect={() => {}} onClose={onClose} />);
fireEvent.click(screen.getByRole("button", { name: "close beta" }));
fireEvent.click(screen.getByRole("button", { name: "fermer beta" }));
expect(onClose).toHaveBeenCalledWith("b");
});
});