@hgdc/ui 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -38,25 +38,27 @@ Un sous-chemin par composant, sans fichier d'index. Les primitives viennent tout
38
38
 
39
39
  Sur écran tactile (pointeur grossier), les cibles font 44 px (variante `coarse:`, classes `touch-target` et `touch-field`) et les champs 16 px, pour qu'iOS ne zoome pas à la saisie ; `ResourceItem` et la barre du haut d'`AppShell` passent à la ligne sur un écran étroit ou à texte agrandi.
40
40
 
41
+ Avatars à initiales : `<AvatarFallback colorKey={user.email}>HG</AvatarFallback>` prend une des sept teintes des icônes des produits (`--avatar-1` à `--avatar-7`), stable pour une même adresse, initiales à 4,5:1 au moins en clair et en sombre ; la photo (`AvatarImage`) reste prioritaire.
42
+
41
43
  Primitives déjà là : `avatar` `badge` `button` `dialog` `drawer` `dropdown-menu` `empty` `field` `input` `input-otp` `label` `segmented-control` `select` `separator` `sonner` `spinner` `switch` `textarea`.
42
44
 
43
45
  ## Coque des consoles
44
46
 
45
- | Import | Exemple |
46
- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
47
- | `app-shell` | `<AppShell product="mail" organization={<OrgSwitcher …/>} nav={<SidebarNav label="Projet">…</SidebarNav>} sidebarFooter={<AccountMenu …/>} onSearch={() => setOpen(true)}>` |
48
- | | `<SidebarNavItem icon={Rocket01Icon} label="Déploiements" render={<Link to="/deployments" />} />` |
49
- | `org-switcher` | `<OrgSwitcher organizations={orgs} activeId={org.id} onSelect={go} createHref="/organisations/nouvelle" />` |
50
- | `project-switcher` | `<ProjectSwitcher projects={projects} activeId={project?.id} activeName={params.project} allHref="/o/acme" createHref="/o/acme/new" renderLink={(href) => <Link to={href} />} />` : projet actif ou « Tous les projets », marque du projet (`ProjectMark`), « Nouveau projet » |
51
- | `account-menu` | `<AccountMenu user={user} onProfile={…} onSecurity={…} onSignOut={signOut} product="Mail" />` : Account et les produits ouverts du catalogue, Mail coché, thème en trois icônes (`ThemeSwitch`), entrée « État du service » vers la page de statut du produit (`statusPage` d'`AppShell`, `false` pour la retirer) |
52
- | `command-menu` | `<CommandMenu groups={[{ label: "Aller à", items }, { label: "Créer", items }, { label: "Changer", items }]} />` (`Ctrl+K`), `<CommandMenuButton compact="mobile" …/>` |
53
- | `products` | `HGDC_PRODUCTS` (mot, nom, sous-domaine, phrase, pastille `badge`, icône, état, `entryHref`), `HGDC_ACCOUNT`, `productMenuEntries({ current: "Mail" })` : chaque produit du menu pointe vers son entrée `/auth/hgdc` (ADR 0010) |
54
- | `service-status` | Bandeau d'incident ou de maintenance posé par `AppShell` en tête du contenu (ADR 0018) : `useServiceStatus(pageUrl)`, `ServiceStatusBanner`. `statusPage={false}` pour ne rien lire |
55
- | `kbd` | `<KbdGroup><Kbd>Ctrl</Kbd><Kbd>K</Kbd></KbdGroup>` |
56
- | `page-header` | `<PageHeader title="Domaines" breadcrumbs={[{ label: "Acme", render: <Link to="/o/acme" /> }, { label: "Domaines" }]} actions={<Button>Ajouter</Button>} />` |
57
- | `page-header` | `<Page title="Réglages">…</Page>` (colonne de lecture de 768 px), `<Page title="Projets" wide actions={…}>…</Page>` (tableaux, listes, tableaux de bord) |
58
- | `member-item` | `<ResourceList><MemberItem name={m.name} email={m.email} orgRole={m.role} isMe={m.isMe} /></ResourceList>` : avatar à initiales, « (toi) », `RoleBadge` |
59
- | `initials` | `initials("Test vérif")` donne « TV » : la même règle pour une personne et une organisation (`OrgMark`, `AccountMenu`, `MemberItem`) |
47
+ | Import | Exemple |
48
+ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
49
+ | `app-shell` | `<AppShell product="mail" organization={<OrgSwitcher …/>} nav={<SidebarNav label="Projet">…</SidebarNav>} links={{ documentation: "/docs", settings: { render: <Link to="/settings" /> } }} account={<AccountMenu …/>} onSearch={() => setOpen(true)} assistant={{ open, onOpenChange: setOpen, panel: <Conversation /> }}>` : Documentation, Réglages et État du service (la page de statut par défaut) juste au-dessus du compte ; bouton Assistant à côté de la recherche, panneau dans une colonne à droite (la page se rétrécit, vue dédiée sous 1280 px, Échap ferme, `shortcut: "j"` facultatif). `sidebarFooter` est déprécié |
50
+ | | `<SidebarNavItem icon={Rocket01Icon} label="Déploiements" render={<Link to="/deployments" />} />` |
51
+ | `org-switcher` | `<OrgSwitcher organizations={orgs} activeId={org.id} onSelect={go} createHref="/organisations/nouvelle" />` |
52
+ | `project-switcher` | `<ProjectSwitcher projects={projects} activeId={project?.id} activeName={params.project} allHref="/o/acme" createHref="/o/acme/new" renderLink={(href) => <Link to={href} />} />` : projet actif ou « Tous les projets », marque du projet (`ProjectMark`), « Nouveau projet » |
53
+ | `account-menu` | `<AccountMenu user={user} onProfile={…} onSecurity={…} onSignOut={signOut} product="Mail" />` : Account et les produits ouverts du catalogue, Mail coché. Dans l'ordre : identité, Profil, Sécurité, Thème (Système, Clair, Sombre, `ThemeSwitch`), entrées du produit, Se déconnecter. Ni « État du service » ni raccourcis : la coque pose le premier au-dessus du compte, l'aide garde sa touche `?` |
54
+ | `command-menu` | `<CommandMenu groups={[{ label: "Aller à", items }, { label: "Créer", items }, { label: "Changer", items }]} />` (`Ctrl+K`), `<CommandMenuButton compact="mobile" …/>` |
55
+ | `products` | `HGDC_PRODUCTS` (mot, nom, sous-domaine, phrase, pastille `badge`, icône, état, `entryHref`), `HGDC_ACCOUNT`, `productMenuEntries({ current: "Mail" })` : chaque produit du menu pointe vers son entrée `/auth/hgdc` (ADR 0010) |
56
+ | `service-status` | Bandeau d'incident ou de maintenance posé par `AppShell` en tête du contenu (ADR 0018) : `useServiceStatus(pageUrl)`, `ServiceStatusBanner`. `statusPage={false}` pour ne rien lire |
57
+ | `kbd` | `<KbdGroup><Kbd>Ctrl</Kbd><Kbd>K</Kbd></KbdGroup>` |
58
+ | `page-header` | `<PageHeader title="Domaines" breadcrumbs={[{ label: "Acme", render: <Link to="/o/acme" /> }, { label: "Domaines" }]} actions={<Button>Ajouter</Button>} />` |
59
+ | `page-header` | `<Page title="Réglages">…</Page>` (colonne de lecture de 768 px), `<Page title="Projets" wide actions={…}>…</Page>` (tableaux, listes, tableaux de bord) |
60
+ | `member-item` | `<ResourceList><MemberItem name={m.name} email={m.email} orgRole={m.role} isMe={m.isMe} /></ResourceList>` : avatar à initiales, « (toi) », `RoleBadge` |
61
+ | `initials` | `initials("Test vérif")` donne « TV » : la même règle pour une personne et une organisation (`OrgMark`, `AccountMenu`, `MemberItem`) ; `avatarTone(email)` donne la teinte stable (1 à 7) de l'avatar |
60
62
 
61
63
  Le menu « Produits » et la palette (`badge` d'une commande) montrent la pastille de chaque produit.
62
64
 
@@ -20,21 +20,29 @@ export type AccountUser = {
20
20
  image?: string | null;
21
21
  };
22
22
  /**
23
- * Le menu du compte, en bas de la barre latérale : profil, sécurité, choix du thème en trois icônes, raccourcis, autres
24
- * produits HGDC, déconnexion. `row` : avatar, nom et adresse (l'avatar seul en rail) ; `avatar` :
23
+ * Le menu du compte, en bas de la barre latérale (`account` d'`AppShell`). Dans cet ordre :
24
+ * l'identité (avatar, nom, adresse), Profil, Sécurité, Thème (Système, Clair, Sombre, dans le
25
+ * menu), les entrées propres au produit, les autres produits (sur téléphone), puis « Se
26
+ * déconnecter », à part. `row` : avatar, nom et adresse (l'avatar seul en rail) ; `avatar` :
25
27
  * l'avatar seul, pour une barre du haut. Le produit passe les actions ; le thème passe par
26
28
  * `ThemeProvider`.
27
29
  *
30
+ * Documentation, Réglages et État du service ne sont pas ici : la coque les pose au-dessus du
31
+ * compte (`links` d'`AppShell`). L'aide des raccourcis garde sa touche `?`, sans entrée de menu.
32
+ *
28
33
  * « Produits » (masquée au-delà de `lg` dans une coque qui affiche la barre des produits, où ils
29
34
  * sont déjà ; gardée sur téléphone) liste par défaut Account et les produits ouverts du catalogue
30
35
  * (`productMenuEntries`, `@hgdc/ui/products`), avec `product` coché. `products` remplace cette
31
36
  * liste ; `products={[]}` la retire.
32
37
  */
33
- export declare function AccountMenu({ user, onProfile, onSecurity, onShortcuts, onSignOut, products: productsProp, product: currentProduct, accountHref, statusPage, children, variant, }: {
38
+ export declare function AccountMenu({ user, onProfile, onSecurity, onSignOut, products: productsProp, product: currentProduct, accountHref, children, variant, }: {
34
39
  user: AccountUser;
35
40
  onProfile?: () => void;
36
41
  onSecurity?: () => void;
37
- /** Ouvre l'aide des raccourcis clavier. */
42
+ /**
43
+ * @deprecated Plus d'entrée « Raccourcis clavier » dans le menu (0.29) : l'aide s'ouvre par la
44
+ * touche `?`, que le produit garde. Ignoré.
45
+ */
38
46
  onShortcuts?: () => void;
39
47
  onSignOut: () => void;
40
48
  /** Les produits de la famille, pour passer de l'un à l'autre. Le catalogue du kit par défaut. */
@@ -44,12 +52,11 @@ export declare function AccountMenu({ user, onProfile, onSecurity, onShortcuts,
44
52
  /** Adresse d'Account dans la liste par défaut (un Account local en développement). */
45
53
  accountHref?: string;
46
54
  /**
47
- * Page de statut du produit, entrée « État du service » : celle d'`AppShell` par défaut (son
48
- * `statusPage`, sinon `https://status.hgdc.ch/<produit>`), `false` pour la retirer. Hors de la
49
- * coque, sans adresse donnée, pas d'entrée.
55
+ * @deprecated « État du service » a quitté le menu (0.29) : la coque le pose au-dessus du compte
56
+ * (`links.status` d'`AppShell`, la page de statut de la coque par défaut). Ignoré.
50
57
  */
51
58
  statusPage?: string | false;
52
- /** Entrées propres au produit (« Administration »), après le profil. */
59
+ /** Entrées propres au produit (« Administration »), après le thème. */
53
60
  children?: ReactNode;
54
61
  variant?: "row" | "avatar";
55
62
  }): React.JSX.Element;
@@ -1,7 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import Activity01Icon from "@hugeicons/core-free-icons/Activity01Icon";
3
2
  import DashboardSquare01Icon from "@hugeicons/core-free-icons/DashboardSquare01Icon";
4
- import KeyboardIcon from "@hugeicons/core-free-icons/KeyboardIcon";
5
3
  import Logout01Icon from "@hugeicons/core-free-icons/Logout01Icon";
6
4
  import SecurityLockIcon from "@hugeicons/core-free-icons/SecurityLockIcon";
7
5
  import Tick02Icon from "@hugeicons/core-free-icons/Tick02Icon";
@@ -9,31 +7,38 @@ import UserIcon from "@hugeicons/core-free-icons/UserIcon";
9
7
  import { HugeiconsIcon } from "@hugeicons/react";
10
8
  import { Menu as MenuPrimitive } from "@base-ui/react/menu";
11
9
  import { Avatar, AvatarFallback, AvatarImage } from "./avatar.js";
12
- import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuTrigger, } from "./dropdown-menu.js";
10
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "./dropdown-menu.js";
13
11
  import { initials } from "./initials.js";
14
12
  import { MENU_LINK_ITEM } from "./org-switcher.js";
15
13
  import { ProductBadge } from "./product-badge.js";
16
14
  import { productMenuEntries } from "./products.js";
17
- import { useLeaveOnClick, useProductRailShown, useStatusPage } from "./shell-placement.js";
15
+ import { useLeaveOnClick, useProductRailShown } from "./shell-placement.js";
18
16
  import { ThemeSwitch } from "./theme-switch.js";
17
+ /** L'avatar du compte : la photo, sinon les initiales sur la teinte stable de l'adresse. */
18
+ function AccountAvatar({ user, size }) {
19
+ return (_jsxs(Avatar, { size: size, children: [user.image ? _jsx(AvatarImage, { src: user.image, alt: "" }) : null, _jsx(AvatarFallback, { colorKey: user.email || user.name, className: "text-xs font-semibold uppercase", children: initials(user.name, user.email) })] }));
20
+ }
19
21
  /**
20
- * Le menu du compte, en bas de la barre latérale : profil, sécurité, choix du thème en trois icônes, raccourcis, autres
21
- * produits HGDC, déconnexion. `row` : avatar, nom et adresse (l'avatar seul en rail) ; `avatar` :
22
+ * Le menu du compte, en bas de la barre latérale (`account` d'`AppShell`). Dans cet ordre :
23
+ * l'identité (avatar, nom, adresse), Profil, Sécurité, Thème (Système, Clair, Sombre, dans le
24
+ * menu), les entrées propres au produit, les autres produits (sur téléphone), puis « Se
25
+ * déconnecter », à part. `row` : avatar, nom et adresse (l'avatar seul en rail) ; `avatar` :
22
26
  * l'avatar seul, pour une barre du haut. Le produit passe les actions ; le thème passe par
23
27
  * `ThemeProvider`.
24
28
  *
29
+ * Documentation, Réglages et État du service ne sont pas ici : la coque les pose au-dessus du
30
+ * compte (`links` d'`AppShell`). L'aide des raccourcis garde sa touche `?`, sans entrée de menu.
31
+ *
25
32
  * « Produits » (masquée au-delà de `lg` dans une coque qui affiche la barre des produits, où ils
26
33
  * sont déjà ; gardée sur téléphone) liste par défaut Account et les produits ouverts du catalogue
27
34
  * (`productMenuEntries`, `@hgdc/ui/products`), avec `product` coché. `products` remplace cette
28
35
  * liste ; `products={[]}` la retire.
29
36
  */
30
- export function AccountMenu({ user, onProfile, onSecurity, onShortcuts, onSignOut, products: productsProp, product: currentProduct, accountHref, statusPage, children, variant = "row", }) {
37
+ export function AccountMenu({ user, onProfile, onSecurity, onSignOut, products: productsProp, product: currentProduct, accountHref, children, variant = "row", }) {
31
38
  const products = productsProp ?? productMenuEntries({ current: currentProduct, accountHref });
32
39
  const railShown = useProductRailShown();
33
- const shellStatusPage = useStatusPage();
34
- const statusPageUrl = statusPage === false ? null : (statusPage ?? shellStatusPage);
35
40
  const leaveOnClick = useLeaveOnClick();
36
- return (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { render: variant === "row" ? (_jsx("button", { type: "button", "aria-label": `Compte de ${user.name}`, className: "flex w-full min-w-0 items-center gap-2.5 rounded-xl p-1.5 text-left outline-none transition-colors duration-(--duration-fast) ease-out hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/50 aria-expanded:bg-muted lg:hover:bg-shell-hover lg:aria-expanded:bg-shell-hover lg:rail:justify-center" })) : (_jsx("button", { type: "button", "aria-label": `Compte de ${user.name}`, className: "touch-target grid size-9 place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50" })), children: [_jsxs(Avatar, { children: [user.image ? _jsx(AvatarImage, { src: user.image, alt: "" }) : null, _jsx(AvatarFallback, { className: "text-xs font-semibold uppercase", children: initials(user.name, user.email) })] }), variant === "row" ? (_jsxs("span", { className: "min-w-0 flex-1 lg:rail:hidden", children: [_jsx("span", { className: "block truncate text-sm font-medium", children: user.name }), _jsx("span", { className: "block truncate text-xs text-muted-foreground lg:text-shell-muted", children: user.email })] })) : null] }), _jsxs(DropdownMenuContent, { align: variant === "row" ? "start" : "end", side: variant === "row" ? "top" : "bottom", className: "w-64", children: [_jsx(DropdownMenuGroup, { children: _jsxs(DropdownMenuLabel, { children: [_jsx("span", { className: "block truncate text-foreground", children: user.name }), _jsx("span", { className: "block truncate", children: user.email })] }) }), _jsx(DropdownMenuSeparator, {}), onProfile ? (_jsxs(DropdownMenuItem, { onClick: onProfile, children: [_jsx(HugeiconsIcon, { icon: UserIcon, "aria-hidden": true }), "Profil"] })) : null, onSecurity ? (_jsxs(DropdownMenuItem, { onClick: onSecurity, children: [_jsx(HugeiconsIcon, { icon: SecurityLockIcon, "aria-hidden": true }), "S\u00E9curit\u00E9"] })) : null, children, _jsx(ThemeSwitch, {}), onShortcuts ? (_jsxs(DropdownMenuItem, { onClick: onShortcuts, children: [_jsx(HugeiconsIcon, { icon: KeyboardIcon, "aria-hidden": true }), "Raccourcis clavier", _jsx(DropdownMenuShortcut, { children: "?" })] })) : null, statusPageUrl ? (_jsxs(MenuPrimitive.LinkItem, { href: statusPageUrl, className: MENU_LINK_ITEM, children: [_jsx(HugeiconsIcon, { icon: Activity01Icon, "aria-hidden": true }), "\u00C9tat du service"] })) : null, products.length ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, { className: railShown ? "lg:hidden" : undefined }), _jsxs(DropdownMenuGroup, { className: railShown ? "lg:hidden" : undefined, children: [_jsx(DropdownMenuLabel, { children: "Produits" }), products.map((product) => product.current ? (_jsxs(DropdownMenuItem, { disabled: true, className: "data-disabled:opacity-100", children: [_jsx(ProductMenuIcon, { product: product }), _jsx("span", { className: "flex-1", children: product.name }), _jsx(HugeiconsIcon, { icon: Tick02Icon, "aria-label": "Produit ouvert" })] }, product.name)) : (_jsxs(MenuPrimitive.LinkItem, { href: product.href, onClick: leaveOnClick, className: MENU_LINK_ITEM, children: [_jsx(ProductMenuIcon, { product: product }), _jsx("span", { className: "flex-1", children: product.name }), product.hint ? (_jsx("span", { className: "text-xs font-normal text-muted-foreground", children: product.hint })) : null] }, product.name)))] })] })) : null, _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { onClick: onSignOut, children: [_jsx(HugeiconsIcon, { icon: Logout01Icon, "aria-hidden": true }), "Se d\u00E9connecter"] })] })] }));
41
+ return (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { render: variant === "row" ? (_jsx("button", { type: "button", "aria-label": `Compte de ${user.name}`, className: "flex w-full min-w-0 items-center gap-2.5 rounded-xl p-1.5 text-left outline-none transition-colors duration-(--duration-fast) ease-out hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/50 aria-expanded:bg-muted lg:hover:bg-shell-hover lg:aria-expanded:bg-shell-hover lg:rail:justify-center" })) : (_jsx("button", { type: "button", "aria-label": `Compte de ${user.name}`, className: "touch-target grid size-9 place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50" })), children: [_jsx(AccountAvatar, { user: user }), variant === "row" ? (_jsxs("span", { className: "min-w-0 flex-1 lg:rail:hidden", children: [_jsx("span", { className: "block truncate text-sm font-medium", children: user.name }), _jsx("span", { className: "block truncate text-xs text-muted-foreground lg:text-shell-muted", children: user.email })] })) : null] }), _jsxs(DropdownMenuContent, { align: variant === "row" ? "start" : "end", side: variant === "row" ? "top" : "bottom", className: "w-72", children: [_jsx(DropdownMenuGroup, { children: _jsxs(DropdownMenuLabel, { className: "flex items-center gap-2.5 py-2", children: [_jsx(AccountAvatar, { user: user }), _jsxs("span", { className: "min-w-0 flex-1", children: [_jsx("span", { className: "block truncate text-sm font-medium text-foreground", children: user.name }), _jsx("span", { className: "block truncate", children: user.email })] })] }) }), _jsx(DropdownMenuSeparator, {}), onProfile ? (_jsxs(DropdownMenuItem, { onClick: onProfile, children: [_jsx(HugeiconsIcon, { icon: UserIcon, "aria-hidden": true }), "Profil"] })) : null, onSecurity ? (_jsxs(DropdownMenuItem, { onClick: onSecurity, children: [_jsx(HugeiconsIcon, { icon: SecurityLockIcon, "aria-hidden": true }), "S\u00E9curit\u00E9"] })) : null, _jsx(ThemeSwitch, {}), children ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), children] })) : null, products.length ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, { className: railShown ? "lg:hidden" : undefined }), _jsxs(DropdownMenuGroup, { className: railShown ? "lg:hidden" : undefined, children: [_jsx(DropdownMenuLabel, { children: "Produits" }), products.map((product) => product.current ? (_jsxs(DropdownMenuItem, { disabled: true, className: "data-disabled:opacity-100", children: [_jsx(ProductMenuIcon, { product: product }), _jsx("span", { className: "flex-1", children: product.name }), _jsx(HugeiconsIcon, { icon: Tick02Icon, "aria-label": "Produit ouvert" })] }, product.name)) : (_jsxs(MenuPrimitive.LinkItem, { href: product.href, onClick: leaveOnClick, className: MENU_LINK_ITEM, children: [_jsx(ProductMenuIcon, { product: product }), _jsx("span", { className: "flex-1", children: product.name }), product.hint ? (_jsx("span", { className: "text-xs font-normal text-muted-foreground", children: product.hint })) : null] }, product.name)))] })] })) : null, _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { onClick: onSignOut, children: [_jsx(HugeiconsIcon, { icon: Logout01Icon, "aria-hidden": true }), "Se d\u00E9connecter"] })] })] }));
37
42
  }
