@hgdc/ui 0.14.2 → 0.15.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
@@ -80,25 +80,25 @@ Sur ordinateur, la coque ouvre sur la barre des produits (ADR 0011) : organisati
80
80
 
81
81
  ## Contenu et données
82
82
 
83
- | Import | Exemple |
84
- | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
85
- | `card` | `<Card><CardHeader><CardTitle>Domaines</CardTitle><CardAction>…</CardAction></CardHeader><CardContent>…</CardContent><CardFooter>…</CardFooter></Card>` |
86
- | `alert` | `<Alert tone="warning" title="Quota presque atteint" action={<Button size="sm">Voir</Button>}>90 % utilisés.</Alert>` |
87
- | `table` | `<TableHead sort="descending" onSort={…}>Date</TableHead>`, `<TableRowLink render={<Link … />}>Production</TableRowLink>`, `<TableEmpty colSpan={4}>…</TableEmpty>`, `<TableSkeleton columns={4} />` |
88
- | `resource-list` | `<ResourceList><ResourceItem icon={…} title="Site vitrine" meta="acme.ch" badge={…} actions={…} href="/p/site" /></ResourceList>` |
89
- | `stat` | `<StatGrid><StatTile label="Erreurs" value="12" delta={{ value: "8 %", direction: "up", good: "down" }} /></StatGrid>` |
90
- | `time-chart` | `<TimeChart label="Envois par jour" timestamps={days} series={[{ key: "sent", label: "Envoyés", values }]} kind="bar" lastIncomplete />` (dernier point en pointillés : la journée n'est pas finie) |
91
- | `status` | `<StatusBadge status="degraded" />` (Opérationnel, Dégradé, Panne, Maintenance, Inconnu) ; hors services, `<StatusBadge tone="success" label="Remis" />` (`neutral`, `success`, `warning`, `danger`, `info`, `pending`) |
92
- | `uptime-bar` | `<UptimeBar days={[{ date, status: "operational", uptime: "100 %" }]} summary="99,95 % sur 90 jours" />` |
93
- | `timeline` | `<Timeline entries={[{ id, title: "Résolu", time, status: "operational" }]} />` |
94
- | `relative-time` | `<RelativeTime date={deployment._creationTime} />` (« il y a 3 min », date exacte au survol) |
95
- | `copy-button` | `<Snippet value="bun add @hgdc/ui" prompt />`, `<CopyButton value={url} />`, `<CopyButton value={all} label="Tout copier" showLabel />` |
96
- | `progress` | `<Progress label="Envoi de la campagne" value={120} max={400} />` (`value={null}` : durée inconnue), `<Meter label="E-mails ce mois" value={8600} max={10000} />` (orange puis rouge près de la limite) |
97
- | `rank-bar` | `<RankBar label="Pages les plus vues" items={[{ key, label: "/tarifs", value: 640, render: <Link … /> }]} limit={5} />` |
98
- | `secret-reveal` | `<SecretReveal label="Clé d'API" value={key} onDismiss={forget} />` : affichée une fois, puis retirée de la page |
99
- | `family-loader` | `<FamilyLoader size="lg" label="Ouverture du projet…" />` : la silhouette grise du H, dont les pièces se remplissent de leurs dégradés en boucle (1,6 s, `transform` seul, fluide pendant un chargement). `page` le centre dans l'écran. Dans un bouton : `Spinner` |
100
- | `page-pending` | `createRouter({ routeTree, ...routerPendingDefaults })` (préchargement au survol, chargement après 50 ms, au moins 300 ms) ; `AppShell {...useRouterState({ select: shellNavigation, structuralSharing: true })}` : le H couvre la zone de contenu dès qu'une navigation attend, la coque reste ; `PagePending` : même H, même place, pour une page qui lit ses données |
101
- | `skeleton` | `<Pending pending={isLoading} fallback={<TableSkeleton columns={3} />}>…</Pending>` : après 200 ms, tenu 400 ms au moins |
83
+ | Import | Exemple |
84
+ | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
85
+ | `card` | `<Card><CardHeader><CardTitle>Domaines</CardTitle><CardAction>…</CardAction></CardHeader><CardContent>…</CardContent><CardFooter>…</CardFooter></Card>` |
86
+ | `alert` | `<Alert tone="warning" title="Quota presque atteint" action={<Button size="sm">Voir</Button>}>90 % utilisés.</Alert>` |
87
+ | `table` | `<TableHead sort="descending" onSort={…}>Date</TableHead>`, `<TableRowLink render={<Link … />}>Production</TableRowLink>`, `<TableEmpty colSpan={4}>…</TableEmpty>`, `<TableSkeleton columns={4} />` |
88
+ | `resource-list` | `<ResourceList><ResourceItem icon={…} title="Site vitrine" meta="acme.ch" badge={…} actions={…} href="/p/site" /></ResourceList>` |
89
+ | `stat` | `<StatGrid><StatTile label="Erreurs" value="12" delta={{ value: "8 %", direction: "up", good: "down" }} /></StatGrid>` |
90
+ | `time-chart` | `<TimeChart label="Envois par jour" timestamps={days} series={[{ key: "sent", label: "Envoyés", values }]} kind="bar" lastIncomplete />` (dernier point en pointillés : la journée n'est pas finie) |
91
+ | `status` | `<StatusBadge status="degraded" />` (Opérationnel, Dégradé, Panne, Maintenance, Inconnu) ; hors services, `<StatusBadge tone="success" label="Remis" />` (`neutral`, `success`, `warning`, `danger`, `info`, `pending`) |
92
+ | `uptime-bar` | `<UptimeBar days={[{ date, status: "operational", uptime: "100 %" }]} summary="99,95 % sur 90 jours" />` |
93
+ | `timeline` | `<Timeline entries={[{ id, title: "Résolu", time, status: "operational" }]} />` |
94
+ | `relative-time` | `<RelativeTime date={deployment._creationTime} />` (« il y a 3 min », date exacte au survol) |
95
+ | `copy-button` | `<Snippet value="bun add @hgdc/ui" prompt />`, `<CopyButton value={url} />`, `<CopyButton value={all} label="Tout copier" showLabel />` |
96
+ | `progress` | `<Progress label="Envoi de la campagne" value={120} max={400} />` (`value={null}` : durée inconnue), `<Meter label="E-mails ce mois" value={8600} max={10000} />` (orange puis rouge près de la limite) |
97
+ | `rank-bar` | `<RankBar label="Pages les plus vues" items={[{ key, label: "/tarifs", value: 640, render: <Link … /> }]} limit={5} />` |
98
+ | `secret-reveal` | `<SecretReveal label="Clé d'API" value={key} onDismiss={forget} />` : affichée une fois, puis retirée de la page |
99
+ | `family-loader` | `<FamilyLoaderRoot>{children}</FamilyLoaderRoot>` une fois dans le `body` de la route racine : le seul H d'un chargement de page, fixe sur la zone de contenu de la coque, rendu par le serveur, calé sur l'horloge (même pixel et même phase d'une page et d'un produit à l'autre, ADR 0015). `<FamilyLoader size="lg" />` : le H en ligne (une boucle de 2 s, `transform` et `opacity`). Dans un bouton : `Spinner` |
100
+ | `page-pending` | `createRouter({ routeTree, ...routerPendingDefaults })` (préchargement au survol, chargement après 50 ms, au moins 300 ms) ; `AppShell {...useRouterState({ select: shellNavigation, structuralSharing: true })}` : la zone de contenu se couvre dès qu'une navigation attend, la coque reste ; `PagePending` : marque la place d'une page qui attend, le H du document s'affiche |
101
+ | `skeleton` | `<Pending pending={isLoading} fallback={<TableSkeleton columns={3} />}>…</Pending>` : après 200 ms, tenu 400 ms au moins |
102
102
 
103
103
  ## Listes, journaux et retours
104
104
 
@@ -33,17 +33,6 @@ export declare function SidebarNavItem({ icon, label, active, href, render, }: {
33
33
  /** Lien du routeur : `render={<Link to="…" />}`. */
34
34
  render?: React.ReactElement;
35
35
  }): React.JSX.Element;
36
- /**
37
- * La coque commune des consoles HGDC, celle de Host. Sur ordinateur, de gauche à droite : la barre
38
- * des produits de la famille (organisation, Account, pastilles ; élargie au survol à la place
39
- * de la barre latérale, qui se plie le temps du survol), la barre latérale du produit (sa marque, sa navigation, le compte en bas),
40
- * réductible en rail (`Ctrl+B`, mémorisé), et la page sur une carte. Sur téléphone : une barre en
41
- * haut, le menu dans un tiroir qui monte du bas et se referme quand on choisit une page ; les
42
- * produits restent dans le menu de l'avatar.
43
- *
44
- * Le kit ne connaît ni le routeur ni l'authentification : le produit passe ses éléments
45
- * (`OrgSwitcher`, `SidebarNav`, `AccountMenu`) et ses liens.
46
- */
47
36
  export declare function AppShell({ product, organization, productRail, accountHref, nav, sidebarFooter, header, onSearch, searchLabel, children, contentKey, navigating, mainClassName, }: {
48
37
  /** Le produit affiché (`"host"`) : sa marque en haut de la barre, « vous êtes ici » dans la barre des produits. */
49
38
  product: FamilyProduct;
package/dist/app-shell.js CHANGED
@@ -4,11 +4,12 @@ import PanelLeftCloseIcon from "@hugeicons/core-free-icons/PanelLeftCloseIcon";
4
4
  import PanelLeftOpenIcon from "@hugeicons/core-free-icons/PanelLeftOpenIcon";
5
5
  import { HugeiconsIcon } from "@hugeicons/react";
6
6
  import { useRender } from "@base-ui/react/use-render";
7
- import { createContext, Suspense, useCallback, useContext, useEffect, useRef, useState, useSyncExternalStore, } from "react";
7
+ import { createContext, Suspense, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, } from "react";
8
8
  import { CommandMenuButton } from "./command-menu.js";
9
9
  import { Drawer, DrawerContent, DrawerTitle } from "./drawer.js";
10
+ import { FamilyLoaderHost, FamilyLoaderRootContext } from "./family-loader.js";
10
11
  import { isApplePlatform, Kbd, KbdGroup } from "./kbd.js";
11
- import { CONTENT_LOADING_LAYER, ContentLoadingContext, ContentLoadingStage, PAGE_PENDING_MIN_MS, PAGE_PENDING_MS, PagePending, useDelayedFlag, } from "./page-pending.js";
12
+ import { CONTENT_LOADING_LAYER, ContentLoadingContext, PAGE_PENDING_MIN_MS, PAGE_PENDING_MS, PagePending, } from "./page-pending.js";
12
13
  import { ProductBadge } from "./product-badge.js";
13
14
  import { ProductRail } from "./product-rail.js";
14
15
  import { HGDC_ACCOUNT, HGDC_PRODUCTS } from "./products.js";
@@ -131,14 +132,55 @@ function SidebarHead({ product }) {
131
132
  * Le kit ne connaît ni le routeur ni l'authentification : le produit passe ses éléments
132
133
  * (`OrgSwitcher`, `SidebarNav`, `AccountMenu`) et ses liens.
133
134
  */
135
+ const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
136
+ /** Faux au rendu serveur et à l'hydratation, vrai avant la première image qui suit. */
137
+ function useHydrated() {
138
+ const [hydrated, setHydrated] = useState(false);
139
+ useIsomorphicLayoutEffect(() => setHydrated(true), []);
140
+ return hydrated;
141
+ }
142
+ /**
143
+ * Le chargement de la zone de contenu : `navigating` après 50 ms, `pending` aussitôt, puis au
144
+ * moins 300 ms. Effets de mise en page : une page qui passe en attente est couverte avant d'être
145
+ * peinte, jamais une image vide entre les deux.
146
+ */
147
+ function useContentLoading({ navigating, pending }) {
148
+ const [navigatingLong, setNavigatingLong] = useState(false);
149
+ useEffect(() => {
150
+ if (!navigating)
151
+ return undefined;
152
+ const timer = window.setTimeout(() => setNavigatingLong(true), PAGE_PENDING_MS);
153
+ return () => {
154
+ window.clearTimeout(timer);
155
+ setNavigatingLong(false);
156
+ };
157
+ }, [navigating]);
158
+ const active = pending || (navigating && navigatingLong);
159
+ const [visible, setVisible] = useState(false);
160
+ const shownAt = useRef(0);
161
+ useIsomorphicLayoutEffect(() => {
162
+ if (active && !visible) {
163
+ shownAt.current = performance.now();
164
+ setVisible(true);
165
+ return undefined;
166
+ }
167
+ if (!active && visible) {
168
+ const timer = window.setTimeout(() => setVisible(false), Math.max(0, shownAt.current + PAGE_PENDING_MIN_MS - performance.now()));
169
+ return () => window.clearTimeout(timer);
170
+ }
171
+ return undefined;
172
+ }, [active, visible]);
173
+ return visible;
174
+ }
134
175
  export function AppShell({ product, organization, productRail = true, accountHref, nav, sidebarFooter, header, onSearch, searchLabel, children, contentKey, navigating = false, mainClassName, }) {
135
176
  const [menuOpen, setMenuOpen] = useState(false);
136
177
  // Barre des produits élargie : la barre latérale se plie le temps du survol (ADR 0011).
137
178
  const [railPeek, setRailPeek] = useState(false);
138
179
  useToggleShortcut();
139
- // Chargement de la zone de contenu : navigation en cours (après 50 ms, au moins 300 ms), ou départ
140
- // vers un autre produit (aussitôt). Une page qui affiche déjà `PagePending` montre le même H au
141
- // même endroit : le calque de la coque s'efface alors derrière lui.
180
+ // Chargement de la zone de contenu (ADR 0015) : une navigation en cours (après 50 ms), une page
181
+ // qui attend (`PagePending`, aussitôt : le routeur a déjà attendu), ou le départ vers un autre
182
+ // produit (aussitôt). Une fois montré, au moins 300 ms. Le H est celui du document, en position
183
+ // fixe : il ne bouge pas d'un état à l'autre et n'est jamais démonté.
142
184
  const [leaving, setLeaving] = useState(false);
143
185
  const [pagePending, setPagePending] = useState(0);
144
186
  const registerPagePending = useCallback(() => {
@@ -155,16 +197,16 @@ export function AppShell({ product, organization, productRail = true, accountHre
155
197
  window.addEventListener("pageshow", onPageShow);
156
198
  return () => window.removeEventListener("pageshow", onPageShow);
157
199
  }, []);
158
- // Le calque attend 50 ms et reste au moins 300 ms ; quand la page prend le relais avec
159
- // `PagePending` (même H, même place, même phase), il s'efface derrière elle sans que rien ne bouge,
160
- // et ne revient pas quand la page arrive.
161
- const navigatingShown = useDelayedFlag(navigating && pagePending === 0, PAGE_PENDING_MS, PAGE_PENDING_MIN_MS);
162
- const loading = navigatingShown || leaving;
200
+ const loading = useContentLoading({ navigating, pending: pagePending > 0 || leaving });
201
+ // Avant l'hydratation, le CSS montre le calque et le H dès qu'une page attend (`:has`) ; ensuite
202
+ // `data-loading` prend le relais dans la même image.
203
+ const hydrated = useHydrated();
204
+ const hasRoot = useContext(FamilyLoaderRootContext);
163
205
  const contentRef = useRef(null);
164
206
  useEffect(() => {
165
207
  // Sur ordinateur, la zone de contenu défile seule : le calque couvre sa partie visible, en haut.
166
208
  if (loading && contentRef.current)
167
209
  contentRef.current.scrollTop = 0;
168
210
  }, [loading]);
169
- return (_jsx(ShellContext.Provider, { value: { closeMobileNav: () => setMenuOpen(false) }, children: _jsx(ProductRailShownContext, { value: productRail, children: _jsx(ShellLeaveContext, { value: leave, children: _jsxs("div", { "data-slot": "app-shell", "data-rail-peek": railPeek ? "" : undefined, className: cn("min-h-dvh lg:grid lg:h-dvh lg:bg-shell", productRail ? "lg:grid-cols-[auto_auto_minmax(0,1fr)]" : "lg:grid-cols-[auto_minmax(0,1fr)]"), children: [_jsx("a", { href: "#contenu", className: "sr-only z-50 rounded-xl bg-background px-4 py-2 text-sm font-medium focus:not-sr-only focus:fixed focus:top-3 focus:left-3 focus:ring-2 focus:ring-ring", children: "Aller au contenu" }), productRail ? (_jsx(ProductRail, { current: product, organization: organization, accountHref: accountHref, onExpandedChange: setRailPeek })) : null, _jsxs("aside", { "aria-label": "Barre lat\u00E9rale", className: "group/sidebar hidden overflow-hidden text-shell-foreground transition-[width] duration-(--duration-base) ease-ios motion-reduce:transition-none lg:flex lg:w-64 lg:flex-col lg:rail:w-16", children: [_jsx(SidebarHead, { product: product }), !productRail && organization ? _jsx("div", { className: "shrink-0 px-3 pb-1", children: organization }) : null, nav, sidebarFooter ? _jsx("div", { className: "grid gap-0.5 px-3 pt-1 pb-3", children: sidebarFooter }) : null] }), _jsx("div", { className: "flex min-h-dvh flex-col lg:min-h-0 lg:p-2 lg:pl-0", children: _jsxs("div", { ref: contentRef, className: "relative flex flex-1 flex-col lg:min-h-0 lg:overflow-y-auto lg:rounded-xl lg:bg-background", children: [_jsxs("header", { className: "sticky top-[env(safe-area-inset-top,0px)] z-30 flex h-14 shrink-0 items-center gap-1 border-b border-foreground/5 bg-background px-2 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: "flex min-w-0 flex-1 items-center gap-2", children: header }), onSearch ? (_jsx(CommandMenuButton, { onClick: onSearch, compact: "mobile", className: "shrink-0 lg:w-96 lg:max-w-none", ...(searchLabel !== undefined ? { label: searchLabel } : {}) })) : null] }), _jsx("div", { "data-slot": "app-shell-loading", "data-visible": loading ? "" : undefined, "aria-hidden": !loading, className: CONTENT_LOADING_LAYER, children: _jsx(ContentLoadingStage, { label: "Chargement\u2026" }) }), _jsx("main", { id: "contenu", tabIndex: -1, "aria-busy": loading || pagePending > 0 ? 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(ContentLoadingContext, { value: registerPagePending, children: contentKey === undefined ? (children) : (_jsx(Suspense, { fallback: _jsx(PagePending, {}), children: children }, contentKey)) }) })] }) }), _jsx(Drawer, { side: "bottom", open: menuOpen, onOpenChange: setMenuOpen, children: _jsxs(DrawerContent, { className: "lg:hidden", children: [_jsx(DrawerTitle, { className: "sr-only", children: "Menu" }), organization ? _jsx("div", { className: "px-3 pt-1", children: organization }) : null, nav, sidebarFooter ? (_jsx("div", { className: "grid gap-0.5 border-t border-foreground/5 px-3 pt-2 pb-[max(env(safe-area-inset-bottom),0.75rem)]", children: sidebarFooter })) : null] }) })] }) }) }) }));
211
+ return (_jsx(ShellContext.Provider, { value: { closeMobileNav: () => setMenuOpen(false) }, children: _jsx(ProductRailShownContext, { value: productRail, children: _jsx(ShellLeaveContext, { value: leave, children: _jsxs("div", { "data-slot": "app-shell", "data-rail-peek": railPeek ? "" : undefined, "data-product-rail": productRail ? "" : undefined, "data-loading": loading ? "" : undefined, "data-hydrated": hydrated ? "" : undefined, className: cn("min-h-dvh lg:grid lg:h-dvh lg:bg-shell", productRail ? "lg:grid-cols-[auto_auto_minmax(0,1fr)]" : "lg:grid-cols-[auto_minmax(0,1fr)]"), children: [_jsx("a", { href: "#contenu", className: "sr-only z-50 rounded-xl bg-background px-4 py-2 text-sm font-medium focus:not-sr-only focus:fixed focus:top-3 focus:left-3 focus:ring-2 focus:ring-ring", children: "Aller au contenu" }), productRail ? (_jsx(ProductRail, { current: product, organization: organization, accountHref: accountHref, onExpandedChange: setRailPeek })) : null, _jsxs("aside", { "aria-label": "Barre lat\u00E9rale", className: "group/sidebar hidden overflow-hidden text-shell-foreground transition-[width] duration-(--duration-base) ease-ios motion-reduce:transition-none lg:flex lg:w-64 lg:flex-col lg:rail:w-16", children: [_jsx(SidebarHead, { product: product }), !productRail && organization ? _jsx("div", { className: "shrink-0 px-3 pb-1", children: organization }) : null, nav, sidebarFooter ? _jsx("div", { className: "grid gap-0.5 px-3 pt-1 pb-3", children: sidebarFooter }) : null] }), _jsx("div", { className: "flex min-h-dvh flex-col lg:min-h-0 lg:p-2 lg:pl-0", children: _jsxs("div", { ref: contentRef, className: "relative flex flex-1 flex-col lg:min-h-0 lg:overflow-y-auto lg:rounded-xl lg:bg-background lg:[scrollbar-gutter:stable]", children: [_jsxs("header", { className: "sticky top-[env(safe-area-inset-top,0px)] z-30 flex h-14 shrink-0 items-center gap-1 border-b border-foreground/5 bg-background px-2 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: "flex min-w-0 flex-1 items-center gap-2", children: header }), onSearch ? (_jsx(CommandMenuButton, { onClick: onSearch, compact: "mobile", className: "shrink-0 lg:w-96 lg:max-w-none", ...(searchLabel !== undefined ? { label: searchLabel } : {}) })) : null] }), _jsx("div", { "data-slot": "app-shell-loading", "aria-hidden": !loading, className: CONTENT_LOADING_LAYER, children: loading ? (_jsx("span", { role: "status", className: "sr-only", children: "Chargement\u2026" })) : null }), _jsx("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(ContentLoadingContext, { value: registerPagePending, children: contentKey === undefined ? (children) : (_jsx(Suspense, { fallback: _jsx(PagePending, {}), children: children }, contentKey)) }) })] }) }), hasRoot ? null : _jsx(FamilyLoaderHost, {}), _jsx(Drawer, { side: "bottom", open: menuOpen, onOpenChange: setMenuOpen, children: _jsxs(DrawerContent, { className: "lg:hidden", children: [_jsx(DrawerTitle, { className: "sr-only", children: "Menu" }), organization ? _jsx("div", { className: "px-3 pt-1", children: organization }) : null, nav, sidebarFooter ? (_jsx("div", { className: "grid gap-0.5 border-t border-foreground/5 px-3 pt-2 pb-[max(env(safe-area-inset-bottom),0.75rem)]", children: sidebarFooter })) : null] }) })] }) }) }) }));
170
212
  }
@@ -3,23 +3,70 @@ declare const SIZES: {
3
3
  readonly md: 40;
4
4
  readonly lg: 64;
5
5
  };
6
+ /** Durée de la boucle du H (`styles.css`, `.family-loader [data-fill]`). */
7
+ export declare const FAMILY_LOADER_PERIOD_MS = 2000;
6
8
  /**
7
- * Le chargement d'une page : la silhouette grise du H de la famille, toujours visible, dont les
8
- * trois pièces se remplissent de leurs dégradés tricolores l'une après l'autre, puis se vident, en
9
- * boucle (1,6 s). CSS seul (`styles.css`) et seulement `transform` : l'animation tourne sur le
10
- * compositeur et reste fluide pendant qu'une page charge. Mouvement réduit : la silhouette reste,
11
- * le remplissage pulse doucement.
9
+ * Début des animations du H sur la ligne de temps du document, pour que la boucle soit à la même
10
+ * phase partout au même instant : `(performance.timeOrigin + now) % période`. Deux pages, deux
11
+ * produits, un rendu serveur et son hydratation tombent en phase sans se parler.
12
+ */
13
+ export declare function familyLoaderStartTime(timeOrigin: number, period?: number): number;
14
+ /** Phase de la boucle (0 à la période) à l'instant `epochMs` (ms depuis 1970). */
15
+ export declare function familyLoaderPhase(epochMs: number, period?: number): number;
16
+ /** Cale toutes les animations du H sous `root` sur l'horloge (voir `familyLoaderStartTime`). */
17
+ export declare function syncFamilyLoader(root: Element | null): void;
18
+ /**
19
+ * Vrai sous `FamilyLoaderRoot` : le document a son chargeur, `PagePending` et la coque s'y
20
+ * signalent au lieu de dessiner leur propre H.
21
+ */
22
+ export declare const FamilyLoaderRootContext: React.Context<boolean>;
23
+ /**
24
+ * Le H du chargement : la silhouette grise du H de la famille, toujours visible, dont les trois
25
+ * pièces se remplissent de leurs dégradés en une seule boucle de 2 s (gauche, barre, droite, H
26
+ * complet, fondu), calée sur l'horloge. CSS seul (`styles.css`), `transform` et `opacity`
27
+ * seulement. Mouvement réduit : le H plein respire doucement.
12
28
  *
13
- * Dans la coque (`AppShell`), un chargement de page passe par `PagePending`, qui affiche ce H à une
14
- * position fixe au centre de la zone de contenu. Ailleurs, `page` le centre dans l'écran. Dans un
15
- * bouton ou une ligne, `Spinner` reste la règle.
29
+ * Un chargement de page ne dessine pas ce H lui-même : c'est `PagePending`, qui passe par le H du
30
+ * document (`FamilyLoaderRoot`), au même pixel dans tous les états. Ce composant sert au H en
31
+ * ligne (une carte, un panneau) ; dans un bouton ou une ligne, `Spinner` reste la règle.
16
32
  */
17
33
  export declare function FamilyLoader({ size, label, page, className, }: {
18
34
  /** `sm` 24 px, `md` 40 px, `lg` 64 px. Par défaut `lg` en page, `md` sinon. */
19
35
  size?: keyof typeof SIZES;
20
36
  label?: string;
21
- /** Centré dans l'écran, pour un chargement hors de la coque. */
37
+ /** Chargement de toute la page : passe par le H du document, comme `PagePending`. */
22
38
  page?: boolean;
23
39
  className?: string;
24
40
  }): React.JSX.Element;
41
+ /**
42
+ * Le H du document : un seul élément, en position fixe sur la scène (la zone de contenu de la
43
+ * coque), toujours monté, que le CSS montre quand une page attend ou que la coque charge. Monté par
44
+ * `FamilyLoaderRoot`, ou, à défaut, par la coque ou par `PagePending` eux-mêmes.
45
+ */
46
+ export declare function FamilyLoaderHost(): React.JSX.Element;
47
+ /**
48
+ * Le chargeur du document, à poser une fois dans le `body` de la route racine, autour de l'app :
49
+ *
50
+ * ```tsx
51
+ * <body>
52
+ * <FamilyLoaderRoot>{children}</FamilyLoaderRoot>
53
+ * <Scripts />
54
+ * </body>
55
+ * ```
56
+ *
57
+ * Il rend le H dans le HTML du serveur, avant l'hydratation, à la même place et à la même phase
58
+ * que dans la coque et que dans le produit qu'on vient de quitter (ADR 0015).
59
+ */
60
+ export declare function FamilyLoaderRoot({ children }: {
61
+ children: React.ReactNode;
62
+ }): React.JSX.Element;
63
+ /**
64
+ * La place d'une page qui attend, hors de la coque : sa seule présence montre le H du document
65
+ * (CSS `:has`), dès le rendu serveur. Sans `FamilyLoaderRoot`, elle monte son propre H.
66
+ */
67
+ export declare function PageLoadingPlaceholder({ label, hasRoot, className, }: {
68
+ label: string;
69
+ hasRoot: boolean;
70
+ className?: string | undefined;
71
+ }): React.JSX.Element;
25
72
  export {};
@@ -1,30 +1,109 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- import { useEffect, useLayoutEffect, useRef } from "react";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { createContext, useContext, useEffect, useLayoutEffect, useRef } from "react";
3
3
  import { cn } from "./cn.js";
4
4
  const SIZES = { sm: 24, md: 40, lg: 64 };
5
+ /** Durée de la boucle du H (`styles.css`, `.family-loader [data-fill]`). */
6
+ export const FAMILY_LOADER_PERIOD_MS = 2000;
5
7
  /**
6
- * Le chargement d'une page : la silhouette grise du H de la famille, toujours visible, dont les
7
- * trois pièces se remplissent de leurs dégradés tricolores l'une après l'autre, puis se vident, en
8
- * boucle (1,6 s). CSS seul (`styles.css`) et seulement `transform` : l'animation tourne sur le
9
- * compositeur et reste fluide pendant qu'une page charge. Mouvement réduit : la silhouette reste,
10
- * le remplissage pulse doucement.
8
+ * Début des animations du H sur la ligne de temps du document, pour que la boucle soit à la même
9
+ * phase partout au même instant : `(performance.timeOrigin + now) % période`. Deux pages, deux
10
+ * produits, un rendu serveur et son hydratation tombent en phase sans se parler.
11
+ */
12
+ export function familyLoaderStartTime(timeOrigin, period = FAMILY_LOADER_PERIOD_MS) {
13
+ return -(((timeOrigin % period) + period) % period);
14
+ }
15
+ /** Phase de la boucle (0 à la période) à l'instant `epochMs` (ms depuis 1970). */
16
+ export function familyLoaderPhase(epochMs, period = FAMILY_LOADER_PERIOD_MS) {
17
+ return ((epochMs % period) + period) % period;
18
+ }
19
+ /** Cale toutes les animations du H sous `root` sur l'horloge (voir `familyLoaderStartTime`). */
20
+ export function syncFamilyLoader(root) {
21
+ if (!root || typeof root.getAnimations !== "function")
22
+ return;
23
+ const start = familyLoaderStartTime(performance.timeOrigin);
24
+ for (const animation of root.getAnimations({ subtree: true })) {
25
+ if (animation.startTime !== start)
26
+ animation.startTime = start;
27
+ }
28
+ }
29
+ /**
30
+ * Même calage, en ligne dans le HTML rendu par le serveur, juste après l'élément : il s'exécute
31
+ * avant l'hydratation, une fois la feuille de style chargée (un script attend les feuilles de
32
+ * style qui le précèdent). Une animation recréée (mouvement réduit basculé) se recale aussi.
33
+ */
34
+ const SYNC_SCRIPT = `(function(e){if(!e||!e.getAnimations)return;var p=${FAMILY_LOADER_PERIOD_MS};function s(){var t=-(((performance.timeOrigin%p)+p)%p);e.getAnimations({subtree:true}).forEach(function(a){if(a.startTime!==t)a.startTime=t})}s();e.addEventListener("animationstart",s)})(document.currentScript&&document.currentScript.previousElementSibling)`;
35
+ const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
36
+ /** Cale le H sur l'horloge au montage et à chaque animation recréée. */
37
+ function useClockSync(ref) {
38
+ useIsomorphicLayoutEffect(() => {
39
+ const element = ref.current;
40
+ if (!element)
41
+ return;
42
+ syncFamilyLoader(element);
43
+ const onStart = () => syncFamilyLoader(element);
44
+ element.addEventListener("animationstart", onStart);
45
+ return () => element.removeEventListener("animationstart", onStart);
46
+ }, [ref]);
47
+ }
48
+ function Mark() {
49
+ return ["left", "bar", "right"].map((piece) => (_jsx("span", { "data-piece": piece, "aria-hidden": true, children: _jsx("span", { "data-fill": true }) }, piece)));
50
+ }
51
+ /**
52
+ * Vrai sous `FamilyLoaderRoot` : le document a son chargeur, `PagePending` et la coque s'y
53
+ * signalent au lieu de dessiner leur propre H.
54
+ */
55
+ export const FamilyLoaderRootContext = createContext(false);
56
+ /**
57
+ * Le H du chargement : la silhouette grise du H de la famille, toujours visible, dont les trois
58
+ * pièces se remplissent de leurs dégradés en une seule boucle de 2 s (gauche, barre, droite, H
59
+ * complet, fondu), calée sur l'horloge. CSS seul (`styles.css`), `transform` et `opacity`
60
+ * seulement. Mouvement réduit : le H plein respire doucement.
11
61
  *
12
- * Dans la coque (`AppShell`), un chargement de page passe par `PagePending`, qui affiche ce H à une
13
- * position fixe au centre de la zone de contenu. Ailleurs, `page` le centre dans l'écran. Dans un
14
- * bouton ou une ligne, `Spinner` reste la règle.
62
+ * Un chargement de page ne dessine pas ce H lui-même : c'est `PagePending`, qui passe par le H du
63
+ * document (`FamilyLoaderRoot`), au même pixel dans tous les états. Ce composant sert au H en
64
+ * ligne (une carte, un panneau) ; dans un bouton ou une ligne, `Spinner` reste la règle.
15
65
  */
16
66
  export function FamilyLoader({ size, label = "Chargement…", page = false, className, }) {
17
- const px = SIZES[size ?? (page ? "lg" : "md")];
67
+ const hasRoot = useContext(FamilyLoaderRootContext);
68
+ if (page)
69
+ return _jsx(PageLoadingPlaceholder, { label: label, hasRoot: hasRoot, className: className });
70
+ return _jsx(FamilyMark, { px: SIZES[size ?? "md"], label: label, className: className });
71
+ }
72
+ function FamilyMark({ px, label, className, }) {
18
73
  const ref = useRef(null);
19
- // Tous les H de la page battent en phase, calés sur l'horloge du document : passer du calque de
20
- // navigation de la coque à celui de la page, ou du rendu serveur à l'hydratation, ne saute pas.
21
- useIsomorphicLayoutEffect(() => {
22
- for (const animation of ref.current?.getAnimations?.({ subtree: true }) ?? [])
23
- animation.startTime = 0;
24
- }, []);
25
- const loader = (_jsx("span", { ref: ref, role: "status", "aria-label": label, "data-slot": "family-loader", className: cn("family-loader", !page && className), style: { ["--family-loader-size"]: `${px}px` }, children: ["left", "right", "bar"].map((piece) => (_jsx("span", { "data-piece": piece, "aria-hidden": true, children: _jsx("span", { "data-fill": true }) }, piece))) }));
26
- if (!page)
27
- return loader;
28
- return (_jsx("div", { "data-slot": "family-loader-page", className: cn("grid min-h-dvh w-full place-items-center", className), children: loader }));
74
+ useClockSync(ref);
75
+ return (_jsx("span", { ref: ref, ...(label ? { role: "status", "aria-label": label } : { "aria-hidden": true }), "data-slot": "family-loader", className: cn("family-loader", className), style: { ["--family-loader-size"]: `${px}px` }, children: _jsx(Mark, {}) }));
76
+ }
77
+ /**
78
+ * Le H du document : un seul élément, en position fixe sur la scène (la zone de contenu de la
79
+ * coque), toujours monté, que le CSS montre quand une page attend ou que la coque charge. Monté par
80
+ * `FamilyLoaderRoot`, ou, à défaut, par la coque ou par `PagePending` eux-mêmes.
81
+ */
82
+ export function FamilyLoaderHost() {
83
+ const ref = useRef(null);
84
+ useClockSync(ref);
85
+ 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 } })] }));
86
+ }
87
+ /**
88
+ * Le chargeur du document, à poser une fois dans le `body` de la route racine, autour de l'app :
89
+ *
90
+ * ```tsx
91
+ * <body>
92
+ * <FamilyLoaderRoot>{children}</FamilyLoaderRoot>
93
+ * <Scripts />
94
+ * </body>
95
+ * ```
96
+ *
97
+ * Il rend le H dans le HTML du serveur, avant l'hydratation, à la même place et à la même phase
98
+ * que dans la coque et que dans le produit qu'on vient de quitter (ADR 0015).
99
+ */
100
+ export function FamilyLoaderRoot({ children }) {
101
+ return (_jsxs(FamilyLoaderRootContext, { value: true, children: [children, _jsx(FamilyLoaderHost, {})] }));
102
+ }
103
+ /**
104
+ * La place d'une page qui attend, hors de la coque : sa seule présence montre le H du document
105
+ * (CSS `:has`), dès le rendu serveur. Sans `FamilyLoaderRoot`, elle monte son propre H.
106
+ */
107
+ export function PageLoadingPlaceholder({ label, hasRoot, className, }) {
108
+ return (_jsx("div", { "data-slot": "page-pending", role: "status", "aria-label": label, className: cn("min-h-dvh w-full", className), children: hasRoot ? null : _jsx(FamilyLoaderHost, {}) }));
29
109
  }
30
- const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
@@ -7,24 +7,32 @@ export declare const PAGE_PENDING_MS = 50;
7
7
  export declare const PAGE_PENDING_MIN_MS = 300;
8
8
  /**
9
9
  * Calque de chargement de la zone de contenu : sous l'en-tête de la coque (56 px), sur toute la
10
- * largeur, opaque. Le H y est centré dans la hauteur visible et y reste (`sticky`) : même place
11
- * pour le calque de navigation de la coque et pour `PagePending`, donc aucun saut de l'un à l'autre.
10
+ * largeur, opaque. Il cache l'ancienne page ; le H, lui, est celui du document
11
+ * (`FamilyLoaderRoot`), en position fixe, au même pixel dans tous les états.
12
12
  */
13
13
  export declare const CONTENT_LOADING_LAYER = "absolute inset-x-0 top-14 bottom-0 z-20 bg-background";
14
- /** Le H centré dans la zone de contenu (calque de `CONTENT_LOADING_LAYER`). */
14
+ /**
15
+ * @deprecated Le H d'un chargement de page est celui du document (`FamilyLoaderRoot`, ADR 0015) :
16
+ * la scène ne garde qu'un texte pour les lecteurs d'écran.
17
+ */
15
18
  export declare function ContentLoadingStage({ label }: {
16
19
  label: string;
17
20
  }): React.JSX.Element;
18
21
  /**
19
- * Fourni par `AppShell` autour de la zone de contenu : `PagePending` s'y signale, pour que la coque
20
- * n'affiche pas son propre calque de navigation par-dessus.
22
+ * Fourni par `AppShell` autour de la zone de contenu : `PagePending` s'y signale, la coque couvre
23
+ * alors la zone de contenu et montre le H du document.
21
24
  */
22
25
  export declare const ContentLoadingContext: React.Context<(() => () => void) | null>;
23
26
  /**
24
- * Le chargement d'une page. Dans la coque (`AppShell`) : le H centré sur un calque opaque de la
25
- * zone de contenu, la coque restant en place, dès le rendu serveur. Hors de la coque : le H centré
26
- * dans l'écran. Composant de `pendingComponent` (TanStack Router, `routerPendingDefaults`), repli
27
- * de la frontière `Suspense` de la coque, et état de chargement d'une page qui lit ses données.
27
+ * Le chargement d'une page : le H de la famille, au centre de la zone de contenu de la coque, au
28
+ * même pixel et à la même phase dans tous les états (calque de navigation de la coque, attente
29
+ * d'une route, frontière `Suspense`, rendu serveur, page d'entrée, produit d'à côté). Ce composant
30
+ * ne dessine pas de H : il marque la place de la page (`data-slot="page-pending"`) et le H du
31
+ * document (`FamilyLoaderRoot`) s'affiche, dès le HTML du serveur. Dans la coque, la coque s'en
32
+ * charge ; hors de la coque, le cadre de la coque est dessiné autour du H.
33
+ *
34
+ * Composant de `pendingComponent` (TanStack Router, `routerPendingDefaults`), repli de la frontière
35
+ * `Suspense` de la coque, et état de chargement d'une page qui lit ses données.
28
36
  */
29
37
  export declare function PagePending({ label }: {
30
38
  label?: string;
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { createContext, useContext, useEffect, useLayoutEffect, useState } from "react";
3
- import { FamilyLoader } from "./family-loader.js";
3
+ import { FamilyLoaderRootContext, PageLoadingPlaceholder } from "./family-loader.js";
4
4
  /**
5
5
  * Délai avant de couvrir l'ancienne page pendant une navigation : une navigation préchargée
6
6
  * (survol, `defaultPreload: "intent"`) se termine avant et ne montre rien.
@@ -10,32 +10,40 @@ export const PAGE_PENDING_MS = 50;
10
10
  export const PAGE_PENDING_MIN_MS = 300;
11
11
  /**
12
12
  * Calque de chargement de la zone de contenu : sous l'en-tête de la coque (56 px), sur toute la
13
- * largeur, opaque. Le H y est centré dans la hauteur visible et y reste (`sticky`) : même place
14
- * pour le calque de navigation de la coque et pour `PagePending`, donc aucun saut de l'un à l'autre.
13
+ * largeur, opaque. Il cache l'ancienne page ; le H, lui, est celui du document
14
+ * (`FamilyLoaderRoot`), en position fixe, au même pixel dans tous les états.
15
15
  */
16
16
  export const CONTENT_LOADING_LAYER = "absolute inset-x-0 top-14 bottom-0 z-20 bg-background";
17
- const CONTENT_LOADING_STAGE = "sticky top-14 grid h-[calc(100dvh-3.5rem)] place-items-center lg:h-[calc(100dvh-1rem-3.5rem)]";
18
- /** Le H centré dans la zone de contenu (calque de `CONTENT_LOADING_LAYER`). */
17
+ /**
18
+ * @deprecated Le H d'un chargement de page est celui du document (`FamilyLoaderRoot`, ADR 0015) :
19
+ * la scène ne garde qu'un texte pour les lecteurs d'écran.
20
+ */
19
21
  export function ContentLoadingStage({ label }) {
20
- return (_jsx("div", { className: CONTENT_LOADING_STAGE, children: _jsx(FamilyLoader, { size: "lg", label: label }) }));
22
+ return (_jsx("span", { role: "status", className: "sr-only", children: label }));
21
23
  }
22
24
  /**
23
- * Fourni par `AppShell` autour de la zone de contenu : `PagePending` s'y signale, pour que la coque
24
- * n'affiche pas son propre calque de navigation par-dessus.
25
+ * Fourni par `AppShell` autour de la zone de contenu : `PagePending` s'y signale, la coque couvre
26
+ * alors la zone de contenu et montre le H du document.
25
27
  */
26
28
  export const ContentLoadingContext = createContext(null);
27
29
  /**
28
- * Le chargement d'une page. Dans la coque (`AppShell`) : le H centré sur un calque opaque de la
29
- * zone de contenu, la coque restant en place, dès le rendu serveur. Hors de la coque : le H centré
30
- * dans l'écran. Composant de `pendingComponent` (TanStack Router, `routerPendingDefaults`), repli
31
- * de la frontière `Suspense` de la coque, et état de chargement d'une page qui lit ses données.
30
+ * Le chargement d'une page : le H de la famille, au centre de la zone de contenu de la coque, au
31
+ * même pixel et à la même phase dans tous les états (calque de navigation de la coque, attente
32
+ * d'une route, frontière `Suspense`, rendu serveur, page d'entrée, produit d'à côté). Ce composant
33
+ * ne dessine pas de H : il marque la place de la page (`data-slot="page-pending"`) et le H du
34
+ * document (`FamilyLoaderRoot`) s'affiche, dès le HTML du serveur. Dans la coque, la coque s'en
35
+ * charge ; hors de la coque, le cadre de la coque est dessiné autour du H.
36
+ *
37
+ * Composant de `pendingComponent` (TanStack Router, `routerPendingDefaults`), repli de la frontière
38
+ * `Suspense` de la coque, et état de chargement d'une page qui lit ses données.
32
39
  */
33
40
  export function PagePending({ label = "Chargement…" }) {
34
41
  const register = useContext(ContentLoadingContext);
42
+ const hasRoot = useContext(FamilyLoaderRootContext);
35
43
  useIsomorphicLayoutEffect(() => register?.(), [register]);
36
44
  if (!register)
37
- return _jsx(FamilyLoader, { page: true, label: label });
38
- return (_jsx("div", { "data-slot": "page-pending", className: "min-h-[calc(100dvh-10rem)]", children: _jsx("div", { className: CONTENT_LOADING_LAYER, children: _jsx(ContentLoadingStage, { label: label }) }) }));
45
+ return _jsx(PageLoadingPlaceholder, { label: label, hasRoot: hasRoot });
46
+ return (_jsx("div", { "data-slot": "page-pending", role: "status", "aria-label": label, className: "min-h-[calc(100dvh-10rem)]" }));
39
47
  }
40
48
  const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
41
49
  /**
@@ -1,9 +1,10 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useRef, useState } from "react";
2
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
3
3
  import { ProductBadge } from "./product-badge.js";
4
4
  import { productRailEntries } from "./products.js";
5
5
  import { ShellPlacementContext, useLeaveOnClick } from "./shell-placement.js";
6
6
  import { cn } from "./cn.js";
7
+ const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
7
8
  /** Délai d'intention avant d'élargir la barre au survol : un passage de souris ne l'ouvre pas. */
8
9
  export const PRODUCT_RAIL_OPEN_DELAY = 150;
9
10
  function ProductLink({ entry }) {
@@ -28,7 +29,9 @@ export function ProductRail({ current, organization, accountHref, catalog, onExp
28
29
  const [focused, setFocused] = useState(false);
29
30
  const [menuOpen, setMenuOpen] = useState(false);
30
31
  const expanded = hovered || focused || menuOpen;
31
- useEffect(() => {
32
+ // Avant la peinture : la barre latérale se plie dans la même image que la barre s'élargit, les
33
+ // deux transitions partent ensemble et la page ne bouge pas.
34
+ useIsomorphicLayoutEffect(() => {
32
35
  onExpandedChange?.(expanded);
33
36
  }, [expanded, onExpandedChange]);
34
37
  const ref = useRef(null);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hgdc/ui",
3
- "version": "0.14.2",
3
+ "version": "0.15.0",
4
4
  "description": "Interface de la H family : 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
@@ -477,13 +477,12 @@
477
477
  opacity: 0.35;
478
478
  }
479
479
  }
480
-
481
- /* `FamilyLoader` : le H tricolore se construit (jambage gauche, barre, jambage droit), tient,
482
- s'efface, recommence. Les pièces sont coupées par `clip-path` (la forme ne change pas), un
483
- léger chevauchement lie les trois gestes. Visible après 200 ms seulement : pas de flash. */
484
- /* Chargeur de la famille (`FamilyLoader`) : silhouette grise du H, pièces remplies de leurs
485
- dégradés. Géométrie du H sur 256 (FAMILY_MARK_SVG). Seul `transform` s'anime : compositeur,
486
- fluide pendant qu'une page charge. */
480
+ /* Chargeur de la famille (`FamilyLoader`, ADR 0015) : silhouette grise du H, pièces remplies de
481
+ leurs dégradés. Géométrie du H sur 256 (FAMILY_MARK_SVG). Une seule boucle de 2 s pour tout le H,
482
+ calée sur l'horloge (`syncFamilyLoader`) : le jambage gauche monte, la barre le prolonge vers la
483
+ droite, le jambage droit s'ouvre depuis la barre vers le haut et le bas ; le H complet tient, se
484
+ vide d'un fondu, la silhouette reste un instant, et ça recommence. Seuls `transform` et `opacity`
485
+ s'animent, sur des couches internes : compositeur, fluide pendant qu'une page charge. */
487
486
  .family-loader {
488
487
  --fl: var(--family-loader-size, 40px);
489
488
  --fl-ghost: color-mix(in oklab, var(--foreground) var(--fl-ghost-mix, 11%), var(--background));
@@ -527,73 +526,216 @@
527
526
  .family-loader [data-fill] {
528
527
  position: absolute;
529
528
  inset: 0;
530
- will-change: transform;
529
+ will-change: transform, opacity;
530
+ /* Une seule durée, aucun délai : les trois pièces sont trois pistes de la même boucle. */
531
+ animation-duration: 2s;
532
+ animation-timing-function: linear;
533
+ animation-iteration-count: infinite;
534
+ animation-fill-mode: both;
531
535
  }
532
536
  .family-loader [data-piece="left"] [data-fill] {
533
537
  background: linear-gradient(to bottom, #2c78fd, #7343fe);
534
- animation: family-loader-rise 1.6s var(--ease-in-out) infinite both;
535
- }
536
- .family-loader [data-piece="right"] [data-fill] {
537
- background: linear-gradient(to bottom, #fe5292, #ff9a46);
538
- animation: family-loader-rise 1.6s var(--ease-in-out) 0.4s infinite both;
538
+ animation-name: family-loader-left;
539
539
  }
540
540
  .family-loader [data-piece="bar"] [data-fill] {
541
541
  background: linear-gradient(to right, #8145ff, #fe5292);
542
- animation: family-loader-sweep 1.6s var(--ease-in-out) 0.2s infinite both;
542
+ animation-name: family-loader-bar;
543
543
  }
544
- /* Les jambes se remplissent par le bas, tiennent, puis la couleur continue de monter et laisse
545
- la silhouette : le cycle suivant repart sans temps mort. */
546
- @keyframes family-loader-rise {
544
+ .family-loader [data-piece="right"] [data-fill] {
545
+ background: linear-gradient(to bottom, #fe5292, #ff9a46);
546
+ /* Le jambage droit s'ouvre depuis la barre (son milieu : 128,5 sur 256, soit 50,24 % du jambage). */
547
+ transform-origin: 50% 50.24%;
548
+ animation-name: family-loader-right;
549
+ }
550
+ /* La boucle, sur 2 s : gauche 0-500 ms, barre 300-800, droite 600-1100, H complet 1100-1500,
551
+ fondu 1500-1750, silhouette seule 1750-2000. Chaque remplissage reprend sa place de départ
552
+ pendant qu'il est invisible (opacité nulle), d'un seul pas. */
553
+ @keyframes family-loader-left {
547
554
  0% {
548
- transform: translateY(101%);
555
+ transform: translateY(100%);
556
+ opacity: 1;
557
+ animation-timing-function: var(--ease-in-out);
549
558
  }
550
- 38%,
551
- 62% {
559
+ 25%,
560
+ 75% {
552
561
  transform: translateY(0);
562
+ opacity: 1;
563
+ animation-timing-function: var(--ease-out);
564
+ }
565
+ 87.5% {
566
+ transform: translateY(0);
567
+ opacity: 0;
568
+ animation-timing-function: steps(1, end);
553
569
  }
554
570
  100% {
555
- transform: translateY(-101%);
571
+ transform: translateY(100%);
572
+ opacity: 1;
556
573
  }
557
574
  }
558
- /* La barre, de gauche à droite. */
559
- @keyframes family-loader-sweep {
560
- 0% {
561
- transform: translateX(-101%);
575
+ @keyframes family-loader-bar {
576
+ 0%,
577
+ 15% {
578
+ transform: translateX(-100%);
579
+ opacity: 1;
580
+ animation-timing-function: var(--ease-in-out);
581
+ }
582
+ 40%,
583
+ 75% {
584
+ transform: translateX(0);
585
+ opacity: 1;
586
+ animation-timing-function: var(--ease-out);
562
587
  }
563
- 38%,
564
- 62% {
588
+ 87.5% {
565
589
  transform: translateX(0);
590
+ opacity: 0;
591
+ animation-timing-function: steps(1, end);
592
+ }
593
+ 100% {
594
+ transform: translateX(-100%);
595
+ opacity: 1;
596
+ }
597
+ }
598
+ @keyframes family-loader-right {
599
+ 0%,
600
+ 30% {
601
+ transform: scaleY(0);
602
+ opacity: 1;
603
+ animation-timing-function: var(--ease-in-out);
604
+ }
605
+ 55%,
606
+ 75% {
607
+ transform: scaleY(1);
608
+ opacity: 1;
609
+ animation-timing-function: var(--ease-out);
610
+ }
611
+ 87.5% {
612
+ transform: scaleY(1);
613
+ opacity: 0;
614
+ animation-timing-function: steps(1, end);
566
615
  }
567
616
  100% {
568
- transform: translateX(101%);
617
+ transform: scaleY(0);
618
+ opacity: 1;
569
619
  }
570
620
  }
571
- @keyframes family-loader-glow {
621
+ /* Mouvement réduit : le H est plein et respire doucement, sur la même boucle. */
622
+ @keyframes family-loader-breathe {
572
623
  0%,
573
624
  100% {
574
- opacity: 0.25;
625
+ opacity: 0.4;
575
626
  }
576
627
  50% {
577
628
  opacity: 1;
578
629
  }
579
630
  }
580
631
  @media (prefers-reduced-motion: reduce) {
581
- /* La silhouette reste, le remplissage pulse en place, sans mouvement. */
582
632
  .family-loader [data-piece] [data-fill] {
583
633
  transform: none;
584
- animation: family-loader-glow 1.6s ease-in-out infinite both;
634
+ animation-name: family-loader-breathe;
635
+ animation-timing-function: ease-in-out;
636
+ }
637
+ }
638
+
639
+ /* Scène du chargement (ADR 0015) : la zone de contenu de la coque, calculée depuis l'écran, pour
640
+ que le H soit au même pixel dans la coque, avant elle (rendu serveur, page d'entrée) et d'un
641
+ produit à l'autre. Téléphone : sous l'en-tête de 56 px. Ordinateur : barre des produits (56 px),
642
+ barre latérale (256 px, 64 en rail), marge de 8 px, en-tête de 56 px. */
643
+ :root {
644
+ --hgdc-rail-w: 0px;
645
+ --hgdc-content-left: 0px;
646
+ --hgdc-stage-top: calc(env(safe-area-inset-top, 0px) + 3.5rem);
647
+ --hgdc-stage-right: 0px;
648
+ --hgdc-stage-bottom: 0px;
649
+ --hgdc-stage-left: 0px;
650
+ }
651
+ @media (width >= 64rem) {
652
+ :root {
653
+ --hgdc-rail-w: 3.5rem;
654
+ --hgdc-sidebar-w: 16rem;
655
+ --hgdc-content-left: calc(var(--hgdc-rail-w) + var(--hgdc-sidebar-w));
656
+ --hgdc-stage-top: 4rem;
657
+ --hgdc-stage-right: 0.5rem;
658
+ --hgdc-stage-bottom: 0.5rem;
659
+ --hgdc-stage-left: var(--hgdc-content-left);
660
+ }
661
+ :root[data-sidebar="rail"] {
662
+ --hgdc-sidebar-w: 4rem;
663
+ }
664
+ :root:has([data-slot="app-shell"]:not([data-product-rail])) {
665
+ --hgdc-rail-w: 0px;
585
666
  }
586
667
  }
587
- /* Calque de chargement de la zone de contenu (`AppShell`) : opaque, au-dessus de l'ancienne page,
588
- le H à une position fixe. Apparition courte, disparition plus courte encore. */
668
+
669
+ /* Le chargeur du document (`FamilyLoaderRoot`) : un seul H par page, en position fixe sur la scène.
670
+ Visible quand une page attend (`PagePending`) hors de la coque, quand la coque charge
671
+ (`data-loading`), ou dès le rendu serveur quand une page attend dans une coque pas encore
672
+ hydratée. Masqué avec 80 ms de retenue : un `PagePending` qui laisse la place à un autre ne fait
673
+ pas clignoter le H. */
674
+ [data-slot="family-loader-root"] {
675
+ position: fixed;
676
+ inset: 0;
677
+ z-index: 25;
678
+ pointer-events: none;
679
+ opacity: 0;
680
+ visibility: hidden;
681
+ transition:
682
+ opacity 120ms var(--ease-out) 80ms,
683
+ visibility 0s linear 200ms;
684
+ }
685
+ :root:has([data-slot="page-pending"]):not(:has([data-slot="app-shell"])) [data-slot="family-loader-root"],
686
+ :root:has([data-slot="app-shell"][data-loading]) [data-slot="family-loader-root"],
687
+ :root:has([data-slot="app-shell"]:not([data-hydrated]) [data-slot="page-pending"]) [data-slot="family-loader-root"] {
688
+ opacity: 1;
689
+ visibility: visible;
690
+ transition:
691
+ opacity 150ms var(--ease-out),
692
+ visibility 0s;
693
+ }
694
+ [data-slot="family-loader-root"] > [data-stage] {
695
+ position: fixed;
696
+ top: var(--hgdc-stage-top);
697
+ right: var(--hgdc-stage-right);
698
+ bottom: var(--hgdc-stage-bottom);
699
+ left: var(--hgdc-stage-left);
700
+ display: grid;
701
+ place-items: center;
702
+ }
703
+ /* Hors de la coque, le cadre de la coque : la page qui arrive a déjà sa forme, le H sa place. */
704
+ [data-slot="family-loader-root"] > [data-frame] {
705
+ position: fixed;
706
+ inset: 0;
707
+ display: none;
708
+ background: var(--background);
709
+ }
710
+ :root:not(:has([data-slot="app-shell"])) [data-slot="family-loader-root"] > [data-frame] {
711
+ display: block;
712
+ }
713
+ @media (width >= 64rem) {
714
+ [data-slot="family-loader-root"] > [data-frame] {
715
+ background:
716
+ linear-gradient(var(--shell-rail), var(--shell-rail)) left / var(--hgdc-rail-w) 100% no-repeat,
717
+ var(--shell);
718
+ }
719
+ [data-slot="family-loader-root"] > [data-frame]::after {
720
+ content: "";
721
+ position: absolute;
722
+ inset: 0.5rem 0.5rem 0.5rem var(--hgdc-content-left);
723
+ border-radius: var(--radius-xl);
724
+ background: var(--background);
725
+ }
726
+ }
727
+
728
+ /* Calque de la zone de contenu (`AppShell`) : opaque, au-dessus de l'ancienne page, sous le H du
729
+ document. Mêmes conditions et même rythme que le H : ils apparaissent et s'effacent ensemble. */
589
730
  [data-slot="app-shell-loading"] {
590
731
  opacity: 0;
591
732
  visibility: hidden;
592
733
  transition:
593
- opacity 100ms var(--ease-out),
594
- visibility 0s linear 100ms;
734
+ opacity 120ms var(--ease-out) 80ms,
735
+ visibility 0s linear 200ms;
595
736
  }
596
- [data-slot="app-shell-loading"][data-visible] {
737
+ [data-slot="app-shell"][data-loading] [data-slot="app-shell-loading"],
738
+ [data-slot="app-shell"]:not([data-hydrated]):has([data-slot="page-pending"]) [data-slot="app-shell-loading"] {
597
739
  opacity: 1;
598
740
  visibility: visible;
599
741
  transition: