feat(frontend): ajoute les menus Panneaux/Paramètres à la version web (#90)

WebWorkspace gagne un menu « Panneaux » qui route la surface principale du
projet ouvert vers les panneaux transport-neutres déjà réutilisables
(contexte, agents, templates, skills, permissions, mémoire, git) en plus des
surfaces web existantes (travail live, tickets, sprints), ainsi qu'un variant
web du panneau Projets (création par chemin serveur saisi manuellement, sans
browse natif). WebApp gagne un menu « Paramètres » (Profils IA, Appareils,
Déploiement désactivé "Desktop uniquement") qui remplace l'ancien bouton
unique "Appareils".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 18:33:22 +02:00
parent 678ff3011b
commit 30f6415d51
7 changed files with 710 additions and 167 deletions

View File

@ -0,0 +1,101 @@
/**
* Full-screen navigation sheet for the web client (ticket #90): a mobile-first
* menu — never a desktop dropdown — reused by both the "Panneaux" menu
* ({@link WebWorkspace}) and the "Paramètres" menu ({@link WebApp}). Same shell
* as {@link WebTicketPickerSheet}: fixed header with a close action, an optional
* shared note, and a scrollable list of entries. The active entry is marked with
* "●"; a disabled entry stays visible (never removed) so its reason is
* discoverable, via `hint` (title tooltip) and/or `secondaryLabel` (always-visible
* text — the only way to communicate "why" on a touch device with no hover).
*/
import { useEffect, useRef } from "react";
import { Button, cn, zIndex } from "@/shared";
export interface WebMenuEntry {
id: string;
label: string;
active: boolean;
disabled?: boolean;
/** Tooltip shown on hover/focus (desktop browsers); not relied on for touch. */
hint?: string;
/** Always-visible secondary text, e.g. "Desktop uniquement". */
secondaryLabel?: string;
onSelect: () => void;
}
export interface WebMenuSheetProps {
title: string;
entries: WebMenuEntry[];
/** Shared note shown once above the list (e.g. why some entries are disabled). */
note?: string;
onClose: () => void;
}
export function WebMenuSheet({ title, entries, note, onClose }: WebMenuSheetProps) {
const closeRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
closeRef.current?.focus();
}, []);
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onClose]);
return (
<div
className="fixed inset-0 flex flex-col bg-canvas"
role="dialog"
aria-modal="true"
aria-label={title}
style={{ zIndex: zIndex.floatingWindowNested }}
>
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-4 py-3 pt-[max(0.75rem,env(safe-area-inset-top))]">
<span className="min-w-0 truncate text-sm font-medium text-content">{title}</span>
<Button ref={closeRef} size="sm" variant="ghost" onClick={onClose}>
Fermer
</Button>
</header>
{note && (
<p className="shrink-0 border-b border-border px-4 py-2 text-xs text-muted">{note}</p>
)}
<ul className="min-h-0 flex-1 overflow-auto px-2 py-2 pb-[max(0.5rem,env(safe-area-inset-bottom))]">
{entries.map((entry) => (
<li key={entry.id}>
<button
type="button"
disabled={entry.disabled}
title={entry.hint}
aria-current={entry.active ? "true" : undefined}
onClick={() => {
if (entry.disabled) return;
entry.onSelect();
}}
className={cn(
"flex min-h-[44px] w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors",
"hover:bg-raised focus:bg-raised focus:outline-none disabled:opacity-50 disabled:hover:bg-transparent",
entry.active && "bg-raised font-medium text-content",
)}
>
<span aria-hidden="true" className="w-3 shrink-0 text-primary">
{entry.active ? "●" : ""}
</span>
<span className="min-w-0 flex-1 truncate">{entry.label}</span>
{entry.secondaryLabel && (
<span className="shrink-0 text-xs text-muted">{entry.secondaryLabel}</span>
)}
</button>
</li>
))}
</ul>
</div>
);
}