38
43
  function ProductMenuIcon({ product }) {
39
44
  if (product.badge)
@@ -33,7 +33,45 @@ export declare function SidebarNavItem({ icon, label, active, href, render, }: {
33
33
  /** Lien du routeur : `render={<Link to="…" />}`. */
34
34
  render?: React.ReactElement;
35
35
  }): React.JSX.Element;
36
- export declare function AppShell({ product, organization, productRail, accountHref, nav, sidebarFooter, header, onSearch, searchLabel, children, contentKey, navigating, mainClassName, statusPage, }: {
36
+ /**
37
+ * Un accès commun de la barre : une adresse, ou un lien du routeur (`{ render: <Link to="/settings" /> }`,
38
+ * dont le `data-status="active"` marque la page ouverte), ou une adresse avec `active`.
39
+ */
40
+ export type ShellLink = string | {
41
+ href?: string;
42
+ render?: React.ReactElement;
43
+ active?: boolean;
44
+ };
45
+ /** Les trois accès que la coque pose juste au-dessus du compte, toujours dans cet ordre. */
46
+ export type AppShellLinks = {
47
+ /** Documentation du produit. */
48
+ documentation?: ShellLink;
49
+ /** Réglages du produit ou de l'organisation. */
50
+ settings?: ShellLink;
51
+ /** État du service : la page de statut de la coque (`statusPage`) par défaut, `false` pour le retirer. */
52
+ status?: ShellLink | false;
53
+ };
54
+ /**
55
+ * L'assistant IA du produit. La coque pose son bouton juste à côté de la recherche et l'ouvre dans
56
+ * une colonne à droite de la page, sur le fond de l'application : la page se rétrécit et se
57
+ * réorganise, jamais recouverte. Sous 1280 px, là où les deux ne tiennent plus côte à côte, une
58
+ * vue dédiée plein écran avec « Retour à la page ». La page reste montée dans les deux cas, et le
59
+ * panneau, une fois ouvert, aussi : rien ne se perd en fermant ou en changeant de largeur.
60
+ */
61
+ export type AppShellAssistant = {
62
+ open: boolean;
63
+ onOpenChange: (open: boolean) => void;
64
+ /**
65
+ * Le corps de l'assistant : conversation et saisie (`@hgdc/ui/assistant-chat`). La coque pose le
66
+ * titre, la fermeture et le retour à la page ; le panneau occupe la hauteur restante et défile.
67
+ */
68
+ panel: ReactNode;
69
+ /** Nom du bouton et titre du panneau : « Assistant » par défaut. */
70
+ label?: string;
71
+ /** Lettre du raccourci `Ctrl` (`⌘`) + lettre qui ouvre et ferme l'assistant (`"j"`). Aucun par défaut. */
72
+ shortcut?: string;
73
+ };
74
+ export declare function AppShell({ product, organization, productRail, accountHref, nav, links, account, sidebarFooter, header, onSearch, searchLabel, assistant, children, contentKey, navigating, mainClassName, statusPage, }: {
37
75
  /** Le produit affiché (`"host"`) : sa marque en haut de la barre, « vous êtes ici » dans la barre des produits. */
38
76
  product: FamilyProduct;
39
77
  /** `OrgSwitcher` : en haut de la barre des produits, en haut du tiroir sur téléphone. */
@@ -47,7 +85,21 @@ export declare function AppShell({ product, organization, productRail, accountHr
47
85
  accountHref?: string | undefined;
48
86
  /** `SidebarNav` et ses `SidebarNavItem`. */
49
87
  nav: ReactNode;
50
- /** Bas de la barre : liens secondaires (Docs, Paramètres) et `AccountMenu`. */
88
+ /**
89
+ * Documentation, Réglages et État du service, juste au-dessus du compte, dans cet ordre, chacun
90
+ * optionnel (État du service suit `statusPage` par défaut).
91
+ */
92
+ links?: AppShellLinks;
93
+ /** `AccountMenu`, tout en bas de la barre latérale (et du tiroir sur téléphone). */
94
+ account?: ReactNode;
95
+ /**
96
+ * Avec `account` : entrées propres au produit, posées au-dessus des accès communs.
97
+ *
98
+ * @deprecated Comme bas de barre complet (liens et `AccountMenu`), remplacé par `links` et
99
+ * `account` (0.29). Sans `account`, il est rendu tel quel sous les accès communs, pour ne rien
100
+ * casser : déplacer `AccountMenu` dans `account`, Docs et Réglages dans `links`, et retirer
101
+ * l'ancien lien vers l'assistant (`assistant`).
102
+ */
51
103
  sidebarFooter?: ReactNode;
52
104
  /**
53
105
  * Barre du haut, à gauche, après la marque sur téléphone : le contexte de la page (sélecteur de
@@ -61,6 +113,8 @@ export declare function AppShell({ product, organization, productRail, accountHr
61
113
  onSearch?: () => void;
62
114
  /** Texte du champ de recherche : « Rechercher » par défaut. */
63
115
  searchLabel?: string;
116
+ /** L'assistant IA : son bouton à côté de la recherche, son panneau à droite de la page. */
117
+ assistant?: AppShellAssistant;
64
118
  children: ReactNode;
65
119
  /**
66
120
  * Clé de la page affichée (le chemin, `location.pathname`) : la zone de contenu devient une
@@ -84,7 +138,7 @@ export declare function AppShell({ product, organization, productRail, accountHr
84
138
  /**
85
139
  * Page de statut du produit, dont le bandeau d'état se pose en tête du contenu (ADR 0018) :
86
140
  * `https://status.hgdc.ch/<produit>` par défaut, une autre adresse (prévisualisation sur une page
87
- * de test), ou `false` pour ne rien lire. Le menu du compte en tire son entrée « État du service ».
141
+ * de test), ou `false` pour ne rien lire. L'accès « État du service », au-dessus du compte, y mène.
88
142
  */
89
143
  statusPage?: string | false | undefined;
90
144
  }): React.JSX.Element;
package/dist/app-shell.js CHANGED
@@ -1,10 +1,16 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import Activity01Icon from "@hugeicons/core-free-icons/Activity01Icon";
3
+ import AiMagicIcon from "@hugeicons/core-free-icons/AiMagicIcon";
4
+ import ArrowLeft01Icon from "@hugeicons/core-free-icons/ArrowLeft01Icon";
5
+ import BookOpen01Icon from "@hugeicons/core-free-icons/BookOpen01Icon";
6
+ import Cancel01Icon from "@hugeicons/core-free-icons/Cancel01Icon";
2
7
  import Menu01Icon from "@hugeicons/core-free-icons/Menu01Icon";
3
8
  import PanelLeftCloseIcon from "@hugeicons/core-free-icons/PanelLeftCloseIcon";
4
9
  import PanelLeftOpenIcon from "@hugeicons/core-free-icons/PanelLeftOpenIcon";
10
+ import Settings01Icon from "@hugeicons/core-free-icons/Settings01Icon";
5
11
  import { HugeiconsIcon } from "@hugeicons/react";
6
12
  import { useRender } from "@base-ui/react/use-render";
7
- import { createContext, Suspense, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, } from "react";
13
+ import { createContext, Suspense, useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore, } from "react";
8
14
  import { CommandMenuButton } from "./command-menu.js";
9
15
  import { Drawer, DrawerContent, DrawerTitle } from "./drawer.js";
10
16
  import { FamilyLoaderHost, FamilyLoaderRootContext } from "./family-loader.js";
@@ -18,6 +24,7 @@ import { ProductRailShownContext, ShellLeaveContext, StatusPageContext } from ".
18
24
  import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip.js";
19
25
  import { cn } from "./cn.js";
20
26
  import { ServiceStatus, statusPageUrlFor } from "./service-status.js";
27
+ import { useMediaQuery } from "./use-media-query.js";
21
28
  // ── Rail : la barre latérale réduite aux icônes, mémorisée par navigateur ──────────────
22
29
  const RAIL_KEY = "sidebar";
23
30
  const RAIL_EVENT = "sidebarchange";
@@ -99,6 +106,104 @@ export function SidebarNavItem({ icon, label, active, href, render, }) {
99
106
  });
100
107
  return (_jsx("li", { children: _jsxs(Tooltip, { disabled: !rail, children: [_jsx(TooltipTrigger, { render: link }), _jsx(TooltipContent, { side: "right", children: label })] }) }));
101
108
  }
109
+ const COMMON_LINKS = [
110
+ { key: "documentation", label: "Documentation", icon: BookOpen01Icon },
111
+ { key: "settings", label: "Réglages", icon: Settings01Icon },
112
+ { key: "status", label: "État du service", icon: Activity01Icon },
113
+ ];
114
+ function ShellCommonLinks({ links, statusPageUrl, }) {
115
+ const resolved = {
116
+ documentation: links.documentation,
117
+ settings: links.settings,
118
+ status: links.status === false ? undefined : (links.status ?? statusPageUrl ?? undefined),
119
+ };
120
+ const items = COMMON_LINKS.flatMap(({ key, label, icon }) => {
121
+ const link = resolved[key];
122
+ if (link === undefined)
123
+ return [];
124
+ const props = typeof link === "string" ? { href: link } : link;
125
+ return [
126
+ _jsx(SidebarNavItem, { icon: icon, label: label, ...(props.href !== undefined ? { href: props.href } : {}), ...(props.render !== undefined ? { render: props.render } : {}), ...(props.active !== undefined ? { active: props.active } : {}) }, key),
127
+ ];
128
+ });
129
+ if (!items.length)
130
+ return null;
131
+ return (_jsx("nav", { "aria-label": "Acc\u00E8s communs", children: _jsx("ul", { "data-slot": "app-shell-links", className: "grid gap-0.5", children: items }) }));
132
+ }
133
+ /** Largeur à partir de laquelle la page et l'assistant tiennent côte à côte (`xl`). */
134
+ const ASSISTANT_SIDE_BY_SIDE = "(min-width: 80rem)";
135
+ const FOCUSABLE = 'textarea, input, [contenteditable="true"], [autofocus]';
136
+ function AssistantButton({ assistant, panelId, buttonRef, }) {
137
+ const label = assistant.label ?? "Assistant";
138
+ const letter = assistant.shortcut?.toUpperCase();
139
+ return (_jsxs(Tooltip, { children: [_jsxs(TooltipTrigger, { render: _jsx("button", { ref: buttonRef, type: "button", "data-slot": "app-shell-assistant-button", "aria-expanded": assistant.open, "aria-controls": panelId, ...(letter ? { "aria-keyshortcuts": `Control+${letter} Meta+${letter}` } : {}), onClick: () => assistant.onOpenChange(!assistant.open), className: cn("touch-target flex h-9 shrink-0 items-center gap-2 rounded-xl px-3 text-sm font-medium text-muted-foreground outline-none", "transition-colors duration-(--duration-fast) ease-out hover:bg-foreground/[0.05] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50", "aria-expanded:bg-foreground/[0.08] aria-expanded:text-foreground", "max-lg:w-9 max-lg:justify-center max-lg:px-0") }), children: [_jsx(HugeiconsIcon, { icon: AiMagicIcon, strokeWidth: 1.8, "aria-hidden": true, className: "size-[1.125rem] shrink-0" }), _jsx("span", { className: "max-lg:sr-only", children: label })] }), _jsx(TooltipContent, { children: _jsxs("span", { className: "flex items-center gap-2", children: [assistant.open ? `Fermer ${label.toLocaleLowerCase("fr-CH")}` : label, letter ? (_jsxs(KbdGroup, { children: [_jsx(Kbd, { children: isApplePlatform() ? "⌘" : "Ctrl" }), _jsx(Kbd, { children: letter })] })) : null] }) })] }));
140
+ }
141
+ /** `Ctrl` (`⌘`) + la lettre de l'assistant : l'ouvre ou le ferme, où que soit le focus. */
142
+ function useAssistantShortcut(assistant) {
143
+ const latest = useRef(assistant);
144
+ useEffect(() => {
145
+ latest.current = assistant;
146
+ });
147
+ const letter = assistant?.shortcut?.toLowerCase();
148
+ useEffect(() => {
149
+ if (!letter)
150
+ return undefined;
151
+ const onKey = (event) => {
152
+ if (event.key.toLowerCase() !== letter || !(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey)
153
+ return;
154
+ const current = latest.current;
155
+ if (!current)
156
+ return;
157
+ event.preventDefault();
158
+ current.onOpenChange(!current.open);
159
+ };
160
+ document.addEventListener("keydown", onKey);
161
+ return () => document.removeEventListener("keydown", onKey);
162
+ }, [letter]);
163
+ }
164
+ function AssistantPanel({ assistant, panelId, wide, openAtShellMount, buttonRef, }) {
165
+ const { open, onOpenChange } = assistant;
166
+ const label = assistant.label ?? "Assistant";
167
+ const titleId = useId();
168
+ const panelRef = useRef(null);
169
+ // Ouvert dès le premier rendu de la coque (état restauré) : le focus reste où le navigateur le met.
170
+ const wasOpen = useRef(openAtShellMount);
171
+ // Ouvert : le focus entre dans le panneau (sa saisie, sinon le panneau). Fermé : il revient au
172
+ // bouton, s'il était dans le panneau ou perdu sur la page.
173
+ useEffect(() => {
174
+ const panel = panelRef.current;
175
+ if (open && !wasOpen.current && panel && !panel.contains(document.activeElement)) {
176
+ const target = panel.querySelector(FOCUSABLE) ?? panel;
177
+ target.focus({ preventScroll: true });
178
+ }
179
+ if (!open && wasOpen.current) {
180
+ const active = document.activeElement;
181
+ if (!active || active === document.body || panel?.contains(active))
182
+ buttonRef.current?.focus();
183
+ }
184
+ wasOpen.current = open;
185
+ }, [open, buttonRef]);
186
+ // Échap, le focus dans le panneau : il se ferme (sauf si un élément du panneau l'a déjà traité).
187
+ useEffect(() => {
188
+ const panel = panelRef.current;
189
+ if (!open || !panel)
190
+ return undefined;
191
+ const onKey = (event) => {
192
+ if (event.key !== "Escape" || event.defaultPrevented)
193
+ return;
194
+ event.preventDefault();
195
+ onOpenChange(false);
196
+ };
197
+ panel.addEventListener("keydown", onKey);
198
+ return () => panel.removeEventListener("keydown", onKey);
199
+ }, [open, onOpenChange]);
200
+ const close = () => onOpenChange(false);
201
+ return (_jsx("section", { ref: panelRef, id: panelId, tabIndex: -1, "data-slot": "app-shell-assistant", "data-open": open ? "" : undefined, "aria-labelledby": titleId, inert: !open || undefined, ...(open && !wide ? { role: "dialog", "aria-modal": true } : {}), className: cn("outline-none",
202
+ // Vue dédiée, sous 1280 px : plein écran, la page reste montée dessous.
203
+ "hidden data-open:max-xl:fixed data-open:max-xl:inset-0 data-open:max-xl:z-40 data-open:max-xl:flex data-open:max-xl:flex-col data-open:max-xl:bg-background", "data-open:max-xl:pt-[env(safe-area-inset-top,0px)] data-open:max-xl:soft-in motion-reduce:animate-none",
204
+ // Côte à côte : une colonne de la grille, qui s'ouvre en largeur ; la page se réorganise.
205
+ "xl:invisible xl:flex xl:w-0 xl:flex-col xl:overflow-hidden xl:transition-[width,visibility] xl:duration-(--duration-slow) xl:ease-ios motion-reduce:transition-none", "xl:data-open:visible xl:data-open:w-[26rem] 2xl:data-open:w-[28rem]"), children: _jsxs("div", { className: "flex min-h-0 flex-1 flex-col xl:w-[26rem] xl:py-2 xl:pr-2 2xl:w-[28rem]", children: [_jsxs("div", { className: "flex min-h-14 shrink-0 items-center gap-1 border-b border-foreground/5 px-2 sm:px-4 xl:border-0 xl:pr-1 xl:pl-3", children: [_jsxs("button", { type: "button", onClick: close, className: "touch-target flex h-9 shrink-0 items-center gap-1.5 rounded-xl pr-3 pl-2 text-sm font-medium text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 xl:hidden", children: [_jsx(HugeiconsIcon, { icon: ArrowLeft01Icon, strokeWidth: 2, "aria-hidden": true, className: "size-4" }), "Retour \u00E0 la page"] }), _jsxs("h2", { id: titleId, className: "flex min-w-0 flex-1 items-center gap-2 truncate font-heading text-[0.9375rem] font-semibold max-xl:justify-end max-xl:pr-2", children: [_jsx(HugeiconsIcon, { icon: AiMagicIcon, strokeWidth: 1.8, "aria-hidden": true, className: "size-[1.125rem] shrink-0" }), label] }), _jsx("button", { type: "button", onClick: close, "aria-label": `Fermer ${label.toLocaleLowerCase("fr-CH")}`, className: "touch-target grid size-9 shrink-0 place-items-center rounded-xl text-muted-foreground outline-none hover:bg-shell-hover hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 max-xl:hidden", children: _jsx(HugeiconsIcon, { icon: Cancel01Icon, strokeWidth: 1.8, "aria-hidden": true, className: "size-[1.125rem]" }) })] }), _jsx("div", { "data-slot": "app-shell-assistant-body", className: "flex min-h-0 flex-1 flex-col overflow-y-auto", children: assistant.panel })] }) }));
206
+ }
102
207
  // ── Coque ─────────────────────────────────────────────────────────────────────────────
103
208
  // Les noms de Hugeicons disent l'état du panneau, pas le geste : « Open » dessine la flèche vers la
104
209
  // gauche (réduire), « Close » vers la droite (déplier).
@@ -174,7 +279,7 @@ function useContentLoading({ navigating, pending }) {
174
279
  }, [active, visible]);
175
280
  return visible;
176
281
  }
177
- export function AppShell({ product, organization, productRail = true, accountHref, nav, sidebarFooter, header, onSearch, searchLabel, children, contentKey, navigating = false, mainClassName, statusPage, }) {
282
+ export function AppShell({ product, organization, productRail = true, accountHref, nav, links, account, sidebarFooter, header, onSearch, searchLabel, assistant, children, contentKey, navigating = false, mainClassName, statusPage, }) {
178
283
  const [menuOpen, setMenuOpen] = useState(false);
179
284
  // Barre des produits élargie : la barre latérale se plie le temps du survol (ADR 0011).
180
285
  const [railPeek, setRailPeek] = useState(false);
@@ -213,5 +318,18 @@ export function AppShell({ product, organization, productRail = true, accountHre
213
318
  if (loading && contentRef.current)
214
319
  contentRef.current.scrollTop = 0;
215
320
  }, [loading]);
216
- return (_jsx(ShellContext.Provider, { value: { closeMobileNav: () => setMenuOpen(false) }, children: _jsx(ProductRailShownContext, { value: productRail, children: _jsx(ShellLeaveContext, { value: leave, children: _jsx(StatusPageContext, { value: statusPageUrl, children: _jsxs("div", { "data-slot": "app-shell", "data-rail-peek": railPeek ? "" : undefined, "data-product-rail": productRail ? "" : undefined, "data-loading": loading ? "" : undefined, "data-hydrated": hydrated ? "" : undefined, className: cn("min-h-dvh lg:grid lg:h-dvh lg:bg-shell", productRail ? "lg:grid-cols-[auto_auto_minmax(0,1fr)]" : "lg:grid-cols-[auto_minmax(0,1fr)]"), children: [_jsx("a", { href: "#contenu", className: "sr-only z-50 rounded-xl bg-background px-4 py-2 text-sm font-medium focus:not-sr-only focus:fixed focus:top-3 focus:left-3 focus:ring-2 focus:ring-ring", children: "Aller au contenu" }), productRail ? (_jsx(ProductRail, { current: product, organization: organization, accountHref: accountHref, onExpandedChange: setRailPeek })) : null, _jsxs("aside", { "aria-label": "Barre lat\u00E9rale", className: "group/sidebar hidden overflow-hidden text-shell-foreground transition-[width] duration-(--duration-base) ease-ios motion-reduce:transition-none lg:flex lg:w-64 lg:flex-col lg:rail:w-16", children: [_jsx(SidebarHead, { product: product }), !productRail && organization ? _jsx("div", { className: "shrink-0 px-3 pb-1", children: organization }) : null, nav, sidebarFooter ? _jsx("div", { className: "grid gap-0.5 px-3 pt-1 pb-3", children: sidebarFooter }) : null] }), _jsx("div", { className: "flex min-h-dvh flex-col lg:min-h-0 lg:p-2 lg:pl-0", children: _jsxs("div", { ref: contentRef, className: "relative flex flex-1 flex-col lg:min-h-0 lg:overflow-y-auto lg:rounded-xl lg:bg-background lg:[scrollbar-gutter:stable]", children: [_jsxs("header", { className: "sticky top-[env(safe-area-inset-top,0px)] z-30 flex min-h-14 shrink-0 flex-wrap items-center gap-x-1 gap-y-1 border-b border-foreground/5 bg-background px-2 py-1.5 sm:px-4 lg:top-0", children: [_jsx("button", { type: "button", onClick: () => setMenuOpen(true), "aria-label": "Ouvrir le menu", className: "touch-target grid size-10 shrink-0 place-items-center rounded-xl text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 lg:hidden", children: _jsx(HugeiconsIcon, { icon: Menu01Icon, "aria-hidden": true, className: "size-5" }) }), _jsxs("div", { "data-slot": "app-shell-brand", className: "flex shrink-0 items-center gap-2 pr-1 lg:hidden", children: [_jsx(ProductBadge, { product: product, className: "size-6" }), _jsx("span", { className: "font-heading text-[0.9375rem] font-semibold", children: productName(product) })] }), _jsx("div", { className: cn("flex min-w-0 items-center gap-2", header ? "flex-[1_1_7rem]" : "flex-1"), children: header }), onSearch ? (_jsx(CommandMenuButton, { onClick: onSearch, compact: "mobile", className: "shrink-0 lg:w-96 lg:max-w-none", ...(searchLabel !== undefined ? { label: searchLabel } : {}) })) : null] }), _jsx("div", { "data-slot": "app-shell-loading", "aria-hidden": !loading, className: CONTENT_LOADING_LAYER, children: loading ? (_jsx("span", { role: "status", className: "sr-only", children: "Chargement\u2026" })) : null }), _jsxs("main", { id: "contenu", tabIndex: -1, "aria-busy": loading ? true : undefined, inert: loading || undefined, className: cn("mx-auto w-full max-w-6xl flex-1 px-4 pt-6 pb-16 outline-none sm:px-6 lg:px-10 lg:pt-8", mainClassName), children: [_jsx(ServiceStatus, { pageUrl: statusPageUrl, className: "mb-6" }), _jsx(ContentLoadingContext, { value: registerPagePending, children: contentKey === undefined ? (children) : (_jsx(Suspense, { fallback: _jsx(PagePending, {}), children: children }, contentKey)) })] })] }) }), hasRoot ? null : _jsx(FamilyLoaderHost, {}), _jsx(Drawer, { side: "bottom", open: menuOpen, onOpenChange: setMenuOpen, children: _jsxs(DrawerContent, { className: "lg:hidden", children: [_jsx(DrawerTitle, { className: "sr-only", children: "Menu" }), organization ? _jsx("div", { className: "px-3 pt-1", children: organization }) : null, nav, sidebarFooter ? (_jsx("div", { className: "grid gap-0.5 border-t border-foreground/5 px-3 pt-2 pb-[max(env(safe-area-inset-bottom),0.75rem)]", children: sidebarFooter })) : null] }) })] }) }) }) }) }));
321
+ // Assistant : monté à la première ouverture, puis gardé (sa conversation survit à la fermeture).
322
+ const assistantOpen = assistant?.open ?? false;
323
+ const [assistantMounted, setAssistantMounted] = useState(assistantOpen);
324
+ const [assistantOpenAtMount] = useState(assistantOpen);
325
+ if (assistantOpen && !assistantMounted)
326
+ setAssistantMounted(true);
327
+ const wide = useMediaQuery(ASSISTANT_SIDE_BY_SIDE);
328
+ // Vue dédiée (sous 1280 px) : la page, dessous, ne reçoit ni focus ni clic.
329
+ const pageBlocked = assistantOpen && !wide;
330
+ const assistantPanelId = useId();
331
+ const assistantButtonRef = useRef(null);
332
+ useAssistantShortcut(assistant);
333
+ const footer = (_jsxs(_Fragment, { children: [account !== undefined ? sidebarFooter : null, _jsx(ShellCommonLinks, { links: links ?? {}, statusPageUrl: statusPageUrl }), account ?? sidebarFooter] }));
334
+ return (_jsx(ShellContext.Provider, { value: { closeMobileNav: () => setMenuOpen(false) }, children: _jsx(ProductRailShownContext, { value: productRail, children: _jsx(ShellLeaveContext, { value: leave, children: _jsx(StatusPageContext, { value: statusPageUrl, children: _jsxs("div", { "data-slot": "app-shell", "data-rail-peek": railPeek ? "" : undefined, "data-product-rail": productRail ? "" : undefined, "data-loading": loading ? "" : undefined, "data-hydrated": hydrated ? "" : undefined, "data-assistant": assistantOpen ? "" : undefined, className: cn("min-h-dvh lg:grid lg:h-dvh lg:bg-shell", productRail ? "lg:grid-cols-[auto_auto_minmax(0,1fr)_auto]" : "lg:grid-cols-[auto_minmax(0,1fr)_auto]"), children: [_jsx("a", { href: "#contenu", className: "sr-only z-50 rounded-xl bg-background px-4 py-2 text-sm font-medium focus:not-sr-only focus:fixed focus:top-3 focus:left-3 focus:ring-2 focus:ring-ring", children: "Aller au contenu" }), productRail ? (_jsx("div", { className: "contents", inert: pageBlocked || undefined, children: _jsx(ProductRail, { current: product, organization: organization, accountHref: accountHref, onExpandedChange: setRailPeek }) })) : null, _jsxs("aside", { "aria-label": "Barre lat\u00E9rale", inert: pageBlocked || undefined, className: "group/sidebar hidden overflow-hidden text-shell-foreground transition-[width] duration-(--duration-base) ease-ios motion-reduce:transition-none lg:flex lg:w-64 lg:flex-col lg:rail:w-16", children: [_jsx(SidebarHead, { product: product }), !productRail && organization ? _jsx("div", { className: "shrink-0 px-3 pb-1", children: organization }) : null, nav, _jsx("div", { "data-slot": "app-shell-footer", className: "grid gap-0.5 px-3 pt-1 pb-3 empty:hidden", children: footer })] }), _jsx("div", { inert: pageBlocked || undefined, className: "flex min-h-dvh flex-col lg:min-h-0 lg:p-2 lg:pl-0", children: _jsxs("div", { ref: contentRef, className: "relative flex flex-1 flex-col lg:min-h-0 lg:overflow-y-auto lg:rounded-xl lg:bg-background lg:[scrollbar-gutter:stable]", children: [_jsxs("header", { className: "sticky top-[env(safe-area-inset-top,0px)] z-30 flex min-h-14 shrink-0 flex-wrap items-center gap-x-1 gap-y-1 border-b border-foreground/5 bg-background px-2 py-1.5 sm:px-4 lg:top-0", children: [_jsx("button", { type: "button", onClick: () => setMenuOpen(true), "aria-label": "Ouvrir le menu", className: "touch-target grid size-10 shrink-0 place-items-center rounded-xl text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 lg:hidden", children: _jsx(HugeiconsIcon, { icon: Menu01Icon, "aria-hidden": true, className: "size-5" }) }), _jsxs("div", { "data-slot": "app-shell-brand", className: "flex shrink-0 items-center gap-2 pr-1 lg:hidden", children: [_jsx(ProductBadge, { product: product, className: "size-6" }), _jsx("span", { className: "font-heading text-[0.9375rem] font-semibold", children: productName(product) })] }), _jsx("div", { className: cn("flex min-w-0 items-center gap-2", header ? "flex-[1_1_7rem]" : "flex-1"), children: header }), onSearch ? (_jsx(CommandMenuButton, { onClick: onSearch, compact: "mobile", className: "shrink-0 lg:w-96 lg:max-w-none", ...(searchLabel !== undefined ? { label: searchLabel } : {}) })) : null, assistant ? (_jsx(AssistantButton, { assistant: assistant, panelId: assistantPanelId, buttonRef: assistantButtonRef })) : null] }), _jsx("div", { "data-slot": "app-shell-loading", "aria-hidden": !loading, className: CONTENT_LOADING_LAYER, children: loading ? (_jsx("span", { role: "status", className: "sr-only", children: "Chargement\u2026" })) : null }), _jsxs("main", { id: "contenu", tabIndex: -1, "aria-busy": loading ? true : undefined, inert: loading || undefined, className: cn("mx-auto w-full max-w-6xl flex-1 px-4 pt-6 pb-16 outline-none sm:px-6 lg:px-10 lg:pt-8", mainClassName), children: [_jsx(ServiceStatus, { pageUrl: statusPageUrl, className: "mb-6" }), _jsx(ContentLoadingContext, { value: registerPagePending, children: contentKey === undefined ? (children) : (_jsx(Suspense, { fallback: _jsx(PagePending, {}), children: children }, contentKey)) })] })] }) }), assistant && assistantMounted ? (_jsx(AssistantPanel, { assistant: assistant, panelId: assistantPanelId, wide: wide, openAtShellMount: assistantOpenAtMount, buttonRef: assistantButtonRef })) : null, hasRoot ? null : _jsx(FamilyLoaderHost, {}), _jsx(Drawer, { side: "bottom", open: menuOpen, onOpenChange: setMenuOpen, children: _jsxs(DrawerContent, { className: "lg:hidden", children: [_jsx(DrawerTitle, { className: "sr-only", children: "Menu" }), organization ? _jsx("div", { className: "px-3 pt-1", children: organization }) : null, nav, _jsx("div", { className: "grid gap-0.5 border-t border-foreground/5 px-3 pt-2 pb-[max(env(safe-area-inset-bottom),0.75rem)] empty:hidden", children: footer })] }) })] }) }) }) }) }));
217
335
  }
package/dist/avatar.d.ts CHANGED
@@ -4,7 +4,15 @@ declare function Avatar({ className, size, ...props }: AvatarPrimitive.Root.Prop
4
4
  size?: "default" | "sm" | "lg";
5
5
  }): React.JSX.Element;
6
6
  declare function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props): React.JSX.Element;
7
- declare function AvatarFallback({ className, ...props }: AvatarPrimitive.Fallback.Props): React.JSX.Element;
7
+ /**
8
+ * Ce qui s'affiche sans photo (la photo, `AvatarImage`, reste prioritaire). Des initiales en
9
+ * texte prennent une teinte de la marque, stable : celle de `colorKey` (identifiant ou adresse
10
+ * e-mail, à préférer, deux homonymes n'ont pas la même couleur), sinon celle du texte. Une icône,
11
+ * ou `colorKey={null}`, garde le gris neutre.
12
+ */
13
+ declare function AvatarFallback({ className, colorKey, children, ...props }: AvatarPrimitive.Fallback.Props & {
14
+ colorKey?: string | null | undefined;
15
+ }): React.JSX.Element;
8
16
  declare function AvatarBadge({ className, ...props }: React.ComponentProps<"span">): React.JSX.Element;
9
17
  declare function AvatarGroup({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element;
10
18
  declare function AvatarGroupCount({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element;
package/dist/avatar.js CHANGED
@@ -2,14 +2,33 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar";
3
3
  import * as React from "react";
4
4
  import { cn } from "./cn.js";
5
+ import { avatarTone } from "./initials.js";
5
6
  function Avatar({ className, size = "default", ...props }) {
6
7
  return (_jsx(AvatarPrimitive.Root, { "data-slot": "avatar", "data-size": size, className: cn("group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten", className), ...props }));
7
8
  }
8
9
  function AvatarImage({ className, ...props }) {
9
10
  return (_jsx(AvatarPrimitive.Image, { "data-slot": "avatar-image", className: cn("aspect-square size-full rounded-full object-cover", className), ...props }));
10
11
  }
11
- function AvatarFallback({ className, ...props }) {
12
- return (_jsx(AvatarPrimitive.Fallback, { "data-slot": "avatar-fallback", className: cn("flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs", className), ...props }));
12
+ /** Lavis et encre de chaque teinte (`--avatar-<n>`, `styles.css`), à 4,5:1 en clair et en sombre. */
13
+ const TONE_CLASSES = {
14
+ 1: "bg-avatar-1 text-avatar-1-ink",
15
+ 2: "bg-avatar-2 text-avatar-2-ink",
16
+ 3: "bg-avatar-3 text-avatar-3-ink",
17
+ 4: "bg-avatar-4 text-avatar-4-ink",
18
+ 5: "bg-avatar-5 text-avatar-5-ink",
19
+ 6: "bg-avatar-6 text-avatar-6-ink",
20
+ 7: "bg-avatar-7 text-avatar-7-ink",
21
+ };
22
+ /**
23
+ * Ce qui s'affiche sans photo (la photo, `AvatarImage`, reste prioritaire). Des initiales en
24
+ * texte prennent une teinte de la marque, stable : celle de `colorKey` (identifiant ou adresse
25
+ * e-mail, à préférer, deux homonymes n'ont pas la même couleur), sinon celle du texte. Une icône,
26
+ * ou `colorKey={null}`, garde le gris neutre.
27
+ */
28
+ function AvatarFallback({ className, colorKey, children, ...props }) {
29
+ const key = colorKey === undefined ? (typeof children === "string" ? children : "") : (colorKey ?? "");
30
+ const tone = key.trim() ? avatarTone(key) : null;
31
+ return (_jsx(AvatarPrimitive.Fallback, { "data-slot": "avatar-fallback", "data-tone": tone ?? undefined, className: cn("flex size-full items-center justify-center rounded-full text-sm group-data-[size=sm]/avatar:text-xs", tone ? cn("font-semibold", TONE_CLASSES[tone]) : "bg-muted text-muted-foreground", className), ...props, children: children }));
13
32
  }
14
33
  function AvatarBadge({ className, ...props }) {
15
34
  return (_jsx("span", { "data-slot": "avatar-badge", className: cn("absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none", "group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden", "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", className), ...props }));
@@ -21,11 +21,10 @@ export declare function syncFamilyLoader(root: Element | null): void;
21
21
  */
22
22
  export declare const FamilyLoaderRootContext: React.Context<boolean>;
23
23
  /**
24
- * Le H du chargement, le H « Double » de la H Collection : la silhouette grise du H plein, devant
25
- * son reflet immobile (35 %, 50 % en sombre), toujours visibles. Les trois pièces du H plein se
26
- * remplissent de leurs dégradés en une seule boucle de 2 s (gauche, pont, droite, H complet,
27
- * fondu), calée sur l'horloge. CSS seul (`styles.css`), `transform` et `opacity`
28
- * seulement. Mouvement réduit : le H plein respire doucement.
24
+ * Le H du chargement : un seul H, sans reflet ni ombre, sa silhouette grise toujours visible. Les
25
+ * trois pièces se remplissent de leurs dégradés en une seule boucle de 2 s (gauche, pont, droite,
26
+ * H complet, fondu), calée sur l'horloge. CSS seul (`styles.css`), `transform` et `opacity`
27
+ * seulement. Mouvement réduit : le H plein, immobile.
29
28
  *
30
29
  * Un chargement de page ne dessine pas ce H lui-même : c'est `PagePending`, qui passe par le H du
31
30
  * document (`FamilyLoaderRoot`), au même pixel dans tous les états. Ce composant sert au H en
@@ -54,9 +54,9 @@ function useClockSync(ref) {
54
54
  }, [ref]);
55
55
  }
56
56
  const PIECES = ["left", "bar", "right"];
57
- /** Le H « Double » : le reflet immobile, puis les trois pièces du H plein qui se remplissent. */
57
+ /** Un seul H : les trois pièces du H plein qui se remplissent, sans reflet ni ombre. */
58
58
  function Mark() {
59
- return (_jsxs(_Fragment, { children: [_jsx("span", { "data-reflet": true, "aria-hidden": true, children: PIECES.map((piece) => (_jsx("span", { "data-piece": piece, children: _jsx("span", {}) }, piece))) }), PIECES.map((piece) => (_jsx("span", { "data-piece": piece, "aria-hidden": true, children: _jsx("span", { "data-fill": true }) }, piece)))] }));
59
+ return (_jsx(_Fragment, { children: PIECES.map((piece) => (_jsx("span", { "data-piece": piece, "aria-hidden": true, children: _jsx("span", { "data-fill": true }) }, piece))) }));
60
60
  }
61
61
  /**
62
62
  * Vrai sous `FamilyLoaderRoot` : le document a son chargeur, `PagePending` et la coque s'y
@@ -64,11 +64,10 @@ function Mark() {
64
64
  */
65
65
  export const FamilyLoaderRootContext = createContext(false);
66
66
  /**
67
- * Le H du chargement, le H « Double » de la H Collection : la silhouette grise du H plein, devant
68
- * son reflet immobile (35 %, 50 % en sombre), toujours visibles. Les trois pièces du H plein se
69
- * remplissent de leurs dégradés en une seule boucle de 2 s (gauche, pont, droite, H complet,
70
- * fondu), calée sur l'horloge. CSS seul (`styles.css`), `transform` et `opacity`
71
- * seulement. Mouvement réduit : le H plein respire doucement.
67
+ * Le H du chargement : un seul H, sans reflet ni ombre, sa silhouette grise toujours visible. Les
68
+ * trois pièces se remplissent de leurs dégradés en une seule boucle de 2 s (gauche, pont, droite,
69
+ * H complet, fondu), calée sur l'horloge. CSS seul (`styles.css`), `transform` et `opacity`
70
+ * seulement. Mouvement réduit : le H plein, immobile.
72
71
  *
73
72
  * Un chargement de page ne dessine pas ce H lui-même : c'est `PagePending`, qui passe par le H du
74
73
  * document (`FamilyLoaderRoot`), au même pixel dans tous les états. Ce composant sert au H en
@@ -6,3 +6,12 @@
6
6
  * e-mail : « jean.dupont@… » donne « JD »), puis « ? ».
7
7
  */
8
8
  export declare function initials(name: string | null | undefined, fallback?: string | null): string;
9
+ /** Nombre de teintes d'avatar (`--avatar-1` à `--avatar-7`, `styles.css`). */
10
+ export declare const AVATAR_TONES = 7;
11
+ export type AvatarTone = 1 | 2 | 3 | 4 | 5 | 6 | 7;
12
+ /**
13
+ * La teinte de l'avatar à initiales d'une personne ou d'une organisation, stable : la même clé
14
+ * (identifiant, ou adresse e-mail, sans tenir compte de la casse ni des espaces autour) donne
15
+ * toujours la même teinte, dans tous les produits. FNV-1a 32 bits, puis modulo 7.
16
+ */
17
+ export declare function avatarTone(key: string): AvatarTone;
package/dist/initials.js CHANGED
@@ -21,3 +21,18 @@ export function initials(name, fallback) {
21
21
  return fromName;
22
22
  return firstLetters((fallback ?? "").split("@")[0] ?? "", /[\s._+-]+/) || "?";
23
23
  }
24
+ /** Nombre de teintes d'avatar (`--avatar-1` à `--avatar-7`, `styles.css`). */
25
+ export const AVATAR_TONES = 7;
26
+ /**
27
+ * La teinte de l'avatar à initiales d'une personne ou d'une organisation, stable : la même clé
28
+ * (identifiant, ou adresse e-mail, sans tenir compte de la casse ni des espaces autour) donne
29
+ * toujours la même teinte, dans tous les produits. FNV-1a 32 bits, puis modulo 7.
30
+ */
31
+ export function avatarTone(key) {
32
+ let hash = 0x811c9dc5;
33
+ for (const char of key.trim().toLocaleLowerCase("fr-CH")) {
34
+ hash ^= char.codePointAt(0) ?? 0;
35
+ hash = Math.imul(hash, 0x01000193) >>> 0;
36
+ }
37
+ return ((hash % AVATAR_TONES) + 1);
38
+ }
@@ -18,5 +18,5 @@ import { ResourceItem } from "./resource-list.js";
18
18
  */
19
19
  export function MemberItem({ name, email, orgRole: role, image, isMe = false, roleHint, actions, className, }) {
20
20
  const shown = name.trim() || email || "Sans nom";
21
- return (_jsx(ResourceItem, { className: className, icon: _jsxs(Avatar, { className: "size-10", children: [image ? _jsx(AvatarImage, { src: image, alt: "" }) : null, _jsx(AvatarFallback, { className: "text-xs font-semibold", children: initials(name, email) })] }), title: _jsxs("span", { className: "flex min-w-0 items-center gap-1.5", children: [_jsx("span", { className: "truncate", children: shown }), isMe ? _jsx("span", { className: "shrink-0 font-normal text-muted-foreground", children: "(toi)" }) : null] }), meta: email && email !== shown ? email : undefined, badge: role ? _jsx(RoleBadge, { orgRole: role, ...(roleHint !== undefined ? { title: roleHint } : {}) }) : undefined, actions: actions }));
21
+ return (_jsx(ResourceItem, { className: className, icon: _jsxs(Avatar, { className: "size-10", children: [image ? _jsx(AvatarImage, { src: image, alt: "" }) : null, _jsx(AvatarFallback, { colorKey: email || name, className: "text-xs font-semibold", children: initials(name, email) })] }), title: _jsxs("span", { className: "flex min-w-0 items-center gap-1.5", children: [_jsx("span", { className: "truncate", children: shown }), isMe ? _jsx("span", { className: "shrink-0 font-normal text-muted-foreground", children: "(toi)" }) : null] }), meta: email && email !== shown ? email : undefined, badge: role ? _jsx(RoleBadge, { orgRole: role, ...(roleHint !== undefined ? { title: roleHint } : {}) }) : undefined, actions: actions }));
22
22
  }
@@ -28,7 +28,7 @@ export declare function ProjectMark({ project, className, }: {
28
28
  * pendant le chargement : le déclencheur montre déjà `activeName` (le segment de l'URL), le menu
29
29
  * reste utilisable pour « Tous les projets » et « Nouveau projet ».
30
30
  */
31
- export declare function ProjectSwitcher({ projects, activeId, activeName, onSelect, allHref, createHref, onCreate, renderLink, createLabel, className, }: {
31
+ export declare function ProjectSwitcher({ projects, activeId, activeName, onSelect, allHref, createHref, onCreate, renderLink, noun, createLabel, className, }: {
32
32
  projects: ReadonlyArray<SwitcherProject> | undefined;
33
33
  /** Le projet ouvert ; `null` ou absent : « Tous les projets ». */
34
34
  activeId?: string | null;
@@ -43,6 +43,11 @@ export declare function ProjectSwitcher({ projects, activeId, activeName, onSele
43
43
  onCreate?: () => void;
44
44
  /** Rend un lien du routeur du produit ; par défaut un `<a href>`. */
45
45
  renderLink?: (href: string) => ReactElement;
46
+ /** Ce que l'on choisit, nom masculin : `{ one: "site", many: "sites" }` pour Analytics. */
47
+ noun?: {
48
+ one: string;
49
+ many: string;
50
+ };
46
51
  createLabel?: string;
47
52
  className?: string;
48
53
  }): React.JSX.Element;
@@ -39,10 +39,12 @@ function SwitcherEntry({ href, onSelect, renderLink, children, }) {
39
39
  * pendant le chargement : le déclencheur montre déjà `activeName` (le segment de l'URL), le menu
40
40
  * reste utilisable pour « Tous les projets » et « Nouveau projet ».
41
41
  */
42
- export function ProjectSwitcher({ projects, activeId, activeName, onSelect, allHref, createHref, onCreate, renderLink, createLabel = "Nouveau projet", className, }) {
42
+ export function ProjectSwitcher({ projects, activeId, activeName, onSelect, allHref, createHref, onCreate, renderLink, noun = { one: "projet", many: "projets" }, createLabel = `Nouveau ${noun.one}`, className, }) {
43
+ const capitalized = (word) => word.charAt(0).toUpperCase() + word.slice(1);
44
+ const all = `Tous les ${noun.many}`;
43
45
  const hasActive = activeId !== undefined && activeId !== null;
44
46
  const current = hasActive ? projects?.find((p) => p.id === activeId) : undefined;
45
- const label = hasActive ? (current?.name ?? activeName ?? "") : "Tous les projets";
47
+ const label = hasActive ? (current?.name ?? activeName ?? "") : all;
46
48
  const canCreate = createHref !== undefined || onCreate !== undefined;
47
- return (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { render: _jsx("button", { type: "button", "aria-label": hasActive ? `Projet ${label}, changer de projet` : "Tous les projets, changer de projet", className: cn("flex h-10 max-w-full min-w-0 items-center gap-2 rounded-xl px-2.5 text-sm font-medium outline-none transition-colors duration-(--duration-fast) ease-out hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/50 aria-expanded:bg-muted coarse:h-11", className) }), children: [hasActive ? (current ? (_jsx(ProjectMark, { project: current })) : (_jsx("span", { "aria-hidden": true, className: "size-6 shrink-0 rounded-md bg-muted skeleton-pulse" }))) : (_jsx(HugeiconsIcon, { icon: DashboardSquare01Icon, "aria-hidden": true, className: "size-4.5 shrink-0 text-muted-foreground" })), _jsx("span", { className: "min-w-0 truncate", children: label }), _jsx(HugeiconsIcon, { icon: UnfoldMoreIcon, "aria-hidden": true, className: "size-4 shrink-0 text-muted-foreground" })] }), _jsxs(DropdownMenuContent, { align: "start", className: "w-72 max-w-[calc(100vw-2rem)]", children: [_jsxs(SwitcherEntry, { href: allHref, onSelect: () => onSelect?.(null), renderLink: renderLink, children: [_jsx(HugeiconsIcon, { icon: DashboardSquare01Icon, "aria-hidden": true, className: "size-4" }), _jsx("span", { className: "flex-1", children: "Tous les projets" }), hasActive ? null : _jsx(HugeiconsIcon, { icon: Tick02Icon, "aria-label": "Vue active", className: "size-4" })] }), projects?.length ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuGroup, { children: [_jsx(DropdownMenuLabel, { children: "Projets" }), projects.map((project) => (_jsxs(SwitcherEntry, { href: project.href, onSelect: () => onSelect?.(project.id), renderLink: renderLink, children: [_jsx(ProjectMark, { project: project }), _jsx("span", { className: "min-w-0 flex-1 truncate", children: project.name }), project.id === activeId ? (_jsx(HugeiconsIcon, { icon: Tick02Icon, "aria-label": "Projet actif", className: "size-4" })) : null] }, project.id)))] })] })) : null, canCreate ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(SwitcherEntry, { href: createHref, onSelect: onCreate, renderLink: renderLink, children: [_jsx(HugeiconsIcon, { icon: PlusSignIcon, "aria-hidden": true, className: "size-4" }), createLabel] })] })) : null] })] }));
49
+ return (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { render: _jsx("button", { type: "button", "aria-label": hasActive ? `${capitalized(noun.one)} ${label}, changer de ${noun.one}` : `${all}, changer de ${noun.one}`, className: cn("flex h-10 max-w-full min-w-0 items-center gap-2 rounded-xl px-2.5 text-sm font-medium outline-none transition-colors duration-(--duration-fast) ease-out hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/50 aria-expanded:bg-muted coarse:h-11", className) }), children: [hasActive ? (current ? (_jsx(ProjectMark, { project: current })) : (_jsx("span", { "aria-hidden": true, className: "size-6 shrink-0 rounded-md bg-muted skeleton-pulse" }))) : (_jsx(HugeiconsIcon, { icon: DashboardSquare01Icon, "aria-hidden": true, className: "size-4.5 shrink-0 text-muted-foreground" })), _jsx("span", { className: "min-w-0 truncate", children: label }), _jsx(HugeiconsIcon, { icon: UnfoldMoreIcon, "aria-hidden": true, className: "size-4 shrink-0 text-muted-foreground" })] }), _jsxs(DropdownMenuContent, { align: "start", className: "w-72 max-w-[calc(100vw-2rem)]", children: [_jsxs(SwitcherEntry, { href: allHref, onSelect: () => onSelect?.(null), renderLink: renderLink, children: [_jsx(HugeiconsIcon, { icon: DashboardSquare01Icon, "aria-hidden": true, className: "size-4" }), _jsx("span", { className: "flex-1", children: all }), hasActive ? null : _jsx(HugeiconsIcon, { icon: Tick02Icon, "aria-label": "Vue active", className: "size-4" })] }), projects?.length ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuGroup, { children: [_jsx(DropdownMenuLabel, { children: capitalized(noun.many) }), projects.map((project) => (_jsxs(SwitcherEntry, { href: project.href, onSelect: () => onSelect?.(project.id), renderLink: renderLink, children: [_jsx(ProjectMark, { project: project }), _jsx("span", { className: "min-w-0 flex-1 truncate", children: project.name }), project.id === activeId ? (_jsx(HugeiconsIcon, { icon: Tick02Icon, "aria-label": `${capitalized(noun.one)} actif`, className: "size-4" })) : null] }, project.id)))] })] })) : null, canCreate ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(SwitcherEntry, { href: createHref, onSelect: onCreate, renderLink: renderLink, children: [_jsx(HugeiconsIcon, { icon: PlusSignIcon, "aria-hidden": true, className: "size-4" }), createLabel] })] })) : null] })] }));
48
50
  }
