Skip to main content

Documentation Index

Fetch the complete documentation index at: https://snakysec.mintlify.app/llms.txt

Use this file to discover all available pages before exploring further.

UI patterns — règles d’application

Ce document tranche les conventions UI récurrentes pour éviter le drift visuel entre pages. Toute nouvelle page DOIT suivre ces règles. Règle absolue : une <Card> ne doit jamais être un lien si elle contient elle-même un bouton d’action.

Pattern A — Card cliquable (cards de listing / hub)

La card entière est un <Link>, aucune action interne.
<Link href={cardHref} className="block">
  <Card className="hover:border-primary/50">
    <CardHeader>...</CardHeader>
    <CardContent>...</CardContent>
  </Card>
</Link>
Si l’item a besoin d’actions secondaires (Impersonate, Quick edit), elles vont dans un menu kebab top-right en dehors du <Link> parent (<div className="absolute right-2 top-2 z-10">) et l’icône MoreHorizontal fait e.preventDefault(); e.stopPropagation() sur le clic pour ne pas naviguer la card. Voir client-card-kebab.tsx pour le pattern de référence.

Pattern B — Card informative (avec actions internes)

La card n’est pas un lien ; chaque action porte son propre <Link> ou <Button>. Le <Link> “View all” dans le <CardHeader> est une action secondaire, pas le primary action de la card.
<Card>
  <CardHeader className="flex items-center justify-between">
    <CardTitle>...</CardTitle>
    <Link href="/all">View all →</Link>
  </CardHeader>
  <CardContent>
    <Table>
      <TableRow>
        <TableCell><Link href={detailHref}>{name}</Link></TableCell>
        ...
      </TableRow>
    </Table>
  </CardContent>
</Card>
Anti-pattern : wrapper la <Card> dans un <Link> quand la card contient un <Button> actionnable. La zone du bouton devient ambiguë (clique-t-on le bouton ou la card ?), et certains navigateurs propagent le clic. Toute sidebar (global, portal, DR runbook, settings) utilise la classe canonique exportée par lib/sidebar-active.ts :
export const SIDEBAR_ACTIVE_CLASS = "bg-primary/10 text-primary font-semibold";
Pas de variantes locales. Si le design change, on modifie une seule fois cette constante. Toute page de profondeur ≥2 (i.e. accessible par >1 click depuis le hub) DOIT rendre un <PageBreadcrumb> (components/page-breadcrumb.tsx). Le back arrow seul est interdit comme nav primaire — les rares cas où il reste utile (e.g. retour au stepper sur une étape onboarding) le font en plus du breadcrumb, pas à la place.
<PageBreadcrumb
  crumbs={[
    { href: "/dashboard", label: tNav("dashboard") },
    { href: "/dashboard/clients", label: tNav("clients") },
    { href: `/dashboard/clients/${id}`, label: client.name },
    { href: `/dashboard/clients/${id}/controls/${controlId}`, label: controlId },
  ]}
/>

Filtres URL-synced (REC-IMP-06)

Toute page avec des filtres utilise <UrlFilters> (components/url-filters.tsx) plutôt qu’une implémentation maison. La définition est déclarative :
<UrlFilters
  filters={[
    { type: "select", key: "status", options: [...] },
    { type: "toggle", key: "period", options: [...] },
  ]}
  resetParams={["page"]}
/>
Couvre usePathname() (ne hardcode jamais le path), URL-sync, sentinel all, et reset auto de ?page=1 sur changement de filtre.

Modales destructives (REC-CRIT-07)

Aucun window.confirm() pour une action destructive. Utiliser <DestructiveDialog> (components/destructive-dialog.tsx) qui standardise : disclosures “irréversible” et “journalisé”, bouton destructive rouge, async-aware (spinner pendant le traitement).
<DestructiveDialog
  open={open}
  onOpenChange={setOpen}
  title={t("deleteConfirmTitle")}
  description={t("deleteConfirmDesc")}
  confirmLabel={t("deleteConfirmCta")}
  onConfirm={performDelete}
/>

EmptyState (REC-MIN-11)

