Merge feature/ticket78-settings-french-labels into develop

Ticket #78 : alignement des libellés Settings desktop sur le français, QA
vert (850/850 tests, tsc propre).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 14:57:37 +02:00
6 changed files with 94 additions and 90 deletions

View File

@ -57,10 +57,10 @@ export function ProfilesSettings() {
return (
<Panel
aria-label="ai profiles settings"
title="AI Profiles"
title="Profils IA"
actions={
<Button size="sm" onClick={() => setEditing(true)}>
Configure profiles
Configurer les profils
</Button>
}
>
@ -72,7 +72,7 @@ export function ProfilesSettings() {
)}
{profiles.length === 0 ? (
<p className="text-sm text-muted">No profiles configured.</p>
<p className="text-sm text-muted">Aucun profil configuré.</p>
) : (
<ul className="flex flex-col divide-y divide-border">
{profiles.map((p) => (
@ -87,10 +87,10 @@ export function ProfilesSettings() {
<Button
size="sm"
variant="ghost"
aria-label={`delete ${p.name}`}
aria-label={`supprimer ${p.name}`}
onClick={() => void del(p.id)}
>
Delete
Supprimer
</Button>
</li>
))}

View File

@ -401,9 +401,10 @@ export function ProjectsView() {
},
{
id: "settings",
label: "Settings",
label: "Paramètres",
// One entry per section (#68). The entries name sections and mark the open
// one; closing lives in the view ("Close Settings"), so no label alternates.
// one; closing lives in the view ("Fermer les paramètres"), so no label
// alternates.
items: SETTINGS_SECTIONS.map((section) => ({
id: section,
label: SETTINGS_SECTION_LABEL[section],

View File

@ -208,13 +208,13 @@ describe("ProjectsView (with MockProjectGateway)", () => {
expect(screen.queryByLabelText("ai profiles settings")).toBeNull();
// Settings → AI Profiles swaps the main area to the profiles settings.
openMenuItem("Settings", "AI Profiles");
openMenuItem("Paramètres", "Profils IA");
expect(await screen.findByLabelText("ai profiles settings")).toBeTruthy();
expect(screen.queryByLabelText("project name")).toBeNull();
// #68: closing is an explicit action in the view, not an alternating menu
// label — the label never scaled past one section.
fireEvent.click(screen.getByRole("button", { name: "Close Settings" }));
fireEvent.click(screen.getByRole("button", { name: "Fermer les paramètres" }));
await waitFor(() =>
expect(screen.getByLabelText("project name")).toBeTruthy(),
);
@ -226,13 +226,13 @@ describe("ProjectsView (with MockProjectGateway)", () => {
await waitForIdle();
// The Settings menu now names sections; Deployment opens directly.
openMenuItem("Settings", "Deployment");
openMenuItem("Paramètres", "Déploiement");
expect(await screen.findByLabelText("deployment settings")).toBeTruthy();
expect(screen.queryByLabelText("ai profiles settings")).toBeNull();
// The internal nav column switches sections without leaving Settings.
const nav = screen.getByRole("navigation", { name: "settings sections" });
fireEvent.click(within(nav).getByRole("button", { name: "AI Profiles" }));
fireEvent.click(within(nav).getByRole("button", { name: "Profils IA" }));
expect(await screen.findByLabelText("ai profiles settings")).toBeTruthy();
expect(screen.queryByLabelText("deployment settings")).toBeNull();
});

View File

@ -6,6 +6,9 @@
* is a radio choice with consequences, the authorized-proxy field is explicitly
* *not* a listen address, addresses come from the backend, a refusal is
* actionable, and no pairing code is ever shown here (#77).
*
* Labels asserted here are French (ticket #78 — UX decision: uniform French UI
* per surface, technical acronyms like URL/LAN/IP/CIDR/HTTPS excepted).
*/
import { describe, it, expect, vi } from "vitest";
@ -30,7 +33,7 @@ function renderView(desktopServer = new MockDesktopServerGateway()) {
/** Waits past the hook's preview debounce. */
async function settle() {
await screen.findByRole("radiogroup", { name: "exposure mode" });
await screen.findByRole("radiogroup", { name: "mode d'accès réseau" });
await waitFor(() => expect(screen.getByLabelText("Port")).toBeTruthy());
}
@ -43,58 +46,58 @@ describe("DeploymentSettings", () => {
renderView();
await settle();
const group = screen.getByRole("radiogroup", { name: "exposure mode" });
const group = screen.getByRole("radiogroup", { name: "mode d'accès réseau" });
expect(within(group).getAllByRole("radio")).toHaveLength(3);
// The plain-language consequence is part of the choice, not a tooltip.
expect(
within(group).getByText(/Remote devices cannot connect/),
within(group).getByText(/ne peuvent pas se connecter/),
).toBeTruthy();
expect(
within(group).getByText(/same machine as IdeA Desktop/),
within(group).getByText(/me machine qu'IdeA Desktop/),
).toBeTruthy();
expect(
within(group).getByText(/only accept traffic from that proxy/),
within(group).getByText(/n'acceptera que le trafic provenant de ce proxy/),
).toBeTruthy();
});
it("shows no exposure fields in 'This computer only'", async () => {
it("shows no exposure fields in 'Cet ordinateur uniquement'", async () => {
renderView();
await settle();
// Default is localOnly: nothing to configure, nothing to get wrong.
expect(screen.queryByLabelText("Public origin")).toBeNull();
expect(screen.queryByLabelText("Authorized proxy IP/CIDR")).toBeNull();
expect(screen.queryByLabelText("LAN address to bind")).toBeNull();
expect(screen.queryByLabelText("Origine publique")).toBeNull();
expect(screen.queryByLabelText("IP/CIDR du proxy autorisé")).toBeNull();
expect(screen.queryByLabelText("Adresse LAN d'écoute")).toBeNull();
});
it("asks only for a public origin when the proxy is on this computer", async () => {
renderView();
await settle();
selectMode("Remote access, proxy on this computer");
selectMode("Accès distant, proxy sur cet ordinateur");
expect(await screen.findByLabelText("Public origin")).toBeTruthy();
expect(await screen.findByLabelText("Origine publique")).toBeTruthy();
// The proxy is local, so there is nothing to authorize and nothing to bind.
expect(screen.queryByLabelText("Authorized proxy IP/CIDR")).toBeNull();
expect(screen.queryByLabelText("LAN address to bind")).toBeNull();
expect(screen.queryByLabelText("IP/CIDR du proxy autorisé")).toBeNull();
expect(screen.queryByLabelText("Adresse LAN d'écoute")).toBeNull();
});
it("explains that the authorized proxy is not a listen address, and warns about the mode", async () => {
renderView();
await settle();
selectMode("Remote access, proxy on another machine");
selectMode("Accès distant, proxy sur une autre machine");
// This help text is the whole point of the screen: it corrects the
// "that's where IdeA listens" misreading.
expect(
await screen.findByText(
"This is not where IdeA listens. It is the machine allowed to contact IdeA.",
"Ce n'est pas l'adresse d'écoute d'IdeA. C'est la machine autorisée à contacter IdeA.",
),
).toBeTruthy();
// The permanent warning about the silent-timeout failure mode.
expect(
screen.getByText(/the proxy may time out without an IdeA error/),
screen.getByText(/le proxy peut expirer sans erreur IdeA/),
).toBeTruthy();
});
@ -103,9 +106,9 @@ describe("DeploymentSettings", () => {
const preview = vi.spyOn(gateway, "previewExposure");
renderView(gateway);
await settle();
selectMode("Remote access, proxy on another machine");
selectMode("Accès distant, proxy sur une autre machine");
const select = await screen.findByLabelText("LAN address to bind");
const select = await screen.findByLabelText("Adresse LAN d'écoute");
const offered = within(select as HTMLElement)
.getAllByRole("option")
.map((o) => (o as HTMLOptionElement).value)
@ -120,7 +123,7 @@ describe("DeploymentSettings", () => {
renderView();
await settle();
// A remote mode with no origin: the backend says exactly what to fix.
selectMode("Remote access, proxy on this computer");
selectMode("Accès distant, proxy sur cet ordinateur");
const alert = await screen.findByRole("alert");
expect(alert.textContent).toMatch(/requires publicOrigin/);
@ -129,13 +132,13 @@ describe("DeploymentSettings", () => {
it("shows the upstream to paste, read-only, once the config is valid", async () => {
renderView();
await settle();
selectMode("Remote access, proxy on this computer");
fireEvent.change(await screen.findByLabelText("Public origin"), {
selectMode("Accès distant, proxy sur cet ordinateur");
fireEvent.change(await screen.findByLabelText("Origine publique"), {
target: { value: "https://idea.example.com" },
});
const upstream = await screen.findByRole("button", {
name: "copy upstream url",
name: "copier l'URL upstream",
});
expect(upstream).toBeTruthy();
// The upstream is IdeA-provided, never a field the user edits.
@ -152,8 +155,8 @@ describe("DeploymentSettings", () => {
expect(screen.queryByRole("button", { name: "copy pairing code" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Start" }));
await waitFor(() => expect(screen.getByText("Running")).toBeTruthy());
fireEvent.click(screen.getByRole("button", { name: "Démarrer" }));
await waitFor(() => expect(screen.getByText("En cours d'exécution")).toBeTruthy());
expect(screen.queryByRole("button", { name: "copy pairing code" })).toBeNull();
expect(screen.queryByText(/generate a pairing code/i)).toBeNull();
@ -165,17 +168,17 @@ describe("DeploymentSettings", () => {
renderView();
await settle();
const pairing = screen.getByText(/Pairing is managed in/);
expect(within(pairing).getByText("Settings → Appareils")).toBeTruthy();
const pairing = screen.getByText(/L'appairage est géré dans/);
expect(within(pairing).getByText("Paramètres → Appareils")).toBeTruthy();
});
it("starts the server and reports the local URL", async () => {
renderView();
await settle();
fireEvent.click(screen.getByRole("button", { name: "Start" }));
fireEvent.click(screen.getByRole("button", { name: "Démarrer" }));
await waitFor(() => expect(screen.getByText("Running")).toBeTruthy());
expect(screen.getByText(/Local URL:/)).toBeTruthy();
await waitFor(() => expect(screen.getByText("En cours d'exécution")).toBeTruthy());
expect(screen.getByText(/URL locale :/)).toBeTruthy();
});
it("persists the draft before starting, so what runs is what is shown", async () => {
@ -185,7 +188,7 @@ describe("DeploymentSettings", () => {
await settle();
fireEvent.change(screen.getByLabelText("Port"), { target: { value: "18080" } });
fireEvent.click(screen.getByRole("button", { name: "Start" }));
fireEvent.click(screen.getByRole("button", { name: "Démarrer" }));
await waitFor(() => expect(save).toHaveBeenCalled());
expect(save.mock.calls[0]![0]).toMatchObject({ port: 18080 });
@ -204,7 +207,7 @@ describe("DeploymentSettings", () => {
error: { code: "INVALID", message: "port 17373 already in use" },
});
await waitFor(() => expect(screen.getByText("Failed")).toBeTruthy());
await waitFor(() => expect(screen.getByText("Échec")).toBeTruthy());
expect(screen.getByRole("alert").textContent).toMatch(/already in use/);
});
});

View File

@ -39,30 +39,30 @@ interface ModeOption {
const MODE_OPTIONS: ModeOption[] = [
{
mode: "localOnly",
title: "This computer only",
title: "Cet ordinateur uniquement",
description:
"For using IdeA on this desktop only. Remote devices cannot connect.",
"Pour utiliser IdeA uniquement sur cet ordinateur. Les appareils distants ne peuvent pas se connecter.",
},
{
mode: "remoteProxyLocal",
title: "Remote access, proxy on this computer",
title: "Accès distant, proxy sur cet ordinateur",
description:
"Use this when your HTTPS proxy runs on the same machine as IdeA Desktop.",
"À utiliser quand le proxy HTTPS tourne sur la même machine qu'IdeA Desktop.",
},
{
mode: "remoteProxyOtherMachine",
title: "Remote access, proxy on another machine",
title: "Accès distant, proxy sur une autre machine",
description:
"Use this when the HTTPS proxy runs on another machine. IdeA will only accept traffic from that proxy.",
"À utiliser quand le proxy HTTPS tourne sur une autre machine. IdeA n'acceptera que le trafic provenant de ce proxy.",
},
];
const STATE_LABEL: Record<string, string> = {
stopped: "Stopped",
starting: "Starting…",
running: "Running",
stopping: "Stopping…",
failed: "Failed",
stopped: "Arrêté",
starting: "Démarrage…",
running: "En cours d'exécution",
stopping: "Arrêt…",
failed: "Échec",
};
/** Copy-to-clipboard button; degrades to disabled where the API is absent. */
@ -86,7 +86,7 @@ function CopyButton({ value, label }: { value: string; label: string }) {
);
}}
>
{copied ? "Copied" : "Copy"}
{copied ? "Copié" : "Copier"}
</Button>
);
}
@ -108,8 +108,8 @@ export function DeploymentSettings() {
if (!vm.ready || !vm.settings) {
return (
<Panel aria-label="deployment settings" title="Deployment">
<p className="text-sm text-muted">Loading</p>
<Panel aria-label="deployment settings" title="Déploiement">
<p className="text-sm text-muted">Chargement</p>
</Panel>
);
}
@ -124,7 +124,7 @@ export function DeploymentSettings() {
<div aria-label="deployment settings" className="flex flex-col gap-4">
{/* ── Status ────────────────────────────────────────────────────────── */}
<Panel
title="Server"
title="Serveur"
actions={
running || status.state === "stopping" ? (
<Button
@ -132,7 +132,7 @@ export function DeploymentSettings() {
onClick={() => void vm.stop()}
disabled={vm.busy || transitioning}
>
Stop
Arrêter
</Button>
) : (
<Button
@ -140,7 +140,7 @@ export function DeploymentSettings() {
onClick={() => void vm.start()}
disabled={vm.busy || transitioning}
>
Start
Démarrer
</Button>
)
}
@ -161,12 +161,12 @@ export function DeploymentSettings() {
{status.localUrl && (
<p className="text-xs text-muted">
Local URL: <code className="font-mono text-content">{status.localUrl}</code>
URL locale : <code className="font-mono text-content">{status.localUrl}</code>
</p>
)}
{status.publicUrl && (
<p className="text-xs text-muted">
Public URL: <code className="font-mono text-content">{status.publicUrl}</code>
URL publique : <code className="font-mono text-content">{status.publicUrl}</code>
</p>
)}
@ -184,12 +184,12 @@ export function DeploymentSettings() {
</div>
</Panel>
{/* ── Exposure ──────────────────────────────────────────────────────── */}
<Panel title="Exposure">
{/* ── Exposure (ticket #78 — "Accès réseau" per UX) ────────────────── */}
<Panel title="Accès réseau">
<div className="flex flex-col gap-4">
<div
role="radiogroup"
aria-label="exposure mode"
aria-label="mode d'accès réseau"
className="flex flex-col gap-2"
>
{MODE_OPTIONS.map((option) => {
@ -234,7 +234,7 @@ export function DeploymentSettings() {
</Field>
{remote && (
<Field label="Public origin" hint="Example: https://idea.example.com">
<Field label="Origine publique" hint="Exemple : https://idea.example.com">
{({ id, describedBy }) => (
<Input
id={id}
@ -251,8 +251,8 @@ export function DeploymentSettings() {
<>
{/* Addresses come from the backend probe — never invented here. */}
<Field
label="LAN address to bind"
hint="The address on this machine that the proxy will connect to."
label="Adresse LAN d'écoute"
hint="L'adresse de cette machine à laquelle le proxy se connectera."
>
{({ id, describedBy }) =>
vm.candidateLanAddresses.length > 0 ? (
@ -263,7 +263,7 @@ export function DeploymentSettings() {
value={settings.lanBindAddress ?? ""}
onChange={(e) => vm.setLanBindAddress(e.target.value)}
>
<option value="">Select an address</option>
<option value="">Sélectionner une adresse</option>
{vm.candidateLanAddresses.map((address) => (
<option key={address} value={address}>
{address}
@ -283,8 +283,8 @@ export function DeploymentSettings() {
</Field>
<Field
label="Authorized proxy IP/CIDR"
hint="This is not where IdeA listens. It is the machine allowed to contact IdeA."
label="IP/CIDR du proxy autorisé"
hint="Ce n'est pas l'adresse d'écoute d'IdeA. C'est la machine autorisée à contacter IdeA."
>
{({ id, describedBy }) => (
<Input
@ -298,8 +298,8 @@ export function DeploymentSettings() {
</Field>
<p className="rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
If your proxy is not on this computer, choose this mode.
Otherwise the proxy may time out without an IdeA error.
Si votre proxy n'est pas sur cet ordinateur, choisissez ce mode.
Sinon, le proxy peut expirer sans erreur IdeA.
</p>
</>
)}
@ -321,16 +321,16 @@ export function DeploymentSettings() {
{/* ── Proxy setup: the upstream to paste, backend-provided ──────────── */}
{remote && (
<Panel title="Proxy setup">
<Panel title="Configuration du proxy">
<div className="flex flex-col gap-2">
<p className="text-xs text-muted">
Point your HTTPS reverse proxy at this upstream.
Faites pointer votre proxy inverse HTTPS vers cet upstream.
</p>
{vm.upstreamUrl ? (
<ReadOnlyValue value={vm.upstreamUrl} copyLabel="copy upstream url" />
<ReadOnlyValue value={vm.upstreamUrl} copyLabel="copier l'URL upstream" />
) : (
<p className="text-xs text-faint">
Complete the settings above to get the upstream URL.
Complétez les paramètres ci-dessus pour obtenir l'URL upstream.
</p>
)}
</div>
@ -338,10 +338,11 @@ export function DeploymentSettings() {
)}
{/* ── Pairing moved to its own surface (#77) — leave a signpost ─────── */}
<Panel title="Pairing">
<Panel title="Appairage">
<p className="text-sm text-muted">
Pairing is managed in <span className="text-content">Settings Appareils</span>,
where you can generate a code and revoke devices.
L'appairage est géré dans{" "}
<span className="text-content">Paramètres Appareils</span>,
vous pouvez générer un code et révoquer des appareils.
</p>
</Panel>
</div>

View File

@ -13,6 +13,12 @@
*
* `EmbedderSettings` / `ModelServersPanel` are **not** pulled in here — that is
* a separate lateral rework. The section list is the seam they would slot into.
*
* Ticket #78 — UX decision: the human UI of IdeA is French by default, uniform
* per surface (proper nouns/technical acronyms like `URL`, `LAN`, `IP/CIDR`,
* `HTTPS`, `API`, `CLI` stay as-is). Settings previously mixed `AI Profiles` /
* `Deployment` (English) with `Appareils` (French, frozen by UX for #77); this
* aligns the whole surface on French.
*/
import { Button, cn } from "@/shared";
@ -23,17 +29,10 @@ import { DeploymentSettings } from "./DeploymentSettings";
/** The Settings sections, in menu/nav order. */
export type SettingsSection = "aiProfiles" | "deployment" | "devices";
/**
* Human labels, shared by the nav column and the `Settings` menu.
*
* `Appareils` is French where its neighbours are English: the device vocabulary
* is frozen by UX across web and desktop (carnet #77), and the web surface it
* mirrors is French throughout. Aligning the whole Settings surface on one
* language is a UX call beyond this ticket.
*/
/** Human labels, shared by the nav column and the `Settings` menu (ticket #78 — French). */
export const SETTINGS_SECTION_LABEL: Record<SettingsSection, string> = {
aiProfiles: "AI Profiles",
deployment: "Deployment",
aiProfiles: "Profils IA",
deployment: "Déploiement",
devices: "Appareils",
};
@ -83,7 +82,7 @@ export function SettingsView({
<div className="mt-auto pt-2">
<Button size="sm" variant="ghost" className="w-full" onClick={onClose}>
Close Settings
Fermer les paramètres
</Button>
</div>
</nav>