@@ -37,6 +37,10 @@ export function ThemeSwitch({ className }) {
37
37
  const current = CHOICES.find((choice) => choice.value === theme);
38
38
  return (_jsxs("div", { className: cn("flex items-center gap-2.5 px-3 py-[3px] text-sm font-medium max-lg:min-h-11 coarse:min-h-11", className), children: [_jsx(HugeiconsIcon, { icon: current?.icon ?? ComputerIcon, "aria-hidden": true, className: "size-4 shrink-0" }), _jsx("span", { className: "flex-1", children: "Th\u00E8me" }), _jsx("div", { ref: group, role: "radiogroup", "aria-label": "Th\u00E8me", tabIndex: -1, onKeyDown: onKeyDown, className: "flex w-fit items-center gap-0.5 rounded-full border border-border p-0.5", children: CHOICES.map(({ value, label, icon }) => {
39
39
  const active = theme === value;
40
- return (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { render: _jsx(MenuPrimitive.Item, { closeOnClick: false, role: "radio", "aria-checked": active, "aria-label": label, "data-theme-choice": value, onClick: () => setTheme(value), className: cn("touch-target grid size-6 cursor-default place-items-center rounded-full outline-none", "transition-colors duration-(--duration-fast) ease-out focus-visible:ring-2 focus-visible:ring-ring/50", active ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground") }), children: _jsx(HugeiconsIcon, { icon: icon, "aria-hidden": true, className: "size-3.5" }) }), _jsx(TooltipContent, { children: label })] }, value));
40
+ return (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { render: _jsx(MenuPrimitive.Item, { closeOnClick: false, role: "radio", "aria-checked": active, "aria-label": label, "data-theme-choice": value, onClick: () => setTheme(value), className: cn("touch-target grid size-7 cursor-default place-items-center rounded-full outline-none", "transition-colors duration-(--duration-fast) ease-out focus-visible:ring-2 focus-visible:ring-ring/50",
41
+ // Le choix actif comme dans `SegmentedControl` : encre pleine, lisible d'un coup d'œil.
42
+ active
43
+ ? "bg-foreground text-background"
44
+ : "text-muted-foreground hover:bg-muted hover:text-foreground data-highlighted:bg-muted data-highlighted:text-foreground") }), children: _jsx(HugeiconsIcon, { icon: icon, "aria-hidden": true, className: "size-4" }) }), _jsx(TooltipContent, { children: label })] }, value));
41
45
  }) })] }));
42
46
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hgdc/ui",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
4
4
  "description": "Interface de la H Collection : jetons de la DA, thèmes, polices et composants.",
5
5
  "license": "UNLICENSED",
6
6
  "homepage": "https://github.com/hugogebel-boop/hgdc-kit/tree/main/packages/ui#readme",
package/styles.css CHANGED
@@ -100,6 +100,20 @@
100
100
  --color-inverse-foreground: var(--inverse-foreground);
101
101
  --color-inverse-muted: var(--inverse-muted);
102
102
  --color-inverse-code: var(--inverse-code);
103
+ --color-avatar-1: var(--avatar-1);
104
+ --color-avatar-1-ink: var(--avatar-1-ink);
105
+ --color-avatar-2: var(--avatar-2);
106
+ --color-avatar-2-ink: var(--avatar-2-ink);
107
+ --color-avatar-3: var(--avatar-3);
108
+ --color-avatar-3-ink: var(--avatar-3-ink);
109
+ --color-avatar-4: var(--avatar-4);
110
+ --color-avatar-4-ink: var(--avatar-4-ink);
111
+ --color-avatar-5: var(--avatar-5);
112
+ --color-avatar-5-ink: var(--avatar-5-ink);
113
+ --color-avatar-6: var(--avatar-6);
114
+ --color-avatar-6-ink: var(--avatar-6-ink);
115
+ --color-avatar-7: var(--avatar-7);
116
+ --color-avatar-7-ink: var(--avatar-7-ink);
103
117
  --radius-sm: calc(var(--radius) * 0.6);
104
118
  --radius-md: calc(var(--radius) * 0.8);
105
119
  --radius-lg: var(--radius);
@@ -185,6 +199,23 @@
185
199
  --inverse-foreground: oklch(0.985 0 0);
186
200
  --inverse-muted: oklch(0.708 0 0);
187
201
  --inverse-code: oklch(0.82 0.11 296);
202
+ /* Avatars à initiales (`AvatarFallback`) : sept teintes, celles des icônes des produits (Host,
203
+ Portal, Account, Mail, Status, Analytics, Docs), en lavis avec une encre de la même teinte.
204
+ Initiales à 5,5:1 au moins, en clair comme en sombre (vérifié par styles.test.ts). */
205
+ --avatar-1: oklch(0.88 0.055 262);
206
+ --avatar-1-ink: oklch(0.42 0.12 262);
207
+ --avatar-2: oklch(0.88 0.06 293);
208
+ --avatar-2-ink: oklch(0.42 0.12 293);
209
+ --avatar-3: oklch(0.88 0.062 40);
210
+ --avatar-3-ink: oklch(0.42 0.12 40);
211
+ --avatar-4: oklch(0.88 0.063 2);
212
+ --avatar-4-ink: oklch(0.42 0.12 2);
213
+ --avatar-5: oklch(0.88 0.07 160);
214
+ --avatar-5-ink: oklch(0.42 0.09 160);
215
+ --avatar-6: oklch(0.88 0.07 75);
216
+ --avatar-6-ink: oklch(0.42 0.083 75);
217
+ --avatar-7: oklch(0.88 0.07 215);
218
+ --avatar-7-ink: oklch(0.42 0.07 215);
188
219
  --radius: 0.85rem;
189
220
  --scrollbar-thumb: color-mix(in oklch, var(--foreground) 18%, transparent);
190
221
  }
@@ -229,6 +260,20 @@
229
260
  --success: oklch(0.7 0.15 152);
230
261
  --success-foreground: oklch(0.16 0.02 152);
231
262
  --inverse: oklch(0.1 0 0);
263
+ --avatar-1: oklch(0.4 0.09 262);
264
+ --avatar-1-ink: oklch(0.9 0.045 262);
265
+ --avatar-2: oklch(0.4 0.09 293);
266
+ --avatar-2-ink: oklch(0.9 0.05 293);
267
+ --avatar-3: oklch(0.4 0.09 40);
268
+ --avatar-3-ink: oklch(0.9 0.05 40);
269
+ --avatar-4: oklch(0.4 0.09 2);
270
+ --avatar-4-ink: oklch(0.9 0.05 2);
271
+ --avatar-5: oklch(0.4 0.085 160);
272
+ --avatar-5-ink: oklch(0.9 0.05 160);
273
+ --avatar-6: oklch(0.4 0.079 75);
274
+ --avatar-6-ink: oklch(0.9 0.05 75);
275
+ --avatar-7: oklch(0.4 0.066 215);
276
+ --avatar-7-ink: oklch(0.9 0.05 215);
232
277
  }
