@hgdc/ui 0.38.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
@@ -65,11 +65,21 @@ Le menu « Produits » et la palette (`badge` d'une commande) montrent la pastil
65
65
 
66
66
  ### D'un produit à l'autre sous `app.hgdc.ch` (ADR 0020)
67
67
 
68
- Entre deux produits servis sous la même origine, la coque fait comme une seule app : transition de vue entre documents (la barre des produits, la barre latérale et la barre du haut restent en place, le contenu passe en fondu de 150 ms, rien en mouvement réduit), 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.
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.
69
78
 
70
79
  Ce que chaque produit fait :
71
80
 
72
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.
73
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.
74
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à.
75
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`).
@@ -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">`. */
@@ -126,7 +128,11 @@ export declare const PRODUCT_LEAVE_DELAY_MS = 100;
126
128
  export declare function AppShell({ product, organization, productRail, accountHref, nav, links, account, sidebarFooter, header, onSearch, searchLabel, assistant, children, contentKey, navigating, mainClassName, statusPage, }: {
127
129
  /** Le produit affiché (`"host"`) : sa marque en haut de la barre, « vous êtes ici » dans la barre des produits. */
128
130
  product: FamilyProduct;
129
- /** `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
+ */
130
136
  organization?: ReactNode;
131
137
  /**
132
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);
@@ -344,11 +347,6 @@ const TOGGLE = "touch-target relative grid h-9 w-10 shrink-0 place-items-center
344
347
  function ShortcutHint({ label }) {
345
348
  return (_jsxs("span", { className: "flex items-center gap-2", children: [label, _jsxs(KbdGroup, { children: [_jsx(Kbd, { children: isApplePlatform() ? "⌘" : "Ctrl" }), _jsx(Kbd, { children: "B" })] })] }));
346
349
  }
347
- function productName(product) {
348
- if (product === HGDC_ACCOUNT.badge)
349
- return HGDC_ACCOUNT.name;
350
- return HGDC_PRODUCTS.find((item) => item.badge === product)?.name ?? `HGDC ${product}`;
351
- }
352
350
  /**
353
351
  * Haut de la barre latérale. Dépliée : la marque du produit, et le bouton « Réduire » à droite.
354
352
  * En rail : la marque seule, qui devient le bouton « Déplier » au survol de la barre ou au focus,
@@ -356,7 +354,7 @@ function productName(product) {
356
354
  * choisis par la variante CSS `rail:`, donc justes dès la première peinture.
357
355
  */
358
356
  function SidebarHead({ product }) {
359
- 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" }) })] })] }));
360
358
  }
361
359
  /**
362
360
  * La coque commune des consoles HGDC, celle de Host. Sur ordinateur, de gauche à droite : la barre
@@ -445,6 +443,7 @@ export function AppShell({ product, organization, productRail = true, accountHre
445
443
  return;
446
444
  window.clearTimeout(leaveTimer.current);
447
445
  setLeaving(false);
446
+ delete document.documentElement.dataset.hgdcSwitching;
448
447
  };
449
448
  window.addEventListener("pageshow", onPageShow);
450
449
  return () => {
@@ -459,6 +458,21 @@ export function AppShell({ product, organization, productRail = true, accountHre
459
458
  const hydrated = useHydrated();
460
459
  const hasRoot = useContext(FamilyLoaderRootContext);
461
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
+ });
462
476
  useEffect(() => {
463
477
  // Sur ordinateur, la zone de contenu défile seule : le calque couvre sa partie visible, en haut.
464
478
  if (loading && contentRef.current)
@@ -477,5 +491,5 @@ export function AppShell({ product, organization, productRail = true, accountHre
477
491
  const assistantButtonRef = useRef(null);
478
492
  useAssistantShortcut(assistant);
479
493
  const footer = (_jsxs(_Fragment, { children: [account !== undefined ? sidebarFooter : null, _jsx(ShellCommonLinks, { links: links ?? {}, statusPageUrl: statusPageUrl }), account ?? sidebarFooter] }));
480
- 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", { "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: 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 })] }) })] }) }) }) }) }));
481
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;
@@ -3,6 +3,7 @@ import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } fr
3
3
  import { ProductBadge } from "./product-badge.js";
4
4
  import { PRODUCT_PRERENDER_ATTRIBUTE } from "./product-switch.js";
5
5
  import { productRailEntries } from "./products.js";
6
+ import { markProductSwitch, takeProductSwitch } from "./shell-memory.js";
6
7
  import { ShellPlacementContext, useLeaveOnClick } from "./shell-placement.js";
7
8
  import { cn } from "./cn.js";
8
9
  const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
@@ -13,10 +14,41 @@ function usePageOrigin() {
13
14
  }
14
15
  /** Délai d'intention avant d'élargir la barre au survol : un passage de souris ne l'ouvre pas. */
15
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
+ }
16
26
  function ProductLink({ entry }) {
17
27
  // Chaque produit s'ouvre en document complet : la coque montre le H dès le clic.
18
28
  const leaveOnClick = useLeaveOnClick();
19
- return (_jsx("li", { children: _jsxs("a", { href: entry.href, onClick: leaveOnClick, ...(entry.sameOrigin ? { [PRODUCT_PRERENDER_ATTRIBUTE]: "" } : {}), "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))) })] }) }));
20
52
  }
21
53
  /**
22
54
  * La barre des produits de la famille, tout à gauche de la coque sur ordinateur : organisation en
@@ -50,17 +82,21 @@ export function ProductRail({ current, organization, accountHref, catalog, onExp
50
82
  if (!rail)
51
83
  return;
52
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();
53
88
  const cancel = () => {
54
89
  window.clearTimeout(timer);
55
90
  timer = undefined;
56
91
  };
57
92
  const onEnter = (event) => {
58
- if (event.pointerType !== "mouse")
93
+ if (event.pointerType !== "mouse" || arrivedUnderPointer)
59
94
  return;
60
95
  cancel();
61
96
  timer = window.setTimeout(() => setHovered(true), PRODUCT_RAIL_OPEN_DELAY);
62
97
  };
63
98
  const onLeave = () => {
99
+ arrivedUnderPointer = false;
64
100
  cancel();
65
101
  setHovered(false);
66
102
  };
@@ -88,6 +124,10 @@ export function ProductRail({ current, organization, accountHref, catalog, onExp
88
124
  return;
89
125
  if (!(event.target instanceof Element) || !event.target.closest("a[href]"))
90
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();
91
131
  cancel();
92
132
  setHovered(false);
93
133
  setFocused(false);
@@ -126,10 +166,10 @@ export function ProductRail({ current, organization, accountHref, catalog, onExp
126
166
  rail.removeEventListener("click", onClick);
127
167
  };
128
168
  }, []);
129
- 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,
130
170
  // Même durée et même courbe que la barre latérale qui se plie : la somme des deux largeurs
131
171
  // reste constante (56 + 256 = 248 + 64), la page ne bouge pas.
132
- "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)))] })] }) }));
133
173
  }
134
174
  function focusVisible(target) {
135
175
  if (!(target instanceof Element))
@@ -0,0 +1,21 @@
1
+ import { type FamilyProduct } from "./family-svg.ts";
2
+ /** Nom affiché d'un produit de la famille (« Mail »), Account compris. */
3
+ export declare function familyProductName(product: FamilyProduct): string;
4
+ /** Haut de la barre latérale (marque du produit) : le même dans la coque et dans le cadre. */
5
+ export declare const SIDEBAR_HEAD = "mt-2 flex h-14 shrink-0 items-center gap-1 px-3";
6
+ export declare const SIDEBAR_BRAND = "flex min-w-0 flex-1 items-center gap-2.5 pl-2.25";
7
+ export declare const SIDEBAR_BRAND_NAME = "truncate font-heading text-[0.9375rem] font-semibold";
8
+ /** Même boîte qu'une entrée de la barre en rail : 40 × 36 px. */
9
+ export declare const SIDEBAR_RAIL_BOX = "grid h-9 w-10 shrink-0 place-items-center rounded-xl";
10
+ /**
11
+ * Le cadre de la coque, dessiné par le chargeur du document (`FamilyLoaderRoot`) quand une page
12
+ * attend hors de la coque : barre des produits complète (organisation retenue, pastilles, produit
13
+ * courant), barre latérale du produit (sa marque, la navigation en squelette à sa hauteur
14
+ * retenue), carte du contenu et sa barre du haut. Mêmes boîtes que la coque, au pixel : la
15
+ * transition de vue d'un produit à l'autre n'a rien à faire bouger, et la coque qui monte ensuite
16
+ * prend la place sans saut.
17
+ *
18
+ * Le produit vient de l'adresse (`<html data-hgdc-product>`, `sidebarInitScript`) : le rendu
19
+ * serveur dessine toutes les marques, le CSS montre la bonne.
20
+ */
21
+ export declare function ShellFrame(): React.JSX.Element;
@@ -0,0 +1,33 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { FAMILY_PRODUCTS } from "./family-svg.js";
3
+ import { ProductBadge } from "./product-badge.js";
4
+ import { ProductRailFrame } from "./product-rail.js";
5
+ import { HGDC_ACCOUNT, HGDC_PRODUCTS } from "./products.js";
6
+ /** Nom affiché d'un produit de la famille (« Mail »), Account compris. */
7
+ export function familyProductName(product) {
8
+ if (product === HGDC_ACCOUNT.badge)
9
+ return HGDC_ACCOUNT.name;
10
+ return HGDC_PRODUCTS.find((item) => item.badge === product)?.name ?? `HGDC ${product}`;
11
+ }
12
+ /** Haut de la barre latérale (marque du produit) : le même dans la coque et dans le cadre. */
13
+ export const SIDEBAR_HEAD = "mt-2 flex h-14 shrink-0 items-center gap-1 px-3";
14
+ export const SIDEBAR_BRAND = "flex min-w-0 flex-1 items-center gap-2.5 pl-2.25";
15
+ export const SIDEBAR_BRAND_NAME = "truncate font-heading text-[0.9375rem] font-semibold";
16
+ /** Même boîte qu'une entrée de la barre en rail : 40 × 36 px. */
17
+ export const SIDEBAR_RAIL_BOX = "grid h-9 w-10 shrink-0 place-items-center rounded-xl";
18
+ /** Lignes de squelette de la navigation, coupées à la hauteur retenue (`--hgdc-nav-height`). */
19
+ const SKELETON_ROWS = 12;
20
+ /**
21
+ * Le cadre de la coque, dessiné par le chargeur du document (`FamilyLoaderRoot`) quand une page
22
+ * attend hors de la coque : barre des produits complète (organisation retenue, pastilles, produit
23
+ * courant), barre latérale du produit (sa marque, la navigation en squelette à sa hauteur
24
+ * retenue), carte du contenu et sa barre du haut. Mêmes boîtes que la coque, au pixel : la
25
+ * transition de vue d'un produit à l'autre n'a rien à faire bouger, et la coque qui monte ensuite
26
+ * prend la place sans saut.
27
+ *
28
+ * Le produit vient de l'adresse (`<html data-hgdc-product>`, `sidebarInitScript`) : le rendu
29
+ * serveur dessine toutes les marques, le CSS montre la bonne.
30
+ */
31
+ export function ShellFrame() {
32
+ return (_jsxs("div", { "data-slot": "shell-frame", "aria-hidden": true, className: "hidden h-full lg:flex", children: [_jsx(ProductRailFrame, {}), _jsxs("div", { "data-slot": "shell-frame-sidebar", className: "flex w-64 shrink-0 flex-col overflow-hidden rail:w-16", children: [_jsx("div", { className: SIDEBAR_HEAD, children: FAMILY_PRODUCTS.map((product) => (_jsxs("div", { "data-product": product, className: "contents", children: [_jsxs("div", { className: `${SIDEBAR_BRAND} rail:hidden`, children: [_jsx(ProductBadge, { product: product, className: "size-6" }), _jsx("span", { className: SIDEBAR_BRAND_NAME, children: familyProductName(product) })] }), _jsx("div", { className: `${SIDEBAR_RAIL_BOX} hidden rail:grid`, children: _jsx(ProductBadge, { product: product, className: "size-6" }) })] }, product))) }), _jsx("div", { className: "px-3 py-2", children: _jsx("div", { "data-slot": "shell-frame-nav", className: "grid gap-0.5 overflow-hidden", children: Array.from({ length: SKELETON_ROWS }, (_, index) => (_jsxs("div", { className: "flex h-9 shrink-0 items-center gap-2.5 px-3 rail:justify-center rail:px-0", children: [_jsx("span", { className: "size-[1.125rem] shrink-0 rounded-md bg-shell-hover" }), _jsx("span", { className: "h-2.5 w-24 rounded-full bg-shell-hover rail:hidden" })] }, index))) }) })] }), _jsx("div", { className: "min-w-0 flex-1 py-2 pr-2", children: _jsx("div", { className: "h-full overflow-hidden rounded-xl bg-background", children: _jsx("div", { "data-slot": "shell-frame-header", className: "min-h-14 border-b border-foreground/5 bg-background" }) }) })] }));
33
+ }
@@ -0,0 +1,65 @@
1
+ /** Organisation affichée en dernier : `{ initials, logo? }`, en `localStorage`. */
2
+ export declare const SHELL_ORG_KEY = "hgdc-shell:org";
3
+ /** Hauteur en pixels de la navigation d'un produit : `hgdc-shell:nav:<produit>`. */
4
+ export declare const SHELL_NAV_KEY_PREFIX = "hgdc-shell:nav:";
5
+ /** Passage d'un produit à l'autre (horodatage), en `sessionStorage`. */
6
+ export declare const SHELL_SWITCH_KEY = "hgdc-shell:switch";
7
+ /** Au-delà, un passage noté n'est plus celui qu'on vit (onglet laissé, retour bien plus tard). */
8
+ export declare const SHELL_SWITCH_MAX_AGE_MS = 10000;
9
+ export type ShellOrganization = {
10
+ initials: string;
11
+ logo?: string;
12
+ };
13
+ export type ShellBootConfig = {
14
+ /** Clé du rail de la barre latérale (`sidebar`), valeur `rail`. */
15
+ railKey: string;
16
+ orgKey: string;
17
+ navKeyPrefix: string;
18
+ /** Les produits de la famille, par mot en minuscules. */
19
+ products: ReadonlyArray<string>;
20
+ /** `[produit, origine, base]` des produits servis sous une base de chemin (`PRODUCT_ORIGINS`). */
21
+ origins: ReadonlyArray<readonly [string, string, string]>;
22
+ /** Domaine des produits sur leur sous-domaine (`hgdc.ch` : `mail.hgdc.ch` est Mail). */
23
+ domain: string;
24
+ };
25
+ type BootWindow = {
26
+ document: {
27
+ documentElement: HTMLElement;
28
+ };
29
+ location: {
30
+ origin: string;
31
+ hostname: string;
32
+ pathname: string;
33
+ };
34
+ localStorage: Pick<Storage, "getItem">;
35
+ };
36
+ /**
37
+ * Applique la mémoire de la coque sur `<html>` : `data-sidebar="rail"`, `data-hgdc-product`
38
+ * (le produit de l'adresse), `data-hgdc-org` et les variables `--hgdc-org-initials`,
39
+ * `--hgdc-org-logo`, `--hgdc-nav-height` que lit `styles.css`. Autonome : sa source est recopiée
40
+ * telle quelle dans le script en ligne du `<head>`.
41
+ */
42
+ export declare function shellBoot(config: ShellBootConfig, win: BootWindow): void;
43
+ /** Configuration de la famille : les produits du kit et `PRODUCT_ORIGINS`. */
44
+ export declare const SHELL_BOOT_CONFIG: ShellBootConfig;
45
+ /** Le script en ligne du `<head>` : `shellBoot` et sa configuration, sans dépendance. */
46
+ export declare function shellBootScript(config?: ShellBootConfig): string;
47
+ /** Ce que la coque retient d'une organisation : ses initiales et son logo (`https:` seulement). */
48
+ export declare function shellOrganization(org: {
49
+ name: string;
50
+ logo?: string | undefined;
51
+ }): ShellOrganization;
52
+ /** Retient l'organisation affichée (`OrgSwitcher` le fait) et l'applique à la page. */
53
+ export declare function rememberShellOrganization(org: {
54
+ name: string;
55
+ logo?: string | undefined;
56
+ }): void;
57
+ /** Oublie l'organisation (déconnexion) : la page suivante montre un squelette. */
58
+ export declare function forgetShellOrganization(): void;
59
+ /** Retient la hauteur de la navigation d'un produit (`AppShell` le fait après l'affichage). */
60
+ export declare function rememberShellNavHeight(product: string, height: number): void;
61
+ /** Note le départ vers un autre produit (clic dans la barre des produits). */
62
+ export declare function markProductSwitch(now?: number): void;
63
+ /** Lit et efface la note : vrai si la page vient d'un passage depuis la barre, il y a peu. */
64
+ export declare function takeProductSwitch(now?: number): boolean;
65
+ export {};
@@ -0,0 +1,169 @@
1
+ import { initials } from "./initials.js";
2
+ import { FAMILY_PRODUCTS } from "./family-svg.js";
3
+ import { PRODUCT_ORIGINS } from "./products.js";
4
+ /*
5
+ * Mémoire de la coque, partagée par les produits servis sous la même origine (`app.hgdc.ch/<produit>`,
6
+ * ADR 0020) : ce qu'il faut pour que la première image d'un produit ait déjà la barre des produits
7
+ * complète et la barre latérale à sa taille, avant que ses données n'arrivent.
8
+ *
9
+ * - l'organisation affichée en dernier : ses initiales et son logo (rien d'autre, pas le nom) ;
10
+ * - la hauteur de la navigation de chaque produit, pour un squelette à la bonne taille ;
11
+ * - le passage en cours d'un produit à l'autre (session de l'onglet), pour que la barre d'arrivée
12
+ * ne s'élargisse pas sous une souris restée immobile.
13
+ *
14
+ * `shellBoot` applique la mémoire sur `<html>` avant la première peinture : il tourne en ligne
15
+ * dans le `<head>` (`sidebarInitScript`), donc il ne dépend de rien d'autre que de ses arguments.
16
+ */
17
+ /** Organisation affichée en dernier : `{ initials, logo? }`, en `localStorage`. */
18
+ export const SHELL_ORG_KEY = "hgdc-shell:org";
19
+ /** Hauteur en pixels de la navigation d'un produit : `hgdc-shell:nav:<produit>`. */
20
+ export const SHELL_NAV_KEY_PREFIX = "hgdc-shell:nav:";
21
+ /** Passage d'un produit à l'autre (horodatage), en `sessionStorage`. */
22
+ export const SHELL_SWITCH_KEY = "hgdc-shell:switch";
23
+ /** Au-delà, un passage noté n'est plus celui qu'on vit (onglet laissé, retour bien plus tard). */
24
+ export const SHELL_SWITCH_MAX_AGE_MS = 10_000;
25
+ /**
26
+ * Applique la mémoire de la coque sur `<html>` : `data-sidebar="rail"`, `data-hgdc-product`
27
+ * (le produit de l'adresse), `data-hgdc-org` et les variables `--hgdc-org-initials`,
28
+ * `--hgdc-org-logo`, `--hgdc-nav-height` que lit `styles.css`. Autonome : sa source est recopiée
29
+ * telle quelle dans le script en ligne du `<head>`.
30
+ */
31
+ export function shellBoot(config, win) {
32
+ // Ni fonction imbriquée ni import : un outil de construction n'a rien à y injecter.
33
+ const root = win.document.documentElement;
34
+ let rail = null;
35
+ let rawOrg = null;
36
+ try {
37
+ rail = win.localStorage.getItem(config.railKey);
38
+ rawOrg = win.localStorage.getItem(config.orgKey);
39
+ }
40
+ catch {
41
+ // Stockage bloqué : rien à reprendre.
42
+ }
43
+ if (rail === "rail")
44
+ root.dataset.sidebar = "rail";
45
+ let product = "";
46
+ for (const [word, origin, base] of config.origins) {
47
+ const path = win.location.pathname;
48
+ if (win.location.origin === origin && (path === base || path.startsWith(`${base}/`)))
49
+ product = word;
50
+ }
51
+ const host = win.location.hostname;
52
+ const suffix = `.${config.domain}`;
53
+ if (!product && host.endsWith(suffix)) {
54
+ const word = host.slice(0, -suffix.length);
55
+ if (config.products.includes(word))
56
+ product = word;
57
+ }
58
+ // La coque, une fois montée, sait mieux que l'adresse : on ne réécrit pas son produit.
59
+ if (product && !root.dataset.hgdcProduct)
60
+ root.dataset.hgdcProduct = product;
61
+ product = root.dataset.hgdcProduct ?? "";
62
+ let org = null;
63
+ try {
64
+ org = JSON.parse(rawOrg ?? "null");
65
+ }
66
+ catch {
67
+ org = null;
68
+ }
69
+ const letters = org && typeof org.initials === "string" ? org.initials.replace(/[^\p{L}\p{N}]/gu, "").slice(0, 4) : "";
70
+ const logo = org && typeof org.logo === "string" && org.logo.length < 2048 && /^https:\/\/[^\s"'()\\]+$/.test(org.logo)
71
+ ? org.logo
72
+ : "";
73
+ if (letters || logo) {
74
+ root.dataset.hgdcOrg = "";
75
+ root.style.setProperty("--hgdc-org-initials", logo ? '""' : `"${letters}"`);
76
+ if (logo)
77
+ root.style.setProperty("--hgdc-org-logo", `url("${logo}")`);
78
+ else
79
+ root.style.removeProperty("--hgdc-org-logo");
80
+ }
81
+ else {
82
+ delete root.dataset.hgdcOrg;
83
+ root.style.removeProperty("--hgdc-org-initials");
84
+ root.style.removeProperty("--hgdc-org-logo");
85
+ }
86
+ let height = 0;
87
+ try {
88
+ height = product ? Number(win.localStorage.getItem(config.navKeyPrefix + product)) : 0;
89
+ }
90
+ catch {
91
+ height = 0;
92
+ }
93
+ if (height > 0 && height < 2000)
94
+ root.style.setProperty("--hgdc-nav-height", `${Math.round(height)}px`);
95
+ }
96
+ /** Configuration de la famille : les produits du kit et `PRODUCT_ORIGINS`. */
97
+ export const SHELL_BOOT_CONFIG = {
98
+ railKey: "sidebar",
99
+ orgKey: SHELL_ORG_KEY,
100
+ navKeyPrefix: SHELL_NAV_KEY_PREFIX,
101
+ products: FAMILY_PRODUCTS,
102
+ origins: Object.entries(PRODUCT_ORIGINS).map(([word, { origin, basePath }]) => [word, origin, basePath]),
103
+ domain: "hgdc.ch",
104
+ };
105
+ /** Le script en ligne du `<head>` : `shellBoot` et sa configuration, sans dépendance. */
106
+ export function shellBootScript(config = SHELL_BOOT_CONFIG) {
107
+ return `try{(${shellBoot.toString()})(${JSON.stringify(config)},window)}catch(e){}`;
108
+ }
109
+ function browser() {
110
+ return typeof window === "undefined" ? null : window;
111
+ }
112
+ function storageSet(storage, key, value) {
113
+ try {
114
+ if (value === null)
115
+ window[storage].removeItem(key);
116
+ else
117
+ window[storage].setItem(key, value);
118
+ }
119
+ catch {
120
+ // Stockage bloqué : la première image d'un autre produit montrera un squelette, rien de plus.
121
+ }
122
+ }
123
+ /** Ce que la coque retient d'une organisation : ses initiales et son logo (`https:` seulement). */
124
+ export function shellOrganization(org) {
125
+ return { initials: initials(org.name), ...(org.logo ? { logo: org.logo } : {}) };
126
+ }
127
+ /** Retient l'organisation affichée (`OrgSwitcher` le fait) et l'applique à la page. */
128
+ export function rememberShellOrganization(org) {
129
+ const win = browser();
130
+ if (!win)
131
+ return;
132
+ storageSet("localStorage", SHELL_ORG_KEY, JSON.stringify(shellOrganization(org)));
133
+ shellBoot(SHELL_BOOT_CONFIG, win);
134
+ }
135
+ /** Oublie l'organisation (déconnexion) : la page suivante montre un squelette. */
136
+ export function forgetShellOrganization() {
137
+ const win = browser();
138
+ if (!win)
139
+ return;
140
+ storageSet("localStorage", SHELL_ORG_KEY, null);
141
+ shellBoot(SHELL_BOOT_CONFIG, win);
142
+ }
143
+ /** Retient la hauteur de la navigation d'un produit (`AppShell` le fait après l'affichage). */
144
+ export function rememberShellNavHeight(product, height) {
145
+ if (!browser() || !(height > 0))
146
+ return;
147
+ storageSet("localStorage", SHELL_NAV_KEY_PREFIX + product, String(Math.round(height)));
148
+ }
149
+ /** Note le départ vers un autre produit (clic dans la barre des produits). */
150
+ export function markProductSwitch(now = Date.now()) {
151
+ if (!browser())
152
+ return;
153
+ storageSet("sessionStorage", SHELL_SWITCH_KEY, String(now));
154
+ }
155
+ /** Lit et efface la note : vrai si la page vient d'un passage depuis la barre, il y a peu. */
156
+ export function takeProductSwitch(now = Date.now()) {
157
+ if (!browser())
158
+ return false;
159
+ let value = null;
160
+ try {
161
+ value = window.sessionStorage.getItem(SHELL_SWITCH_KEY);
162
+ window.sessionStorage.removeItem(SHELL_SWITCH_KEY);
163
+ }
164
+ catch {
165
+ return false;
166
+ }
167
+ const at = Number(value);
168
+ return value !== null && at > 0 && now - at >= 0 && now - at < SHELL_SWITCH_MAX_AGE_MS;
169
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hgdc/ui",
3
- "version": "0.38.0",
3
+ "version": "0.39.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
@@ -838,20 +838,58 @@
838
838
  :root:not(:has([data-slot="app-shell"])) [data-slot="family-loader-root"] > [data-frame] {
839
839
  display: block;
840
840
  }
841
+ /* Sur ordinateur, le cadre est la coque elle-même (`ShellFrame`) : barre des produits complète,
842
+ marque du produit, navigation en squelette, carte du contenu (ADR 0020). */
841
843
  @media (width >= 64rem) {
842
844
  [data-slot="family-loader-root"] > [data-frame] {
843
- background:
844
- linear-gradient(var(--shell-rail), var(--shell-rail)) left / var(--hgdc-rail-w) 100% no-repeat,
845
- var(--shell);
846
- }
847
- [data-slot="family-loader-root"] > [data-frame]::after {
848
- content: "";
849
- position: absolute;
850
- inset: 0.5rem 0.5rem 0.5rem var(--hgdc-content-left);
851
- border-radius: var(--radius-xl);
852
- background: var(--background);
845
+ background: var(--shell);
853
846
  }
854
847
  }
848
+ /* La marque du produit de l'adresse (`<html data-hgdc-product>`, `sidebarInitScript`), seule ; le
849
+ produit courant marqué dans la barre, comme `aria-current` dans la coque. */
850
+ [data-slot="shell-frame"] [data-product] {
851
+ display: none;
852
+ }
853
+ [data-slot="shell-frame-rail"] [data-product] {
854
+ display: flex;
855
+ }
856
+ :root[data-hgdc-product="account"] [data-slot="shell-frame"] [data-product="account"],
857
+ :root[data-hgdc-product="host"] [data-slot="shell-frame"] [data-product="host"],
858
+ :root[data-hgdc-product="mail"] [data-slot="shell-frame"] [data-product="mail"],
859
+ :root[data-hgdc-product="portal"] [data-slot="shell-frame"] [data-product="portal"],
860
+ :root[data-hgdc-product="status"] [data-slot="shell-frame"] [data-product="status"],
861
+ :root[data-hgdc-product="analytics"] [data-slot="shell-frame"] [data-product="analytics"],
862
+ :root[data-hgdc-product="docs"] [data-slot="shell-frame"] [data-product="docs"] {
863
+ display: contents;
864
+ }
865
+ :root[data-hgdc-product="account"] [data-slot="shell-frame-rail"] [data-product="account"],
866
+ :root[data-hgdc-product="host"] [data-slot="shell-frame-rail"] [data-product="host"],
867
+ :root[data-hgdc-product="mail"] [data-slot="shell-frame-rail"] [data-product="mail"],
868
+ :root[data-hgdc-product="portal"] [data-slot="shell-frame-rail"] [data-product="portal"],
869
+ :root[data-hgdc-product="status"] [data-slot="shell-frame-rail"] [data-product="status"],
870
+ :root[data-hgdc-product="analytics"] [data-slot="shell-frame-rail"] [data-product="analytics"],
871
+ :root[data-hgdc-product="docs"] [data-slot="shell-frame-rail"] [data-product="docs"] {
872
+ display: flex;
873
+ background: var(--shell-active);
874
+ color: var(--shell-foreground);
875
+ }
876
+ /* Navigation en squelette, à la hauteur retenue pour ce produit (`--hgdc-nav-height`). */
877
+ [data-slot="shell-frame-nav"] {
878
+ max-height: var(--hgdc-nav-height, 7.5rem);
879
+ }
880
+
881
+ /* La place de l'organisation (`OrgPlaceholder`) : la marque retenue (`<html data-hgdc-org>`,
882
+ initiales ou logo), comme `OrgMark` ; sinon un carré de squelette de la même taille. */
883
+ [data-slot="org-placeholder-mark"] {
884
+ background: var(--shell-hover);
885
+ }
886
+ :root[data-hgdc-org] [data-slot="org-placeholder-mark"] {
887
+ background: var(--hgdc-org-logo, var(--foreground)) center / cover no-repeat;
888
+ color: var(--background);
889
+ }
890
+ :root[data-hgdc-org] [data-slot="org-placeholder-mark"]::before {
891
+ content: var(--hgdc-org-initials, "");
892
+ }
855
893
 
856
894
  /* Calque de la zone de contenu (`AppShell`) : opaque, au-dessus de l'ancienne page, sous le H du
857
895
  document. Mêmes conditions et même rythme que le H : ils apparaissent et s'effacent ensemble. */
@@ -893,16 +931,41 @@
893
931
  [data-slot="app-shell-header"] {
894
932
  view-transition-name: hgdc-header;
895
933
  }
934
+ /* Le cadre du chargeur (hors de la coque) et le H lui-même portent les mêmes noms, seulement quand
935
+ ils sont à l'écran : mêmes conditions que l'affichage du chargeur, plus haut. */
936
+ :root:has([data-slot="page-pending"]):not(:has([data-slot="app-shell"])) {
937
+ & [data-slot="shell-frame-rail"] {
938
+ view-transition-name: hgdc-product-rail;
939
+ }
940
+ & [data-slot="shell-frame-sidebar"] {
941
+ view-transition-name: hgdc-sidebar;
942
+ }
943
+ & [data-slot="shell-frame-header"] {
944
+ view-transition-name: hgdc-header;
945
+ }
946
+ }
947
+ :root:has([data-slot="page-pending"]):not(:has([data-slot="app-shell"])),
948
+ :root:has([data-slot="app-shell"][data-loading]),
949
+ :root:has([data-slot="app-shell"]:not([data-hydrated]) [data-slot="page-pending"]) {
950
+ & [data-slot="family-loader-root"] > [data-stage] {
951
+ view-transition-name: hgdc-loader;
952
+ }
953
+ }
954
+ /* Un clic dans la barre des produits : la barre se replie et la barre latérale revient d'un coup,
955
+ pour que l'image de départ soit déjà celle de l'arrivée (`ProductRail`). */
956
+ :root[data-hgdc-switching] [data-slot="product-rail"],
957
+ :root[data-hgdc-switching] [data-slot="app-shell-sidebar"] {
958
+ transition: none;
959
+ }
896
960
  ::view-transition-old(root),
897
961
  ::view-transition-new(root) {
898
962
  animation-duration: var(--duration-fast);
899
963
  animation-timing-function: var(--ease-out);
900
964
  }
901
- ::view-transition-group(hgdc-product-rail),
965
+ /* Barre latérale et barre du haut : à la même place des deux côtés, leur contenu (un autre
966
+ produit) passe en fondu en place. */
902
967
  ::view-transition-group(hgdc-sidebar),
903
968
  ::view-transition-group(hgdc-header),
904
- ::view-transition-old(hgdc-product-rail),
905
- ::view-transition-new(hgdc-product-rail),
906
969
  ::view-transition-old(hgdc-sidebar),
907
970
  ::view-transition-new(hgdc-sidebar),
908
971
  ::view-transition-old(hgdc-header),
@@ -910,3 +973,37 @@
910
973
  animation-duration: var(--duration-base);
911
974
  animation-timing-function: var(--ease-ios);
912
975
  }
976
+ /* Barre des produits et H : identiques des deux côtés (la barre ne change que d'indicateur, le H
977
+ est à la même phase, calé sur l'horloge). Ni fondu ni mouvement : l'ancienne image est masquée,
978
+ la nouvelle, vivante, s'affiche telle quelle. Présents d'un seul côté (une page sans coque,
979
+ une page prête sans H), ils apparaissent ou s'effacent en fondu court. */
980
+ ::view-transition-group(hgdc-product-rail),
981
+ ::view-transition-group(hgdc-loader),
982
+ ::view-transition-new(hgdc-product-rail),
983
+ ::view-transition-new(hgdc-loader) {
984
+ animation: none;
985
+ }
986
+ ::view-transition-old(hgdc-product-rail),
987
+ ::view-transition-old(hgdc-loader) {
988
+ animation: none;
989
+ opacity: 0;
990
+ }
991
+ ::view-transition-old(hgdc-product-rail):only-child,
992
+ ::view-transition-old(hgdc-loader):only-child {
993
+ opacity: 1;
994
+ animation: hgdc-view-out var(--duration-fast) var(--ease-out) both;
995
+ }
996
+ ::view-transition-new(hgdc-product-rail):only-child,
997
+ ::view-transition-new(hgdc-loader):only-child {
998
+ animation: hgdc-view-in var(--duration-fast) var(--ease-out) both;
999
+ }
1000
+ @keyframes hgdc-view-out {
1001
+ to {
1002
+ opacity: 0;
1003
+ }
1004
+ }
1005
+ @keyframes hgdc-view-in {
1006
+ from {
1007
+ opacity: 0;
1008
+ }
1009
+ }