Toute liste vide rend un <EmptyState> (components/empty-state.tsx). Quand la page a des filtres actifs et que la liste est vide à cause d’eux, passer resetFiltersHref pour faire apparaître un bouton standard “Réinitialiser les filtres”.
<EmptyState
  icon={Shield}
  title={t("empty.title")}
  description={hasFilters ? t("empty.filtered") : t("empty.noRun")}
  resetFiltersHref={hasFilters ? "/dashboard/audits" : undefined}
/>

Hiérarchie d’actions de page (P1)

Règle : une page expose 1 action primaire + au plus 3 actions secondaires + un menu kebab “Plus” pour le reste. Au-delà, refondre la page (cf. REC-CRIT-02 sur la fiche client).
TypeStyleExemple
Primaire<Button> plein, à droite”Lancer un audit” sur fiche client
Secondaire<Button variant="outline">”Comparer”, “Exporter”
Tertiairemenu kebab <MoreHorizontal>Edit, Credentials, Trajectory, CISO, Guide
Le 1 primaire est non négociable : c’est ce que l’utilisateur fait 80% du temps sur la page. Si plusieurs candidats, en élire un par fréquence et mettre les autres en secondaire. Référence : dashboard/clients/[id]/page.tsx (TriggerAuditButton primaire + ClientActionsMenu kebab) et audits/[id]/page.tsx (AuditActions buttons + AuditReportsPanel pour générations).

Profondeur navigation 2 niveaux (P2)

Règle : ne jamais dépasser 2 niveaux de profondeur de navigation au sens “menu” (sidebar entries × sub-pages). Au-delà, surfacer la 3ème profondeur via :
  • des tabs URL-synced dans la page parente (P3), OU
  • un secondary sidebar dans le layout (DR runbook, Settings hub).
Le breadcrumb peut afficher plus de niveaux logiques (P4) mais la sidebar ne doit jamais imbriquer plus de 2 niveaux d’arborescence cliquable. Anti-patterns :
  • Sidebar à 3 niveaux d’accordéon → utiliser secondary sidebar à la place
  • Pages de hub uniquement composées de cards de hub → applatir la hiérarchie

Tabs URL-synced (P3, REC-CRIT-02)

Règle : toute UI à tabs au sein d’une route SaaS doit refléter l’onglet actif dans l’URL via ?tab=.... Reload ou deep-link préserve la sélection, crucial pour partage email / Teams. Composant de référence : dashboard/clients/[id]/url-synced-tabs.tsx (wrap Radix Tabs, sync via router.replace() — pas push, pour ne pas polluer l’historique).
const TAB_VALUES = ["overview", "history", "findings"] as const;

<UrlSyncedTabs defaultValue="overview" allowedValues={TAB_VALUES}>
  <TabsList>...</TabsList>
  <TabsContent value="overview">...</TabsContent>
</UrlSyncedTabs>
Le breadcrumb doit refléter l’onglet actif comme 4ème niveau (cf. P4). Exception : tab strictement local (pas de scroll-to / pas de share / pas de SSR différencié) → <Tabs defaultValue> shadcn standard est OK.

Réutilisation MSSP/portal via variant prop (P10, REC-CRIT-05)

Règle : quand un composant fait sens des deux côtés (MSSP + portail client), un seul composant + une prop variant qui ferme l’écart. Pas de duplication. Exemples :
  • ReportDownloadMenu avec formats={[...]} filtré par contexte. Le portail passe ["pdf-executive"] (A2 : portail = exécutif seul), le MSSP passe ["pdf-executive", "pdf-technical", "excel", "html", "json"].
  • <EmptyState> partagé : prop resetFiltersHref optionnelle.
  • <UrlFilters> (P9) avec définition déclarative — le call site adapte les options sans refactor.
Anti-patterns détectés en audit (livrés en Sprint 3) :
  • DownloadReportButton + AuditActions(downloads) séparés → ✅ unifiés
  • DashboardFilters + AuditFilters divergents → ✅ wrappers thin sur UrlFilters
  • documentContexts recalculé dashboard + portal → ✅ lib/document-contexts.ts
Bénéfice mesuré : -300 lignes nettes après REC-IMP-06 + REC-CRIT-05 + REC-MIN-01.