233
278
 
234
279
  /* Identité H Collection. Dégradé de chaque icône produit sur fond clair, de la teinte vive à la
@@ -533,7 +578,7 @@
533
578
  }
534
579
  }
535
580
  /* Chargeur de la famille (`FamilyLoader`, ADR 0015) : silhouette grise du H, pièces remplies de
536
- leurs dégradés. Géométrie du H sur 256 (FAMILY_MARK_SVG). Une seule boucle de 2 s pour tout le H,
581
+ leurs dégradés. Géométrie du H de FAMILY_MARK_SVG, sans le reflet. Une seule boucle de 2 s pour tout le H,
537
582
  calée sur l'horloge (`syncFamilyLoader`) : le jambage gauche monte, la barre le prolonge vers la
538
583
  droite, le jambage droit s'ouvre depuis la barre vers le haut et le bas ; le H complet tient, se
539
584
  vide d'un fondu, la silhouette reste un instant, et ça recommence. Seuls `transform` et `opacity`
@@ -551,23 +596,21 @@
551
596
  .dark .family-loader {
552
597
  --fl-ghost-mix: 19%;
553
598
  }
554
- /* Le H « Double » (`family/hcollection-icon.svg`, cadre de 46 unités) : le H plein, trois pièces,
555
- devant son reflet décalé de 6 unités. Seul le H plein se remplit ; le reflet reste à 35 % (50 %
556
- en sombre), immobile. */
599
+ /* Un seul H, plein, trois pièces, centré dans un cadre de 46 unités (le H de la marque,
600
+ `family/hcollection-icon.svg`, sans son reflet) : ni ombre, ni double, à aucune image de la
601
+ boucle (demande de Hugo, 2026-10-18). */
557
602
  .family-loader {
558
603
  --fl-u: calc(var(--fl) / 46);
559
604
  --fl-l0: #2c78fd;
560
605
  --fl-l1: #7343fe;
561
606
  --fl-r0: #fe5292;
562
607
  --fl-r1: #ff9a46;
563
- --fl-reflet: 0.35;
564
608
  }
