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

@ -16,6 +16,7 @@ import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Skill, SkillScope } from "@/domain";
import { useI18n } from "@/i18n";
import { Button, Input, cn } from "@/shared";
export interface SkillEditorProps {
@ -40,6 +41,7 @@ export function SkillEditor({
onClose,
busy = false,
}: SkillEditorProps) {
const { tr } = useI18n();
const [name, setName] = useState(skill?.name ?? "");
const [content, setContent] = useState(skill?.contentMd ?? "");
const [scope, setScope] = useState<SkillScope>(skill?.scope ?? "project");
@ -59,17 +61,17 @@ export function SkillEditor({
className="fixed inset-0 z-50 flex flex-col bg-canvas"
role="dialog"
aria-modal="true"
aria-label="skill editor"
aria-label={tr("Éditeur de skill", "Skill editor")}
>
{/* ── Header bar ── */}
<div className="flex shrink-0 items-center justify-between border-b border-border bg-surface px-4 py-3">
<h2 className="text-sm font-semibold text-content">
{editing ? `Edit skill — ${skill.name}` : "New skill"}
{editing ? tr(`Modifier le skill — ${skill.name}`, `Edit skill — ${skill.name}`) : tr("Nouveau skill", "New skill")}
</h2>
<Button
type="button"
variant="ghost"
aria-label="close skill editor"
aria-label={tr("Fermer l’éditeur de skill", "Close skill editor")}
onClick={onClose}
disabled={busy}
>
@ -86,12 +88,12 @@ export function SkillEditor({
<div className="flex shrink-0 flex-wrap items-end gap-3 border-b border-border px-4 py-3">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<label htmlFor="se-name" className="text-xs font-medium text-muted">
Name
{tr("Nom", "Name")}
</label>
<Input
id="se-name"
aria-label="skill name"
placeholder="My skill"
aria-label={tr("Nom du skill", "Skill name")}
placeholder={tr("Mon skill", "My skill")}
value={name}
onChange={(e) => setName(e.target.value)}
disabled={busy}
@ -101,11 +103,11 @@ export function SkillEditor({
<div className="flex min-w-0 flex-col gap-1">
<label htmlFor="se-scope" className="text-xs font-medium text-muted">
Scope
{tr("Portée", "Scope")}
</label>
<select
id="se-scope"
aria-label="skill scope"
aria-label={tr("Portée du skill", "Skill scope")}
value={scope}
onChange={(e) => setScope(e.target.value as SkillScope)}
disabled={busy || editing}
@ -115,8 +117,8 @@ export function SkillEditor({
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
)}
>
<option value="project">Project</option>
<option value="global">Global</option>
<option value="project">{tr("Projet", "Project")}</option>
<option value="global">{tr("Global", "Global")}</option>
</select>
</div>
</div>
@ -128,7 +130,7 @@ export function SkillEditor({
key={t}
type="button"
role="tab"
aria-label={t === "edit" ? "Edit" : "Preview"}
aria-label={t === "edit" ? tr("Modifier", "Edit") : tr("Aperçu", "Preview")}
aria-selected={tab === t}
onClick={() => setTab(t)}
className={cn(
@ -138,7 +140,7 @@ export function SkillEditor({
: "text-muted hover:text-content",
)}
>
{t === "edit" ? "Edit" : "Preview"}
{t === "edit" ? tr("Modifier", "Edit") : tr("Aperçu", "Preview")}
</button>
))}
</div>
@ -147,7 +149,7 @@ export function SkillEditor({
<div className="flex flex-1 flex-col overflow-hidden px-4 py-3">
{tab === "edit" ? (
<textarea
aria-label="skill content"
aria-label={tr("Contenu du skill", "Skill content")}
value={content}
onChange={(e) => setContent(e.target.value)}
disabled={busy}
@ -157,7 +159,7 @@ export function SkillEditor({
"focus:border-primary placeholder:text-faint",
"disabled:cursor-not-allowed disabled:opacity-50",
)}
placeholder="# Skill workflow&#10;&#10;..."
placeholder={tr("# Déroulé du skill\n\n…", "# Skill workflow\n\n…")}
/>
) : (
<div
@ -179,7 +181,7 @@ export function SkillEditor({
</ReactMarkdown>
) : (
<p className="text-sm text-muted italic">
Nothing to preview yet.
{tr("Rien à prévisualiser pour le moment.", "Nothing to preview yet.")}
</p>
)}
</div>
@ -189,16 +191,16 @@ export function SkillEditor({
{/* ── Footer ── */}
<div className="flex shrink-0 items-center justify-end gap-2 border-t border-border px-4 py-3">
<Button type="button" variant="ghost" onClick={onClose} disabled={busy}>
Cancel
{tr("Annuler", "Cancel")}
</Button>
<Button
type="submit"
variant="primary"
aria-label="Save skill"
aria-label={tr("Enregistrer le skill", "Save skill")}
disabled={!canSave}
loading={busy}
>
Save
{tr("Enregistrer", "Save")}
</Button>
</div>
</form>

View File

@ -19,6 +19,7 @@ import { useGateways } from "@/app/di";
import type { Agent, PluginSkillRef, Skill, SkillRef, SkillScope } from "@/domain";
import { useProjectPlugins } from "@/features/plugins/useProjectPlugins";
import { Button, Input, Panel, Spinner, cn } from "@/shared";
import { useI18n } from "@/i18n";
import { SkillEditor } from "./SkillEditor";
import { useSkills } from "./useSkills";
@ -34,6 +35,7 @@ const nativeSkillKey = (ref: SkillRef) => `${ref.scope}:${ref.skillId}`;
const pluginSkillKey = (ref: PluginSkillRef) => `${ref.pluginId}:${ref.skillId}`;
export function SkillsPanel({ projectId }: SkillsPanelProps) {
const { tr } = useI18n();
const vm = useSkills(projectId);
const { agent: agentGateway, skill: skillGateway } = useGateways();
const plugins = useProjectPlugins(projectId);
@ -145,7 +147,7 @@ export function SkillsPanel({ projectId }: SkillsPanelProps) {
return (
<div className="p-4">
{skills.length === 0 ? (
<p className="text-sm text-muted">No {scope} skills yet.</p>
<p className="text-sm text-muted">{scope === "project" ? tr("Aucun skill de projet pour le moment.", "No project skills yet.") : tr("Aucun skill global pour le moment.", "No global skills yet.")}</p>
) : (
<ul className="flex flex-col divide-y divide-border">
{skills.map((s) => (
@ -158,21 +160,21 @@ export function SkillsPanel({ projectId }: SkillsPanelProps) {
<Button
size="sm"
variant="ghost"
aria-label={`edit skill ${s.name}`}
aria-label={tr(`Modifier le skill ${s.name}`, `Edit skill ${s.name}`)}
disabled={vm.busy}
onClick={() => setEditorState({ mode: "edit", skill: s })}
>
Edit
{tr("Modifier", "Edit")}
</Button>
<Button
size="sm"
variant="ghost"
aria-label={`delete skill ${s.name}`}
aria-label={tr(`Supprimer le skill ${s.name}`, `Delete skill ${s.name}`)}
disabled={vm.busy}
onClick={() => void vm.deleteSkill(s.scope, s.id)}
className="text-danger hover:text-danger"
>
Delete
{tr("Supprimer", "Delete")}
</Button>
</div>
</li>
@ -195,7 +197,7 @@ export function SkillsPanel({ projectId }: SkillsPanelProps) {
/>
)}
<Panel title="Skills" className="flex flex-col gap-0">
<Panel title={tr("Skills", "Skills")} className="flex flex-col gap-0">
{(vm.error || plugins.error || assignmentError) && (
<p
role="alert"
@ -206,10 +208,10 @@ export function SkillsPanel({ projectId }: SkillsPanelProps) {
)}
<div className="flex min-h-0 flex-col gap-3 border-b border-border p-4 md:flex-row">
<nav aria-label="cibles des skills" className="flex w-full shrink-0 flex-col gap-1.5 md:w-56">
<h4 className="text-xs font-semibold uppercase tracking-wide text-faint">Agents</h4>
<nav aria-label={tr("Cibles des skills", "Skill targets")} className="flex w-full shrink-0 flex-col gap-1.5 md:w-56">
<h4 className="text-xs font-semibold uppercase tracking-wide text-faint">{tr("Agents", "Agents")}</h4>
{agents.length === 0 ? (
<p className="text-sm text-muted">Aucun agent dans ce projet.</p>
<p className="text-sm text-muted">{tr("Aucun agent dans ce projet.", "No agents in this project.")}</p>
) : agents.map((agent) => (
<button
key={agent.id}
@ -228,21 +230,21 @@ export function SkillsPanel({ projectId }: SkillsPanelProps) {
))}
</nav>
<section className="min-w-0 flex-1" aria-label="attribution des skills">
<section className="min-w-0 flex-1" aria-label={tr("Attribution des skills", "Skill assignment")}>
<div className="mb-3 flex items-center justify-between gap-2">
<div>
<h4 className="text-sm font-semibold text-content">
{selectedAgent ? `Skills — ${selectedAgent.name}` : "Skills"}
{selectedAgent ? `${tr("Skills", "Skills")} — ${selectedAgent.name}` : tr("Skills", "Skills")}
</h4>
<p className="text-xs text-muted">Skills IdeA et plugins disponibles.</p>
<p className="text-xs text-muted">{tr("Skills IdeA et plugins disponibles.", "Available IdeA and plugin skills.")}</p>
</div>
{(assignmentBusy || plugins.busy || plugins.loading) && <Spinner size={14} />}
</div>
<Input
aria-label="rechercher un skill"
aria-label={tr("Rechercher un skill", "Search skills")}
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Rechercher un skill"
placeholder={tr("Rechercher un skill", "Search skills")}
disabled={!selectedAgent}
/>
{selectedAgent && (
@ -254,18 +256,20 @@ export function SkillsPanel({ projectId }: SkillsPanelProps) {
<label key={`${skill.scope}:${skill.id}`} className="flex items-start gap-2 rounded bg-raised px-3 py-2 text-xs text-content">
<input type="checkbox" checked={assigned} disabled={assignmentBusy || vm.busy} onChange={(event) => void setNativeSkill(skill, event.target.checked)} />
<span className="min-w-0 flex-1"><span className="font-medium">{skill.name}</span>{skill.description ? ` — ${skill.description}` : ""}</span>
<span className="text-faint">{skill.scope === "project" ? "Projet" : "Global"}</span>
<span className="text-faint">{skill.scope === "project" ? tr("Projet", "Project") : tr("Global", "Global")}</span>
</label>
);
})}
{staleNativeSkills.filter((ref) => matches(ref.skillId)).map((ref) => (
<div key={`missing:${nativeSkillKey(ref)}`} className="flex items-center justify-between gap-2 rounded border border-warning/40 px-3 py-2 text-xs">
<span><span className="font-medium">{ref.skillId}</span> <span className="text-warning">— Indisponible</span> <span className="text-faint">IdeA</span></span>
<Button size="sm" variant="ghost" disabled={assignmentBusy} onClick={() => void removeNativeSkill(ref)}>Retirer</Button>
<span><span className="font-medium">{ref.skillId}</span> <span className="text-warning">— {tr("Indisponible", "Unavailable")}</span> <span className="text-faint">IdeA</span></span>
<Button size="sm" variant="ghost" disabled={assignmentBusy} onClick={() => void removeNativeSkill(ref)}>{tr("Retirer", "Remove")}</Button>
</div>
))}
<h5 className="mt-2 text-xs font-semibold uppercase tracking-wide text-faint">Plugins</h5>
<h5 className="mt-2 text-xs font-semibold uppercase tracking-wide text-faint">
{tr("Plugins", "Plugins")}
</h5>
{plugins.installed.flatMap((plugin) =>
(plugin.contributes.skills ?? [])
.filter((skill) => matches(`${skill.name} ${skill.description} ${plugin.displayName}`))
@ -275,7 +279,7 @@ export function SkillsPanel({ projectId }: SkillsPanelProps) {
return (
<label key={`${plugin.id}:${skill.id}`} className="flex items-start gap-2 rounded bg-raised px-3 py-2 text-xs text-content">
<input type="checkbox" checked={assigned} disabled={plugins.busy || (!enabled && !assigned)} onChange={(event) => void plugins.setSkillAssigned(selectedAgent.id, plugin.id, skill.id, event.target.checked)} />
<span className="min-w-0 flex-1"><span className="font-medium">{skill.name}</span>{skill.description ? ` — ${skill.description}` : ""}{!enabled && <span className="text-warning"> — Plugin désactivé</span>}</span>
<span className="min-w-0 flex-1"><span className="font-medium">{skill.name}</span>{skill.description ? ` — ${skill.description}` : ""}{!enabled && <span className="text-warning"> — {tr("Plugin désactivé", "Plugin disabled")}</span>}</span>
<span className="text-faint">{plugin.displayName}</span>
</label>
);
@ -283,12 +287,12 @@ export function SkillsPanel({ projectId }: SkillsPanelProps) {
)}
{stalePluginSkills.filter((ref) => matches(`${ref.pluginId} ${ref.skillId}`)).map((ref) => (
<div key={`missing-plugin:${pluginSkillKey(ref)}`} className="flex items-center justify-between gap-2 rounded border border-warning/40 px-3 py-2 text-xs">
<span><span className="font-medium">{ref.skillId}</span> <span className="text-warning">— Indisponible</span> <span className="text-faint">Plugin: {ref.pluginId}</span></span>
<Button size="sm" variant="ghost" disabled={plugins.busy} onClick={() => void plugins.setSkillAssigned(selectedAgent.id, ref.pluginId, ref.skillId, false)}>Retirer</Button>
<span><span className="font-medium">{ref.skillId}</span> <span className="text-warning">— {tr("Indisponible", "Unavailable")}</span> <span className="text-faint">{tr("Plugin", "Plugin")}: {ref.pluginId}</span></span>
<Button size="sm" variant="ghost" disabled={plugins.busy} onClick={() => void plugins.setSkillAssigned(selectedAgent.id, ref.pluginId, ref.skillId, false)}>{tr("Retirer", "Remove")}</Button>
</div>
))}
{nativeSkills.length === 0 && plugins.installed.every((plugin) => (plugin.contributes.skills ?? []).length === 0) && staleNativeSkills.length === 0 && stalePluginSkills.length === 0 && (
<p className="text-sm text-muted">Aucun skill disponible.</p>
<p className="text-sm text-muted">{tr("Aucun skill disponible.", "No skills available.")}</p>
)}
</div>
)}
@ -298,18 +302,18 @@ export function SkillsPanel({ projectId }: SkillsPanelProps) {
{/* ── Create button ── */}
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h4 className="text-xs font-semibold uppercase tracking-wide text-faint">
Skills
{tr("Skills", "Skills")}
</h4>
<div className="flex items-center gap-2">
{vm.busy && <Spinner size={14} />}
<Button
type="button"
variant="primary"
aria-label="create skill"
aria-label={tr("Créer un skill", "Create skill")}
disabled={vm.busy}
onClick={() => setEditorState({ mode: "create" })}
>
New skill
{tr("Nouveau skill", "New skill")}
</Button>
</div>
</div>
@ -317,7 +321,7 @@ export function SkillsPanel({ projectId }: SkillsPanelProps) {
{/* ── Project skills ── */}
<div className="border-b border-border px-4 pt-3">
<h5 className="text-xs font-semibold uppercase tracking-wide text-faint">
Project
{tr("Projet", "Project")}
</h5>
</div>
{renderList("project", vm.projectSkills)}
@ -325,7 +329,7 @@ export function SkillsPanel({ projectId }: SkillsPanelProps) {
{/* ── Global skills ── */}
<div className="border-y border-border px-4 pt-3">
<h5 className="text-xs font-semibold uppercase tracking-wide text-faint">
Global
{tr("Global", "Global")}
</h5>
</div>
{renderList("global", vm.globalSkills)}

View File

@ -43,7 +43,7 @@ function renderSkillsPanel(agent?: MockAgentGateway, skill?: MockSkillGateway) {
async function waitForSkillsIdle() {
await waitFor(() => {
expect(screen.getByRole("button", { name: "create skill" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Créer un skill" })).toBeTruthy();
});
}
@ -54,18 +54,18 @@ async function createSkill(
scope: "project" | "global" = "project",
) {
await waitForSkillsIdle();
fireEvent.click(screen.getByRole("button", { name: "create skill" }));
await waitFor(() => expect(screen.getByLabelText("skill name")).toBeTruthy());
fireEvent.change(screen.getByLabelText("skill name"), {
fireEvent.click(screen.getByRole("button", { name: "Créer un skill" }));
await waitFor(() => expect(screen.getByLabelText("Nom du skill")).toBeTruthy());
fireEvent.change(screen.getByLabelText("Nom du skill"), {
target: { value: name },
});
fireEvent.change(screen.getByLabelText("skill content"), {
fireEvent.change(screen.getByLabelText("Contenu du skill"), {
target: { value: content },
});
fireEvent.change(screen.getByLabelText("skill scope"), {
fireEvent.change(screen.getByLabelText("Portée du skill"), {
target: { value: scope },
});
fireEvent.click(screen.getByRole("button", { name: "Save skill" }));
fireEvent.click(screen.getByRole("button", { name: "Enregistrer le skill" }));
}
// ---------------------------------------------------------------------------
@ -76,8 +76,8 @@ describe("SkillsPanel (with MockSkillGateway)", () => {
it("shows empty state for both scopes initially", async () => {
renderSkillsPanel();
await waitForSkillsIdle();
expect(screen.getByText("No project skills yet.")).toBeTruthy();
expect(screen.getByText("No global skills yet.")).toBeTruthy();
expect(screen.getByText("Aucun skill de projet pour le moment.")).toBeTruthy();
expect(screen.getByText("Aucun skill global pour le moment.")).toBeTruthy();
});
it("creating a project skill adds it to the list", async () => {
@ -100,12 +100,12 @@ describe("SkillsPanel (with MockSkillGateway)", () => {
it("Save is disabled when name or content is empty", async () => {
renderSkillsPanel();
await waitForSkillsIdle();
fireEvent.click(screen.getByRole("button", { name: "create skill" }));
fireEvent.click(screen.getByRole("button", { name: "Créer un skill" }));
await waitFor(() =>
expect(screen.getByRole("button", { name: "Save skill" })).toBeTruthy(),
expect(screen.getByRole("button", { name: "Enregistrer le skill" })).toBeTruthy(),
);
const save = screen.getByRole("button", {
name: "Save skill",
name: "Enregistrer le skill",
}) as HTMLButtonElement;
expect(save.disabled).toBe(true);
});
@ -115,14 +115,14 @@ describe("SkillsPanel (with MockSkillGateway)", () => {
await createSkill("Editable", "old", "project");
await screen.findByText("Editable");
fireEvent.click(screen.getByRole("button", { name: "edit skill Editable" }));
fireEvent.click(screen.getByRole("button", { name: "Modifier le skill Editable" }));
await waitFor(() =>
expect(screen.getByLabelText("skill content")).toBeTruthy(),
expect(screen.getByLabelText("Contenu du skill")).toBeTruthy(),
);
fireEvent.change(screen.getByLabelText("skill content"), {
fireEvent.change(screen.getByLabelText("Contenu du skill"), {
target: { value: "new content" },
});
fireEvent.click(screen.getByRole("button", { name: "Save skill" }));
fireEvent.click(screen.getByRole("button", { name: "Enregistrer le skill" }));
await waitFor(async () => {
const list = await skill.listSkills(PROJECT_ID, "project");
@ -135,7 +135,7 @@ describe("SkillsPanel (with MockSkillGateway)", () => {
await createSkill("ToDelete", "x", "project");
await screen.findByText("ToDelete");
fireEvent.click(
screen.getByRole("button", { name: "delete skill ToDelete" }),
screen.getByRole("button", { name: "Supprimer le skill ToDelete" }),
);
await waitFor(() => expect(screen.queryByText("ToDelete")).toBeNull());
});