@hgdc/ui 0.37.0 → 0.39.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
@@ -63,6 +63,27 @@ Primitives déjà là : `avatar` `badge` `button` `dialog` `drawer` `dropdown-me
63
63
 
64
64
  Le menu « Produits » et la palette (`badge` d'une commande) montrent la pastille de chaque produit.
65
65
 
66
+ ### D'un produit à l'autre sous `app.hgdc.ch` (ADR 0020)
67
+
68
+ Entre deux produits servis sous la même origine, la coque fait comme une seule app : transition de vue entre documents, lien relatif marqué `data-hgdc-prerender` et prérendu au survol (`PRODUCT_SPECULATION_RULES` de `@hgdc/ui/product-switch`). Le H couvre le contenu 100 ms après le clic (`PRODUCT_LEAVE_DELAY_MS`) : jamais pour une page prérendue. Chrome et Edge : tout ; Safari 18.2+ : la transition, sans prérendu ; Firefox : navigation ordinaire.
69
+
70
+ Pendant le passage (0.39) :
71
+
72
+ - **Barre des produits** : immobile, sans fondu, seul l'indicateur du produit change. La page d'arrivée la peint identique dès sa première image, même quand ses données ne sont pas là : `OrgPlaceholder` tient la place de l'organisation (`organization` absent) avec la marque de la dernière organisation affichée sous l'origine (initiales ou logo, retenus par `OrgSwitcher`, oubliés à la déconnexion par `AccountMenu`), sinon un squelette de la même taille. Le clic la replie sans transition et la page d'arrivée ne l'élargit pas sous la souris restée dessus.
73
+ - **Barre latérale** : la marque du produit et sa navigation passent en fondu de 200 ms. Une page qui attend hors de la coque (`PagePending` sous `FamilyLoaderRoot`) dessine la coque entière : barre des produits, marque du produit de l'adresse, navigation en squelette à la hauteur retenue pour ce produit, barre du haut.
74
+ - **H** : un seul nom de transition (`hgdc-loader`), sans fondu, calé sur l'horloge : celui de l'arrivée reprend à la même phase.
75
+ - **Contenu** : fondu de 150 ms.
76
+
77
+ `sidebarInitScript` (dans le `<head>`, déjà posé par tous les produits) applique cette mémoire sur `<html>` avant la première peinture : `data-hgdc-product`, `data-hgdc-org`, `--hgdc-org-initials`, `--hgdc-org-logo` (adresse `https:` seulement), `--hgdc-nav-height`. Elle vit dans `localStorage` (`hgdc-shell:org`, `hgdc-shell:nav:<produit>`) et `sessionStorage` (`hgdc-shell:switch`) ; rien d'autre que des initiales, un logo et une hauteur.
78
+
79
+ Ce que chaque produit fait :
80
+
81
+ - Monter `@hgdc/ui` et `@hgdc/auth` : `styles.css` déclare `@view-transition`, `AppShell` pose les noms et les règles. Rien d'autre à écrire.
82
+ - Pendant le chargement de ses données, rendre `AppShell` sans `organization` (pas `organization={null}`) : la place reste tenue.
83
+ - CSP : aucun produit ne pose `script-src` aujourd'hui. Celui qui en pose un ajoute `'inline-speculation-rules'` (règles en ligne insérées par la coque) ; sans cela, Chrome les ignore et rien ne casse.
84
+ - Une page qui compte une visite ou lance un effet au chargement attend l'affichage : `document.prerendering` faux, sinon `prerenderingchange`. La relecture des organisations le fait déjà.
85
+ - `localStorage` est commun à tous les produits de l'origine : `theme` et `sidebar` sont communs, c'est voulu ; les groupes repliables sont propres au produit (`sidebar-group:<produit>:<titre>`). Une clé du produit commence par son mot (`mail:last-org`).
86
+
66
87
  ### Mise en page (ADR 0012)
67
88
 
68
89
  Une seule règle dans les sept produits, posée par la coque :
@@ -12,6 +12,7 @@ import { initials } from "./initials.js";
12
12
  import { MENU_LINK_ITEM } from "./org-switcher.js";
13
13
  import { ProductBadge } from "./product-badge.js";
14
14
  import { productMenuEntries } from "./products.js";
15
+ import { forgetShellOrganization } from "./shell-memory.js";
15
16
  import { useLeaveOnClick, useProductRailShown } from "./shell-placement.js";
16
17
  import { ThemeSwitch } from "./theme-switch.js";
17
18
  /** L'avatar du compte : la photo, sinon les initiales sur la teinte stable de l'adresse. */
@@ -38,7 +39,11 @@ export function AccountMenu({ user, onProfile, onSecurity, onSignOut, products:
38
39
  const products = productsProp ?? productMenuEntries({ current: currentProduct, accountHref });
39
40
  const railShown = useProductRailShown();
40
41
  const leaveOnClick = useLeaveOnClick();
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"] })] })] }));
42
+ 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: () => {
43
+ // La marque de l'organisation retenue pour la coque ne survit pas à la session.
44
+ forgetShellOrganization();
45
+ onSignOut();
46
+ }, children: [_jsx(HugeiconsIcon, { icon: Logout01Icon, "aria-hidden": true }), "Se d\u00E9connecter"] })] })] }));
42
47
  }
43
48
  function ProductMenuIcon({ product }) {
44
49
  if (product.badge)
@@ -2,8 +2,10 @@ import { type IconSvgElement } from "@hugeicons/react";
2
2
  import { type ReactNode } from "react";
3
3
  import type { FamilyProduct } from "./family-svg.ts";
4
4
  /**
5
- * À poser dans le `<head>`, avec `themeInitScript` : il remet le rail avant la première
6
- * peinture, et la variante CSS `rail:` le lit, donc rien ne saute au chargement.
5
+ * À poser dans le `<head>`, avec `themeInitScript` : avant la première peinture, il remet le
6
+ * rail (la variante CSS `rail:` le lit, rien ne saute au chargement) et la mémoire de la coque
7
+ * partagée sous la même origine (`shell-memory` : produit de l'adresse, dernière organisation,
8
+ * hauteur de la navigation), que le cadre du chargeur et `OrgPlaceholder` lisent (ADR 0020).
7
9
  */
8
10
  export declare const sidebarInitScript: string;
9
11
  /** La barre latérale est-elle réduite en rail ? L'état vit sur `<html data-sidebar="rail">`. */
@@ -77,7 +79,7 @@ export declare function SidebarNavGroup(props: {
77
79
  active?: boolean;
78
80
  /** Ouvert tant que la personne n'a rien choisi. Fermé par défaut. */
79
81
  defaultOpen?: boolean;
80
- /** Clé de mémorisation, par défaut le titre : à donner quand deux groupes portent le même. */
82
+ /** Clé de mémorisation, par défaut le titre, propre au produit de la coque : à donner quand deux groupes portent le même. */
81
83
  storageKey?: string;
82
84
  }): React.JSX.Element;
83
85
  /**
@@ -118,10 +120,19 @@ export type AppShellAssistant = {
118
120
  /** Lettre du raccourci `Ctrl` (`⌘`) + lettre qui ouvre et ferme l'assistant (`"j"`). Aucun par défaut. */
119
121
  shortcut?: string;
120
122
  };
123
+ /**
124
+ * Délai entre le clic vers un autre produit et le H : une page prérendue s'affiche avant (une ou
125
+ * deux images), le H n'apparaît alors jamais ; une page à charger le montre aussitôt après.
126
+ */
127
+ export declare const PRODUCT_LEAVE_DELAY_MS = 100;
121
128
  export declare function AppShell({ product, organization, productRail, accountHref, nav, links, account, sidebarFooter, header, onSearch, searchLabel, assistant, children, contentKey, navigating, mainClassName, statusPage, }: {
122
129
  /** Le produit affiché (`"host"`) : sa marque en haut de la barre, « vous êtes ici » dans la barre des produits. */
123
130
  product: FamilyProduct;
124
- /** `OrgSwitcher` : en haut de la barre des produits, en haut du tiroir sur téléphone. */
131
+ /**
132
+ * `OrgSwitcher` : en haut de la barre des produits, en haut du tiroir sur téléphone. Absent
133
+ * pendant que les données du produit arrivent : la barre lui garde sa place, avec la marque de
134
+ * la dernière organisation affichée (`OrgPlaceholder`, ADR 0020). `null` : aucune organisation.
135
+ */
125
136
  organization?: ReactNode;
126
137
  /**
127
138
  * `false` : pas de barre des produits (un écran hors de la famille). L'organisation passe alors
package/dist/app-shell.js CHANGED
@@ -20,8 +20,9 @@ import { CONTENT_LOADING_LAYER, ContentLoadingContext, PAGE_PENDING_MIN_MS, PAGE
20
20
  import { ProductBadge } from "./product-badge.js";
21
21
  import { ProductRail } from "./product-rail.js";
22
22
  import { useProductSwitch } from "./product-switch.js";
23
- import { HGDC_ACCOUNT, HGDC_PRODUCTS } from "./products.js";
24
23
  import { ProductRailShownContext, ShellLeaveContext, StatusPageContext } from "./shell-placement.js";
24
+ import { familyProductName, SIDEBAR_BRAND, SIDEBAR_BRAND_NAME, SIDEBAR_HEAD } from "./shell-frame.js";
25
+ import { rememberShellNavHeight, shellBootScript } from "./shell-memory.js";
25
26
  import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip.js";
26
27
  import { cn } from "./cn.js";
27
28
  import { ServiceStatus, statusPageUrlFor } from "./service-status.js";
@@ -30,10 +31,12 @@ import { useMediaQuery } from "./use-media-query.js";
30
31
  const RAIL_KEY = "sidebar";
31
32
  const RAIL_EVENT = "sidebarchange";
32
33
  /**
33
- * À poser dans le `<head>`, avec `themeInitScript` : il remet le rail avant la première
34
- * peinture, et la variante CSS `rail:` le lit, donc rien ne saute au chargement.
34
+ * À poser dans le `<head>`, avec `themeInitScript` : avant la première peinture, il remet le
35
+ * rail (la variante CSS `rail:` le lit, rien ne saute au chargement) et la mémoire de la coque
36
+ * partagée sous la même origine (`shell-memory` : produit de l'adresse, dernière organisation,
37
+ * hauteur de la navigation), que le cadre du chargeur et `OrgPlaceholder` lisent (ADR 0020).
35
38
  */
36
- export const sidebarInitScript = `try{if(localStorage.getItem(${JSON.stringify(RAIL_KEY)})==="rail")document.documentElement.dataset.sidebar="rail"}catch(e){}`;
39
+ export const sidebarInitScript = shellBootScript();
37
40
  function subscribeRail(onChange) {
38
41
  window.addEventListener(RAIL_EVENT, onChange);
39
42
  return () => window.removeEventListener(RAIL_EVENT, onChange);
@@ -77,7 +80,9 @@ function useToggleShortcut() {
77
80
  }, []);
78
81
  }
79
82
  // ── Navigation ──────────────────────────────────────────────────────────────────────
80
- const ShellContext = createContext({ closeMobileNav: () => { } });
83
+ const ShellContext = createContext({
84
+ closeMobileNav: () => { },
85
+ });
81
86
  /** Pour fermer le tiroir mobile depuis un élément de la barre (un sélecteur, un bouton). */
82
87
  export function useAppShell() {
83
88
  return useContext(ShellContext);
@@ -189,7 +194,11 @@ export function SidebarNavGroup(props) {
189
194
  return _jsx(TitledNavGroup, { label: props.label, children: props.children });
190
195
  }
191
196
  function CollapsibleNavGroup({ label, children, icon, active, defaultOpen = false, storageKey, }) {
192
- const key = storageKey ?? label;
197
+ // Propre au produit : sous une origine partagée (`app.hgdc.ch/<produit>`), `localStorage` est
198
+ // commun à tous. Le thème et la barre réduite le sont volontairement (`theme`, `sidebar`), un
199
+ // groupe « Réglages » de Mail et de Host ne l'est pas (ADR 0020).
200
+ const { product } = useContext(ShellContext);
201
+ const key = product ? `${product}:${storageKey ?? label}` : (storageKey ?? label);
193
202
  const buttonId = useId();
194
203
  const listId = useId();
195
204
  const listRef = useRef(null);
@@ -338,11 +347,6 @@ const TOGGLE = "touch-target relative grid h-9 w-10 shrink-0 place-items-center
338
347
  function ShortcutHint({ label }) {
339
348
  return (_jsxs("span", { className: "flex items-center gap-2", children: [label, _jsxs(KbdGroup, { children: [_jsx(Kbd, { children: isApplePlatform() ? "⌘" : "Ctrl" }), _jsx(Kbd, { children: "B" })] })] }));
340
349
  }
341
- function productName(product) {
342
- if (product === HGDC_ACCOUNT.badge)
343
- return HGDC_ACCOUNT.name;
344
- return HGDC_PRODUCTS.find((item) => item.badge === product)?.name ?? `HGDC ${product}`;
345
- }
346
350
  /**
347
351
  * Haut de la barre latérale. Dépliée : la marque du produit, et le bouton « Réduire » à droite.
348
352
  * En rail : la marque seule, qui devient le bouton « Déplier » au survol de la barre ou au focus,
@@ -350,7 +354,7 @@ function productName(product) {
350
354
  * choisis par la variante CSS `rail:`, donc justes dès la première peinture.
351
355
  */
352
356
  function SidebarHead({ product }) {
353
- return (_jsxs("div", { className: "mt-2 flex h-14 shrink-0 items-center gap-1 px-3", children: [_jsxs("div", { "data-slot": "sidebar-brand", className: "flex min-w-0 flex-1 items-center gap-2.5 pl-2.25 rail:hidden", children: [_jsx(ProductBadge, { product: product, className: "size-6" }), _jsx("span", { className: "truncate font-heading text-[0.9375rem] font-semibold", children: productName(product) })] }), _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { render: _jsx("button", { type: "button", "data-slot": "sidebar-collapse", onClick: toggleSidebar, "aria-label": "R\u00E9duire la barre lat\u00E9rale", "aria-keyshortcuts": "Control+B Meta+B", className: cn(TOGGLE, "rail:hidden") }), children: _jsx(HugeiconsIcon, { icon: COLLAPSE_ICON, strokeWidth: 1.8, "aria-hidden": true, className: "size-[1.125rem]" }) }), _jsx(TooltipContent, { side: "right", children: _jsx(ShortcutHint, { label: "R\u00E9duire" }) })] }), _jsxs(Tooltip, { children: [_jsxs(TooltipTrigger, { render: _jsx("button", { type: "button", "data-slot": "sidebar-expand", onClick: toggleSidebar, "aria-label": "D\u00E9plier la barre lat\u00E9rale", "aria-keyshortcuts": "Control+B Meta+B", className: cn(TOGGLE, "group/toggle hidden rail:grid") }), children: [_jsx(ProductBadge, { product: product, className: "col-start-1 row-start-1 size-6 transition-opacity duration-(--duration-fast) ease-out group-hover/sidebar:opacity-0 group-focus-visible/toggle:opacity-0 motion-reduce:transition-none" }), _jsx(HugeiconsIcon, { icon: EXPAND_ICON, strokeWidth: 1.8, "aria-hidden": true, className: "col-start-1 row-start-1 size-[1.125rem] opacity-0 transition-opacity duration-(--duration-fast) ease-out group-hover/sidebar:opacity-100 group-focus-visible/toggle:opacity-100 motion-reduce:transition-none" })] }), _jsx(TooltipContent, { side: "right", children: _jsx(ShortcutHint, { label: "D\u00E9plier" }) })] })] }));
357
+ return (_jsxs("div", { className: SIDEBAR_HEAD, children: [_jsxs("div", { "data-slot": "sidebar-brand", className: cn(SIDEBAR_BRAND, "rail:hidden"), children: [_jsx(ProductBadge, { product: product, className: "size-6" }), _jsx("span", { className: SIDEBAR_BRAND_NAME, children: familyProductName(product) })] }), _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { render: _jsx("button", { type: "button", "data-slot": "sidebar-collapse", onClick: toggleSidebar, "aria-label": "R\u00E9duire la barre lat\u00E9rale", "aria-keyshortcuts": "Control+B Meta+B", className: cn(TOGGLE, "rail:hidden") }), children: _jsx(HugeiconsIcon, { icon: COLLAPSE_ICON, strokeWidth: 1.8, "aria-hidden": true, className: "size-[1.125rem]" }) }), _jsx(TooltipContent, { side: "right", children: _jsx(ShortcutHint, { label: "R\u00E9duire" }) })] }), _jsxs(Tooltip, { children: [_jsxs(TooltipTrigger, { render: _jsx("button", { type: "button", "data-slot": "sidebar-expand", onClick: toggleSidebar, "aria-label": "D\u00E9plier la barre lat\u00E9rale", "aria-keyshortcuts": "Control+B Meta+B", className: cn(TOGGLE, "group/toggle hidden rail:grid") }), children: [_jsx(ProductBadge, { product: product, className: "col-start-1 row-start-1 size-6 transition-opacity duration-(--duration-fast) ease-out group-hover/sidebar:opacity-0 group-focus-visible/toggle:opacity-0 motion-reduce:transition-none" }), _jsx(HugeiconsIcon, { icon: EXPAND_ICON, strokeWidth: 1.8, "aria-hidden": true, className: "col-start-1 row-start-1 size-[1.125rem] opacity-0 transition-opacity duration-(--duration-fast) ease-out group-hover/sidebar:opacity-100 group-focus-visible/toggle:opacity-100 motion-reduce:transition-none" })] }), _jsx(TooltipContent, { side: "right", children: _jsx(ShortcutHint, { label: "D\u00E9plier" }) })] })] }));
354
358
  }
355
359
  /**
356
360
  * La coque commune des consoles HGDC, celle de Host. Sur ordinateur, de gauche à droite : la barre
@@ -403,6 +407,11 @@ function useContentLoading({ navigating, pending }) {
403
407
  }, [active, visible]);
404
408
  return visible;
405
409
  }
410
+ /**
411
+ * Délai entre le clic vers un autre produit et le H : une page prérendue s'affiche avant (une ou
412
+ * deux images), le H n'apparaît alors jamais ; une page à charger le montre aussitôt après.
413
+ */
414
+ export const PRODUCT_LEAVE_DELAY_MS = 100;
406
415
  export function AppShell({ product, organization, productRail = true, accountHref, nav, links, account, sidebarFooter, header, onSearch, searchLabel, assistant, children, contentKey, navigating = false, mainClassName, statusPage, }) {
407
416
  const [menuOpen, setMenuOpen] = useState(false);
408
417
  // Barre des produits élargie : la barre latérale se plie le temps du survol (ADR 0011).
@@ -420,15 +429,27 @@ export function AppShell({ product, organization, productRail = true, accountHre
420
429
  setPagePending((count) => count + 1);
421
430
  return () => setPagePending((count) => count - 1);
422
431
  }, []);
423
- const leave = useCallback(() => setLeaving(true), []);
432
+ // Après `PRODUCT_LEAVE_DELAY_MS` seulement : une page prérendue au survol s'affiche avant, sans
433
+ // que le H apparaisse (ADR 0020). Sinon le H couvre le contenu jusqu'au départ de la page.
434
+ const leaveTimer = useRef(undefined);
435
+ const leave = useCallback(() => {
436
+ window.clearTimeout(leaveTimer.current);
437
+ leaveTimer.current = window.setTimeout(() => setLeaving(true), PRODUCT_LEAVE_DELAY_MS);
438
+ }, []);
424
439
  useEffect(() => {
425
440
  // Retour arrière depuis l'autre produit (cache de page) : la page revient telle quelle.
426
441
  const onPageShow = (event) => {
427
- if (event.persisted)
428
- setLeaving(false);
442
+ if (!event.persisted)
443
+ return;
444
+ window.clearTimeout(leaveTimer.current);
445
+ setLeaving(false);
446
+ delete document.documentElement.dataset.hgdcSwitching;
429
447
  };
430
448
  window.addEventListener("pageshow", onPageShow);
431
- return () => window.removeEventListener("pageshow", onPageShow);
449
+ return () => {
450
+ window.removeEventListener("pageshow", onPageShow);
451
+ window.clearTimeout(leaveTimer.current);
452
+ };
432
453
  }, []);
433
454
  const statusPageUrl = statusPage === false ? null : (statusPage ?? statusPageUrlFor(product));
434
455
  const loading = useContentLoading({ navigating, pending: pagePending > 0 || leaving });
@@ -437,6 +458,21 @@ export function AppShell({ product, organization, productRail = true, accountHre
437
458
  const hydrated = useHydrated();
438
459
  const hasRoot = useContext(FamilyLoaderRootContext);
439
460
  const contentRef = useRef(null);
461
+ const sidebarRef = useRef(null);
462
+ // Mémoire de la coque (ADR 0020) : le produit affiché, pour le cadre d'un chargement qui suivrait
463
+ // hors de la coque dans ce document, et la hauteur de sa navigation, pour le squelette de la
464
+ // barre latérale à la prochaine arrivée sur ce produit.
465
+ useIsomorphicLayoutEffect(() => {
466
+ document.documentElement.dataset.hgdcProduct = product;
467
+ }, [product]);
468
+ const navHeight = useRef(0);
469
+ useEffect(() => {
470
+ const list = sidebarRef.current?.querySelector(":scope > nav > ul");
471
+ if (!(list instanceof HTMLElement) || list.offsetHeight === navHeight.current)
472
+ return;
473
+ navHeight.current = list.offsetHeight;
474
+ rememberShellNavHeight(product, list.offsetHeight);
475
+ });
440
476
  useEffect(() => {
441
477
  // Sur ordinateur, la zone de contenu défile seule : le calque couvre sa partie visible, en haut.
442
478
  if (loading && contentRef.current)
@@ -455,5 +491,5 @@ export function AppShell({ product, organization, productRail = true, accountHre
455
491
  const assistantButtonRef = useRef(null);
456
492
  useAssistantShortcut(assistant);
457
493
  const footer = (_jsxs(_Fragment, { children: [account !== undefined ? sidebarFooter : null, _jsx(ShellCommonLinks, { links: links ?? {}, statusPageUrl: statusPageUrl }), account ?? sidebarFooter] }));
458
- 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 })] }) })] }) }) }) }) }));
494
+ return (_jsx(ShellContext.Provider, { value: { closeMobileNav: () => setMenuOpen(false), product }, 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", { ref: sidebarRef, "data-slot": "app-shell-sidebar", "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", { "data-slot": "app-shell-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: familyProductName(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 })] }) })] }) }) }) }) }));
459
495
  }
@@ -2,6 +2,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import { createContext, useContext, useEffect, useLayoutEffect, useRef } from "react";
3
3
  import { cn } from "./cn.js";
4
4
  import { useBackgroundOrganizationRefresh } from "./product-switch.js";
5
+ import { ShellFrame } from "./shell-frame.js";
5
6
  const SIZES = { sm: 24, md: 40, lg: 64 };
6
7
  /** Durée de la boucle du H (`styles.css`, `.family-loader [data-fill]`). */
7
8
  export const FAMILY_LOADER_PERIOD_MS = 2000;
@@ -92,7 +93,7 @@ function FamilyMark({ px, label, className, }) {
92
93
  export function FamilyLoaderHost() {
93
94
  const ref = useRef(null);
94
95
  useClockSync(ref);
95
- return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: ref, "data-slot": "family-loader-root", "aria-hidden": true, children: [_jsx("div", { "data-frame": true }), _jsx("div", { "data-stage": true, children: _jsx("span", { "data-slot": "family-loader", className: "family-loader", style: { ["--family-loader-size"]: "64px" }, children: _jsx(Mark, {}) }) })] }), _jsx("script", { dangerouslySetInnerHTML: { __html: SYNC_SCRIPT } })] }));
96
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: ref, "data-slot": "family-loader-root", "aria-hidden": true, children: [_jsx("div", { "data-frame": true, children: _jsx(ShellFrame, {}) }), _jsx("div", { "data-stage": true, children: _jsx("span", { "data-slot": "family-loader", className: "family-loader", style: { ["--family-loader-size"]: "64px" }, children: _jsx(Mark, {}) }) })] }), _jsx("script", { dangerouslySetInnerHTML: { __html: SYNC_SCRIPT } })] }));
96
97
  }
97
98
  /**
98
99
  * Le chargeur du document, à poser une fois dans le `body` de la route racine, autour de l'app :
@@ -4,9 +4,11 @@ import Tick02Icon from "@hugeicons/core-free-icons/Tick02Icon";
4
4
  import UnfoldMoreIcon from "@hugeicons/core-free-icons/UnfoldMoreIcon";
5
5
  import { HugeiconsIcon } from "@hugeicons/react";
6
6
  import { Menu as MenuPrimitive } from "@base-ui/react/menu";
7
+ import { useEffect } from "react";
7
8
  import { Badge } from "./badge.js";
8
9
  import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "./dropdown-menu.js";
9
10
  import { initials } from "./initials.js";
11
+ import { rememberShellOrganization } from "./shell-memory.js";
10
12
  import { useShellPlacement } from "./shell-placement.js";
11
13
  import { cn } from "./cn.js";
12
14
  export const ORG_ROLE_LABEL = {
@@ -39,6 +41,13 @@ export function OrgMark({ org, className, }) {
39
41
  export function OrgSwitcher({ organizations, activeId, onSelect, createHref, onCreate, className, }) {
40
42
  const placement = useShellPlacement();
41
43
  const active = organizations.find((org) => org.id === activeId);
44
+ // Retenue pour la première image du prochain produit ouvert sous la même origine (ADR 0020).
45
+ const activeName = active?.name;
46
+ const activeLogo = active?.logo;
47
+ useEffect(() => {
48
+ if (activeName !== undefined)
49
+ rememberShellOrganization({ name: activeName, logo: activeLogo });
50
+ }, [activeName, activeLogo]);
42
51
  if (!active)
43
52
  return null;
44
53
  // En haut de la barre des produits : la marque seule dans 40 px, le nom quand la barre s'élargit.
@@ -2,6 +2,20 @@ import { type ReactNode } from "react";
2
2
  import { type HgdcCatalogProduct } from "./products.ts";
3
3
  /** Délai d'intention avant d'élargir la barre au survol : un passage de souris ne l'ouvre pas. */
4
4
  export declare const PRODUCT_RAIL_OPEN_DELAY = 150;
5
+ /**
6
+ * La place de l'organisation tant que le produit ne l'a pas (ses données arrivent) : la marque de
7
+ * la dernière organisation affichée sous cette origine, au pixel près de celle d'`OrgSwitcher`
8
+ * (initiales ou logo, posés sur `<html>` par `sidebarInitScript` avant la première peinture),
9
+ * sinon un carré de squelette de la même taille. Rien ne bouge quand la vraie arrive.
10
+ */
11
+ export declare function OrgPlaceholder(): React.JSX.Element;
12
+ /**
13
+ * La barre des produits du cadre de chargement (`FamilyLoaderRoot`, hors de la coque) : la même
14
+ * colonne que `ProductRail` repliée, `OrgPlaceholder` en haut, les mêmes pastilles, sans liens. Le
15
+ * produit courant est marqué par le CSS (`<html data-hgdc-product>`, posé par
16
+ * `sidebarInitScript`) : au rendu serveur, le kit ne connaît pas l'adresse.
17
+ */
18
+ export declare function ProductRailFrame(): React.JSX.Element;
5
19
  /**
6
20
  * La barre des produits de la famille, tout à gauche de la coque sur ordinateur : organisation en
7
21
  * haut, Account, puis les pastilles des produits. 56 px de large ; au survol (après un court délai
@@ -14,7 +28,10 @@ export declare const PRODUCT_RAIL_OPEN_DELAY = 150;
14
28
  export declare function ProductRail({ current, organization, accountHref, catalog, onExpandedChange, }: {
15
29
  /** Le mot du produit affiché (« host ») : marqué en teinte. */
16
30
  current?: string | undefined;
17
- /** `OrgSwitcher`, rendu compact. */
31
+ /**
32
+ * `OrgSwitcher`, rendu compact. Absent (`undefined` : les données du produit arrivent), sa
33
+ * place est tenue par `OrgPlaceholder`. `null` : pas d'organisation, rien en haut.
34
+ */
18
35
  organization?: ReactNode;
19
36
  /** Adresse d'Account (un Account local en développement). */
20
37
  accountHref?: string | undefined;
@@ -1,16 +1,54 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useLayoutEffect, useRef, useState } from "react";
2
+ import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
3
3
  import { ProductBadge } from "./product-badge.js";
4
+ import { PRODUCT_PRERENDER_ATTRIBUTE } from "./product-switch.js";
4
5
  import { productRailEntries } from "./products.js";
6
+ import { markProductSwitch, takeProductSwitch } from "./shell-memory.js";
5
7
  import { ShellPlacementContext, useLeaveOnClick } from "./shell-placement.js";
6
8
  import { cn } from "./cn.js";
7
9
  const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
10
+ const noSubscription = () => () => { };
11
+ /** `location.origin` après l'hydratation, `undefined` au rendu serveur (liens absolus d'abord). */
12
+ function usePageOrigin() {
13
+ return useSyncExternalStore(noSubscription, () => window.location.origin, () => undefined);
14
+ }
8
15
  /** Délai d'intention avant d'élargir la barre au survol : un passage de souris ne l'ouvre pas. */
9
16
  export const PRODUCT_RAIL_OPEN_DELAY = 150;
17
+ /** Boîte d'une entrée de la barre : la même pour un lien et pour sa place dans le cadre de chargement. */
18
+ const PRODUCT_ITEM = "flex h-10 items-center gap-3 rounded-xl px-2 text-shell-muted coarse:h-11";
19
+ /** Colonne de la barre (56 px) et son contenu : les mêmes pour la barre et pour le cadre de chargement. */
20
+ const RAIL_COLUMN = "hidden w-14 shrink-0 overflow-hidden lg:block";
21
+ const RAIL_INNER = "group/rail flex h-full w-full flex-col gap-2 overflow-hidden bg-shell-rail px-2 py-3 text-shell-foreground";
22
+ const RAIL_LIST = "grid min-h-0 gap-1 overflow-x-hidden overflow-y-auto";
23
+ function ProductLabel({ entry }) {
24
+ return (_jsxs(_Fragment, { children: [_jsx(ProductBadge, { product: entry.badge, className: "size-6" }), _jsxs("span", { className: "min-w-0 flex-1 truncate opacity-0 transition-opacity duration-(--duration-fast) ease-out group-data-expanded/rail:opacity-100 motion-reduce:transition-none", children: [_jsx("span", { className: "font-heading text-sm font-medium", children: entry.name }), entry.hint ? _jsx("span", { className: "sr-only", children: ", " }) : null, entry.hint ? _jsx("span", { className: "ml-1.5 text-xs text-shell-muted", children: entry.hint }) : null] })] }));
25
+ }
10
26
  function ProductLink({ entry }) {
11
27
  // Chaque produit s'ouvre en document complet : la coque montre le H dès le clic.
12
28
  const leaveOnClick = useLeaveOnClick();
13
- return (_jsx("li", { children: _jsxs("a", { href: entry.href, onClick: leaveOnClick, "aria-current": entry.current ? "page" : undefined, className: cn("flex h-10 items-center gap-3 rounded-xl px-2 text-shell-muted coarse:h-11 outline-none transition-colors duration-(--duration-fast) ease-out", "hover:bg-shell-hover hover:text-shell-foreground focus-visible:ring-2 focus-visible:ring-ring/50", "aria-[current=page]:bg-shell-active aria-[current=page]:text-shell-foreground"), children: [_jsx(ProductBadge, { product: entry.badge, className: "size-6" }), _jsxs("span", { className: "min-w-0 flex-1 truncate opacity-0 transition-opacity duration-(--duration-fast) ease-out group-data-expanded/rail:opacity-100 motion-reduce:transition-none", children: [_jsx("span", { className: "font-heading text-sm font-medium", children: entry.name }), entry.hint ? _jsx("span", { className: "sr-only", children: ", " }) : null, entry.hint ? _jsx("span", { className: "ml-1.5 text-xs text-shell-muted", children: entry.hint }) : null] })] }) }));
29
+ return (_jsx("li", { children: _jsx("a", { href: entry.href, onClick: leaveOnClick, ...(entry.sameOrigin ? { [PRODUCT_PRERENDER_ATTRIBUTE]: "" } : {}), "aria-current": entry.current ? "page" : undefined, className: cn(PRODUCT_ITEM, "outline-none transition-colors duration-(--duration-fast) ease-out", "hover:bg-shell-hover hover:text-shell-foreground focus-visible:ring-2 focus-visible:ring-ring/50", "aria-[current=page]:bg-shell-active aria-[current=page]:text-shell-foreground"), children: _jsx(ProductLabel, { entry: entry }) }) }));
30
+ }
31
+ /**
32
+ * La place de l'organisation tant que le produit ne l'a pas (ses données arrivent) : la marque de
33
+ * la dernière organisation affichée sous cette origine, au pixel près de celle d'`OrgSwitcher`
34
+ * (initiales ou logo, posés sur `<html>` par `sidebarInitScript` avant la première peinture),
35
+ * sinon un carré de squelette de la même taille. Rien ne bouge quand la vraie arrive.
36
+ */
37
+ export function OrgPlaceholder() {
38
+ return (_jsx("div", { "data-slot": "org-placeholder", "aria-hidden": true, className: "flex h-10 w-full shrink-0 items-center px-1 coarse:h-11", children: _jsx("span", { "data-slot": "org-placeholder-mark", className: "grid size-8 shrink-0 place-items-center rounded-lg text-xs font-semibold uppercase" }) }));
39
+ }
40
+ function RailDivider() {
41
+ return _jsx("div", { "aria-hidden": true, className: "mx-2 h-px shrink-0 bg-foreground/8" });
42
+ }
43
+ /**
44
+ * La barre des produits du cadre de chargement (`FamilyLoaderRoot`, hors de la coque) : la même
45
+ * colonne que `ProductRail` repliée, `OrgPlaceholder` en haut, les mêmes pastilles, sans liens. Le
46
+ * produit courant est marqué par le CSS (`<html data-hgdc-product>`, posé par
47
+ * `sidebarInitScript`) : au rendu serveur, le kit ne connaît pas l'adresse.
48
+ */
49
+ export function ProductRailFrame() {
50
+ const { products, account } = productRailEntries();
51
+ return (_jsx("div", { "data-slot": "shell-frame-rail", className: RAIL_COLUMN, children: _jsxs("div", { className: RAIL_INNER, children: [_jsx(OrgPlaceholder, {}), _jsx(RailDivider, {}), _jsx("ul", { className: RAIL_LIST, children: [account, ...products].map((entry) => (_jsx("li", { children: _jsx("span", { "data-product": entry.badge, className: PRODUCT_ITEM, children: _jsx(ProductLabel, { entry: entry }) }) }, entry.badge))) })] }) }));
14
52
  }
15
53
  /**
16
54
  * La barre des produits de la famille, tout à gauche de la coque sur ordinateur : organisation en
@@ -35,7 +73,8 @@ export function ProductRail({ current, organization, accountHref, catalog, onExp
35
73
  onExpandedChange?.(expanded);
36
74
  }, [expanded, onExpandedChange]);
37
75
  const ref = useRef(null);
38
- const { products, account } = productRailEntries({ current, accountHref, catalog });
76
+ const pageOrigin = usePageOrigin();
77
+ const { products, account } = productRailEntries({ current, accountHref, catalog, pageOrigin });
39
78
  // Écouteurs natifs : la barre est un repère `nav`, pas un contrôle ; c'est son survol, le focus
40
79
  // de ses liens et les menus qu'elle ouvre qui l'élargissent.
41
80
  useEffect(() => {
@@ -43,17 +82,21 @@ export function ProductRail({ current, organization, accountHref, catalog, onExp
43
82
  if (!rail)
44
83
  return;
45
84
  let timer;
85
+ // Arrivée depuis la barre d'un autre produit, la souris restée dessus : la barre ne s'élargit
86
+ // pas sous elle, il faut d'abord en sortir (ADR 0020).
87
+ let arrivedUnderPointer = takeProductSwitch();
46
88
  const cancel = () => {
47
89
  window.clearTimeout(timer);
48
90
  timer = undefined;
49
91
  };
50
92
  const onEnter = (event) => {
51
- if (event.pointerType !== "mouse")
93
+ if (event.pointerType !== "mouse" || arrivedUnderPointer)
52
94
  return;
53
95
  cancel();
54
96
  timer = window.setTimeout(() => setHovered(true), PRODUCT_RAIL_OPEN_DELAY);
55
97
  };
56
98
  const onLeave = () => {
99
+ arrivedUnderPointer = false;
57
100
  cancel();
58
101
  setHovered(false);
59
102
  };
@@ -74,6 +117,21 @@ export function ProductRail({ current, organization, accountHref, catalog, onExp
74
117
  if (!(event.relatedTarget instanceof Node) || !rail.contains(event.relatedTarget))
75
118
  setFocused(false);
76
119
  };
120
+ // Un produit choisi : la barre se replie comme un menu qui se ferme, pendant que l'autre produit
121
+ // s'ouvre. La page d'arrivée la montre repliée : rien ne bouge au passage (ADR 0020).
122
+ const onClick = (event) => {
123
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
124
+ return;
125
+ if (!(event.target instanceof Element) || !event.target.closest("a[href]"))
126
+ return;
127
+ // Repliée d'un coup, sans transition (`data-hgdc-switching`) : l'image de départ de la
128
+ // transition de vue montre déjà la barre telle que la page d'arrivée la peint.
129
+ document.documentElement.dataset.hgdcSwitching = "";
130
+ markProductSwitch();
131
+ cancel();
132
+ setHovered(false);
133
+ setFocused(false);
134
+ };
77
135
  const onKey = (event) => {
78
136
  if (event.key !== "Escape")
79
137
  return;
@@ -94,6 +152,7 @@ export function ProductRail({ current, organization, accountHref, catalog, onExp
94
152
  rail.addEventListener("focusin", onFocusIn);
95
153
  rail.addEventListener("focusout", onFocusOut);
96
154
  rail.addEventListener("keydown", onKey);
155
+ rail.addEventListener("click", onClick);
97
156
  return () => {
98
157
  cancel();
99
158
  observer.disconnect();
@@ -104,12 +163,13 @@ export function ProductRail({ current, organization, accountHref, catalog, onExp
104
163
  rail.removeEventListener("focusin", onFocusIn);
105
164
  rail.removeEventListener("focusout", onFocusOut);
106
165
  rail.removeEventListener("keydown", onKey);
166
+ rail.removeEventListener("click", onClick);
107
167
  };
108
168
  }, []);
109
- return (_jsx("div", { "data-slot": "product-rail", "data-expanded": expanded ? "" : undefined, className: cn("hidden w-14 shrink-0 overflow-hidden lg:block",
169
+ return (_jsx("div", { "data-slot": "product-rail", "data-expanded": expanded ? "" : undefined, className: cn(RAIL_COLUMN,
110
170
  // Même durée et même courbe que la barre latérale qui se plie : la somme des deux largeurs
111
171
  // reste constante (56 + 256 = 248 + 64), la page ne bouge pas.
112
- "transition-[width] duration-(--duration-base) ease-ios motion-reduce:transition-none", "data-expanded:w-62 folded:data-expanded:w-55"), children: _jsxs("nav", { ref: ref, "aria-label": "Produits", "data-expanded": expanded ? "" : undefined, className: "group/rail flex h-full w-full flex-col gap-2 overflow-hidden bg-shell-rail px-2 py-3 text-shell-foreground", children: [organization ? (_jsxs(_Fragment, { children: [_jsx(ShellPlacementContext, { value: "product-rail", children: organization }), _jsx("div", { "aria-hidden": true, className: "mx-2 h-px shrink-0 bg-foreground/8" })] })) : null, _jsxs("ul", { className: "grid min-h-0 gap-1 overflow-x-hidden overflow-y-auto", children: [_jsx(ProductLink, { entry: account }), products.map((entry) => (_jsx(ProductLink, { entry: entry }, entry.badge)))] })] }) }));
172
+ "transition-[width] duration-(--duration-base) ease-ios motion-reduce:transition-none", "data-expanded:w-62 folded:data-expanded:w-55"), children: _jsxs("nav", { ref: ref, "aria-label": "Produits", "data-expanded": expanded ? "" : undefined, className: RAIL_INNER, children: [organization === undefined ? (_jsxs(_Fragment, { children: [_jsx(OrgPlaceholder, {}), _jsx(RailDivider, {})] })) : organization ? (_jsxs(_Fragment, { children: [_jsx(ShellPlacementContext, { value: "product-rail", children: organization }), _jsx(RailDivider, {})] })) : null, _jsxs("ul", { className: RAIL_LIST, children: [_jsx(ProductLink, { entry: account }), products.map((entry) => (_jsx(ProductLink, { entry: entry }, entry.badge)))] })] }) }));
113
173
  }
114
174
  function focusVisible(target) {
115
175
  if (!(target instanceof Element))
@@ -4,13 +4,29 @@ export declare const ORG_SYNC_COOKIE = "hgdc-org-sync";
4
4
  export declare const BACKGROUND_REFRESH_PATH = "/auth/hgdc?refresh=1&frame=1";
5
5
  /** Abandon du cadre s'il ne s'est pas chargé : Account injoignable, réseau coupé. */
6
6
  export declare const BACKGROUND_REFRESH_TIMEOUT_MS = 20000;
7
+ /** Marque d'un lien de la barre vers un autre produit servi sous la même origine : prérendu au survol. */
8
+ export declare const PRODUCT_PRERENDER_ATTRIBUTE = "data-hgdc-prerender";
7
9
  /**
8
- * Liens préchargés au survol : l'entrée d'un autre produit en `refresh=background`. Elle ne fait
9
- * que lire la session et rediriger ; sans session, elle répond 503 à un préchargement (rien n'est
10
- * créé), et le clic repart normalement. Jamais de `prerender` : la page d'arrivée lit un cookie et
11
- * ouvre un cadre, ce qu'un prérendu ferait pour un simple survol.
10
+ * Règles de spéculation posées par la coque (Chrome et Edge ; ignorées ailleurs) :
11
+ *
12
+ * - **prérendu** au survol (`eagerness: "moderate"`, environ 200 ms) des liens marqués
13
+ * `PRODUCT_PRERENDER_ATTRIBUTE` vers l'entrée `refresh=background` d'un produit de la même
14
+ * origine (`app.hgdc.ch/<produit>`) : le motif est relatif, il ne vaut que pour l'origine de la
15
+ * page. L'entrée ne rend que la redirection d'une session ouverte ; sans session, elle répond 503
16
+ * à une requête `Sec-Purpose: prefetch;prerender`, le prérendu est jeté et le clic repart
17
+ * normalement. La page prérendue attend d'être affichée pour relire les organisations.
18
+ * - **préchargement** seul de l'entrée d'un produit encore sur son sous-domaine (même site, les
19
+ * cookies partent), comme avant (ADR 0016).
12
20
  */
21
+ export declare const PRODUCT_SPECULATION_RULES: string;
22
+ /** @deprecated Remplacé par `PRODUCT_SPECULATION_RULES` (prérendu sous la même origine, 0.38). */
13
23
  export declare const PRODUCT_PREFETCH_RULES: string;
24
+ /**
25
+ * Lit et efface le cookie de relecture. Rend la base de chemin du produit (`""` à la racine de son
26
+ * origine, `"/analytics"` sous `app.hgdc.ch/analytics`), `null` s'il n'était pas posé. Sous une
27
+ * base, l'entrée le limite à ce chemin : un autre produit de la même origine ne le voit pas.
28
+ */
29
+ export declare function takeOrgSync(doc?: Document): string | null;
14
30
  /** Lit et efface le cookie de relecture. Vrai s'il était posé. */
15
31
  export declare function takeOrgSyncCookie(doc?: Document): boolean;
16
32
  /**
@@ -18,7 +34,8 @@ export declare function takeOrgSyncCookie(doc?: Document): boolean;
18
34
  * ni script ni formulaire (`sandbox`), il suit seulement les redirections d'Account ; la nouvelle
19
35
  * session du produit recopie les organisations, et les requêtes Convex abonnées se mettent à jour.
20
36
  * Si la session d'Account a expiré, Account refuse d'être affiché dans un cadre : rien ne se passe,
21
- * la session du produit reste celle d'avant.
37
+ * la session du produit reste celle d'avant. Une page prérendue au survol attend d'être affichée :
38
+ * un survol sans clic ne relit rien.
22
39
  */
23
40
  export declare function startBackgroundRefresh(doc?: Document): HTMLIFrameElement | null;
24
41
  /**
@@ -30,6 +47,8 @@ export declare function startBackgroundRefresh(doc?: Document): HTMLIFrameElemen
30
47
  export declare function useBackgroundOrganizationRefresh(): void;
31
48
  /**
32
49
  * Dans la coque (`AppShell` le fait) : la relecture ci-dessus si la racine ne l'a pas déjà faite
33
- * (le cookie ne sert qu'une fois), et le préchargement au survol des autres produits.
50
+ * (le cookie ne sert qu'une fois), et le prérendu ou le préchargement au survol des autres
51
+ * produits. Les règles sont un `<script type="speculationrules">` en ligne : une CSP qui pose
52
+ * `script-src` doit y autoriser `'inline-speculation-rules'` (README).
34
53
  */
35
54
  export declare function useProductSwitch(enabled?: boolean): void;