565
609
  .dark .family-loader {
566
610
  --fl-l0: #6aa0ff;
567
611
  --fl-l1: #9a7bff;
568
612
  --fl-r0: #ff7aa8;
569
613
  --fl-r1: #ffb070;
570
- --fl-reflet: 0.5;
571
614
  }
572
615
  .family-loader [data-piece] {
573
616
  position: absolute;
@@ -581,31 +624,24 @@
581
624
  .family-loader [data-piece="left"],
582
625
  .family-loader [data-piece="right"] {
583
626
  z-index: 1;
584
- top: calc(var(--fl-u) * 2.5);
627
+ top: calc(var(--fl-u) * 5);
585
628
  width: calc(var(--fl-u) * 10);
586
629
  height: calc(var(--fl-u) * 36);
587
630
  border-radius: calc(var(--fl-u) * 3);
588
631
  }
589
632
  .family-loader [data-piece="left"] {
590
- left: calc(var(--fl-u) * 5.5);
633
+ left: calc(var(--fl-u) * 8);
591
634
  }
592
635
  .family-loader [data-piece="right"] {
593
- left: calc(var(--fl-u) * 25.5);
636
+ left: calc(var(--fl-u) * 28);
594
637
  }
595
638
  /* Le pont déborde d'une demi-unité sous chaque montant, comme dans le dessin. */
596
639
  .family-loader [data-piece="bar"] {
597
- left: calc(var(--fl-u) * 15);
598
- top: calc(var(--fl-u) * 16);
640
+ left: calc(var(--fl-u) * 17.5);
641
+ top: calc(var(--fl-u) * 18.5);
599
642
  width: calc(var(--fl-u) * 11);
600
643
  height: calc(var(--fl-u) * 9);
601
644
  }
602
- /* Le reflet : le même H, plein, décalé de (6, 6), en un seul calque translucide. */
603
- .family-loader > [data-reflet] {
604
- position: absolute;
605
- inset: 0;
606
- opacity: var(--fl-reflet);
607
- transform: translate(calc(var(--fl-u) * 6), calc(var(--fl-u) * 6));
608
- }
609
645
  /* Couleurs du H : chaque montant son dégradé vertical ; le pont prend celui du montant droit,
610
646
  recouvert de celui du gauche qui s'efface de gauche à droite (le masque du dessin). Le pont
611
647
  occupe les unités 25 à 34 des 11,5 à 47,5 des montants : dégradé quatre fois plus haut, centré. */
@@ -627,10 +663,6 @@
627
663
  background-position: 0 50%;
628
664
  mask-image: linear-gradient(to right, #000 4.545%, transparent 95.455%);
629
665
  }
630
- .family-loader [data-reflet] [data-piece] > * {
631
- position: absolute;
632
- inset: 0;
633
- }
634
666
  .family-loader [data-fill] {
635
667
  position: absolute;
636
668
  inset: 0;
@@ -723,21 +755,12 @@
723
755
  opacity: 1;
724
756
  }
725
757
  }
726
- /* Mouvement réduit : le H est plein et respire doucement, sur la même boucle. */
727
- @keyframes family-loader-breathe {
728
- 0%,
729
- 100% {
730
- opacity: 0.4;
731
- }
732
- 50% {
733
- opacity: 1;
734
- }
735
- }
758
+ /* Mouvement réduit : le H plein, immobile. Le chargement se lit au libellé (`role="status"`). */
736
759
  @media (prefers-reduced-motion: reduce) {
737
760
  .family-loader [data-piece] [data-fill] {
738
761
  transform: none;
739
- animation-name: family-loader-breathe;
740
- animation-timing-function: ease-in-out;
762
+ opacity: 1;
763
+ animation-name: none;
741
764
  }
742
765
  }
743
766