@hgdc/ui 0.25.0 → 0.27.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
@@ -67,14 +67,19 @@ Une seule règle dans les sept produits, posée par la coque :
67
67
 
68
68
  ## Identité H Collection
69
69
 
70
- | Import | Rôle |
71
- | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
72
- | `product-badge` | `<ProductBadge product="host" className="size-8" />` (carré blanc arrondi, la même forme partout), `<FamilyMark />` (le H « Double », clair et sombre selon le thème), `PRODUCT_GRADIENT` |
73
- | `@hgdc/ui/family/<mot>.svg` | Pictogrammes des produits (`account`, `host`, `mail`, `portal`, `status`, `analytics`, `docs`), originaux du site ; `family/tile/<mot>.svg` (carré blanc arrondi) ; `family/app/<mot>/` : favicon.svg, favicon.ico, apple-touch-icon.png, icon-192/512.png, icon-maskable-512.png, à copier dans `public/` |
74
- | `@hgdc/ui/family/hcollection-icon*.svg` | Le H « Double », marque de la H Collection, sur fond clair (`hcollection-icon.svg`) ou sombre (`-dark`), jamais recoloré |
75
- | `styles.css` | `--product-<mot>-from` / `--product-<mot>-to` (dégradé de chaque icône sur fond clair), `--glow-blue`, `--glow-violet`, `--glow-pink`, `--glow-orange` |
70
+ | Import | Rôle |
71
+ | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
72
+ | `product-badge` | `<ProductBadge product="host" className="size-8" />` (carré blanc arrondi, la même forme partout), `<FamilyMark />` (le H « Double », clair et sombre selon le thème), `PRODUCT_GRADIENT` |
73
+ | `@hgdc/ui/family/<mot>.svg` | Pictogrammes des produits (`account`, `host`, `mail`, `portal`, `status`, `analytics`, `docs`), originaux du site ; `family/tile/<mot>.svg` (carré blanc arrondi) ; `family/app/<mot>/` : favicon.svg, favicon.ico, apple-touch-icon.png, icon-192/512.png, icon-maskable-512.png, posés dans `public/` au build par `hgdc-brand sync <mot>` (jamais versionnés) |
74
+ | `@hgdc/ui/family/hcollection-icon*.svg` | Le H « Double », marque de la H Collection, sur fond clair (`hcollection-icon.svg`) ou sombre (`-dark`), jamais recoloré |
75
+ | `styles.css` | `--product-<mot>-from` / `--product-<mot>-to` (dégradé de chaque icône sur fond clair), `--glow-blue`, `--glow-violet`, `--glow-pink`, `--glow-orange` |
76
76
 
77
- Couleurs pleines ; pour éclaircir, mélanger au blanc `color-mix(in oklab, …)`, jamais `in oklch`. Une icône de produit se pose toujours sur son fond blanc, en clair comme en sombre, sans bordure ni ombre : carré arrondi 22 % partout, pictogramme à 12 % de marge (barre, menu, « Propulsé par », e-mails, icône d'app), plein cadre pour apple-touch et maskable. Le favicon est sans fond et suit le thème du système (ADR 0017). Le H reste sans fond. Les originaux vivent dans le dépôt du site hgdc.ch : les recopier dans `packages/ui/family/`, puis `bun scripts/family.ts && bun run format` (SVG embarqués et PNG des e-mails).
77
+ Couleurs pleines ; pour éclaircir, mélanger au blanc `color-mix(in oklab, …)`, jamais `in oklch`. Une icône de produit se pose toujours sur son fond blanc, en clair comme en sombre, sans bordure ni ombre : carré arrondi 22 % partout, pictogramme à 12 % de marge (barre, menu, « Propulsé par », e-mails, icône d'app), plein cadre pour apple-touch et maskable. Le favicon est sans fond et suit le thème du système (ADR 0017). Le H reste sans fond. Les originaux vivent dans le dépôt du site hgdc.ch : les recopier dans `packages/ui/family/`, puis `bun scripts/family.ts && bun run format` (SVG embarqués, PNG des e-mails, icônes d'application). Geste complet : `docs/runbooks/changer-une-icone.md`.
78
+
79
+ **`hgdc-brand`** (bin de ce paquet) pose les icônes du kit au build, rien n'est recopié à la main :
80
+
81
+ - produit : `"build": "hgdc-brand sync mail && vite build"` (et en tête de `dev`), les six fichiers de `family/app/<mot>/` dans `public/`, listés dans le `.gitignore` ; `--email-as <fichier>` y ajoute l'icône d'e-mail du produit sous un ancien nom ;
82
+ - site hgdc.ch : `hgdc-brand emails public/email`, les `<mot>.png` servis à `EMAIL_ICON_BASE`.
78
83
 
79
84
  Sur ordinateur, la coque ouvre sur la barre des produits (ADR 0011) : organisation en haut, pastilles des produits ouverts, Account en bas, chacun par son entrée `/auth/hgdc?next=/`, le produit courant en teinte. Au survol (150 ms d'intention) ou au focus clavier, elle s'élargit par-dessus la page et écrit les noms. `productRail={false}` la coupe ; `accountHref` pointe vers un Account local. La barre latérale porte la marque du produit et se réduit en rail (`Ctrl+B`, mémorisé) : poser `sidebarInitScript` dans le `<head>`. En rail, la pastille du produit devient le bouton « Déplier » au survol. Le kit ne connaît ni routeur ni authentification : liens par `render={<Link />}`, actions par callbacks. Rôles d'organisation : `owner`, `admin`, `member` (Propriétaire, Admin, Membre).
80
85
 
@@ -89,7 +94,7 @@ Sur ordinateur, la coque ouvre sur la barre des produits (ADR 0011) : organisati
89
94
  | `stat` | `<StatGrid><StatTile label="Erreurs" value="12" delta={{ value: "8 %", direction: "up", good: "down" }} /></StatGrid>` |
90
95
  | `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
96
  | `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" />` |
97
+ | `uptime-bar` | `<UptimeBar days={[{ date, status: "operational", uptime: "100 %", detail: { levels, incidents } }]} summary="99,95 % sur 90 jours" />` |
93
98
  | `timeline` | `<Timeline entries={[{ id, title: "Résolu", time, status: "operational" }]} />` |
94
99
  | `relative-time` | `<RelativeTime date={deployment._creationTime} />` (« il y a 3 min », date exacte au survol) |
95
100
  | `copy-button` | `<Snippet value="bun add @hgdc/ui" prompt />`, `<CopyButton value={url} />`, `<CopyButton value={all} label="Tout copier" showLabel />` |
@@ -172,7 +177,7 @@ const { html, text } = await renderEmail(
172
177
  | `EmailDetails` | Liste clé / valeur |
173
178
  | `renderEmail` | `{ html, text }` : HTML pour clients mail, texte tiré du même rendu |
174
179
 
175
- L'accent vient du catalogue (`accent` de `@hgdc/ui/products`), les icônes de `https://hgdc.ch/email/<mot>.png` (fichiers dans `@hgdc/ui/email-icons/`, tirés des pastilles par `bun scripts/family.ts`). Un exemple par type : `scripts/email-showcase.tsx`.
180
+ L'accent vient du catalogue (`accent` de `@hgdc/ui/products`), les icônes de `emailIconUrl(mot)` (aussi dans `@hgdc/ui/email-icon`, sans React Email, pour un HTML assemblé à la main) : `https://hgdc.ch/email/<mot>.png?v=<empreinte>` (fichiers dans `@hgdc/ui/email-icons/`, tirés des pastilles par `bun scripts/family.ts` ; l'empreinte `EMAIL_ICON_VERSION` change avec l'image et fait recharger les clients mail qui l'ont en cache). Un exemple par type : `scripts/email-showcase.tsx`.
176
181
 
177
182
  ## Règles de la DA
178
183
 
@@ -30,7 +30,7 @@ export type AccountUser = {
30
30
  * (`productMenuEntries`, `@hgdc/ui/products`), avec `product` coché. `products` remplace cette
31
31
  * liste ; `products={[]}` la retire.
32
32
  */
33
- export declare function AccountMenu({ user, onProfile, onSecurity, onShortcuts, onSignOut, products: productsProp, product: currentProduct, accountHref, children, variant, }: {
33
+ export declare function AccountMenu({ user, onProfile, onSecurity, onShortcuts, onSignOut, products: productsProp, product: currentProduct, accountHref, statusPage, children, variant, }: {
34
34
  user: AccountUser;
35
35
  onProfile?: () => void;
36
36
  onSecurity?: () => void;
@@ -43,6 +43,12 @@ export declare function AccountMenu({ user, onProfile, onSecurity, onShortcuts,
43
43
  product?: string;
44
44
  /** Adresse d'Account dans la liste par défaut (un Account local en développement). */
45
45
  accountHref?: string;
46
+ /**
47
+ * Page de statut du produit, entrée « État du service » : celle d'`AppShell` par défaut (son
48
+ * `statusPage`, sinon `https://status.hgdc.ch/<produit>`), `false` pour la retirer. Hors de la
49
+ * coque, sans adresse donnée, pas d'entrée.
50
+ */
51
+ statusPage?: string | false;
46
52
  /** Entrées propres au produit (« Administration »), après le profil. */
47
53
  children?: ReactNode;
48
54
  variant?: "row" | "avatar";
@@ -1,4 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import Activity01Icon from "@hugeicons/core-free-icons/Activity01Icon";
2
3
  import DashboardSquare01Icon from "@hugeicons/core-free-icons/DashboardSquare01Icon";
3
4
  import KeyboardIcon from "@hugeicons/core-free-icons/KeyboardIcon";
4
5
  import Logout01Icon from "@hugeicons/core-free-icons/Logout01Icon";
@@ -13,7 +14,7 @@ import { initials } from "./initials.js";
13
14
  import { MENU_LINK_ITEM } from "./org-switcher.js";
14
15
  import { ProductBadge } from "./product-badge.js";
15
16
  import { productMenuEntries } from "./products.js";
16
- import { useLeaveOnClick, useProductRailShown } from "./shell-placement.js";
17
+ import { useLeaveOnClick, useProductRailShown, useStatusPage } from "./shell-placement.js";
17
18
  import { ThemeSwitch } from "./theme-switch.js";
18
19
  /**
19
20
  * Le menu du compte, en bas de la barre latérale : profil, sécurité, choix du thème en trois icônes, raccourcis, autres
@@ -26,11 +27,13 @@ import { ThemeSwitch } from "./theme-switch.js";
26
27
  * (`productMenuEntries`, `@hgdc/ui/products`), avec `product` coché. `products` remplace cette
27
28
  * liste ; `products={[]}` la retire.
28
29
  */
29
- export function AccountMenu({ user, onProfile, onSecurity, onShortcuts, onSignOut, products: productsProp, product: currentProduct, accountHref, children, variant = "row", }) {
30
+ export function AccountMenu({ user, onProfile, onSecurity, onShortcuts, onSignOut, products: productsProp, product: currentProduct, accountHref, statusPage, children, variant = "row", }) {
30
31
  const products = productsProp ?? productMenuEntries({ current: currentProduct, accountHref });
31
32
  const railShown = useProductRailShown();
33
+ const shellStatusPage = useStatusPage();
34
+ const statusPageUrl = statusPage === false ? null : (statusPage ?? shellStatusPage);
32
35
  const leaveOnClick = useLeaveOnClick();
33
- return (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { render: variant === "row" ? (_jsx("button", { type: "button", "aria-label": `Compte de ${user.name}`, className: "flex w-full min-w-0 items-center gap-2.5 rounded-xl p-1.5 text-left outline-none transition-colors duration-(--duration-fast) ease-out hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/50 aria-expanded:bg-muted lg:hover:bg-shell-hover lg:aria-expanded:bg-shell-hover lg:rail:justify-center" })) : (_jsx("button", { type: "button", "aria-label": `Compte de ${user.name}`, className: "touch-target grid size-9 place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50" })), children: [_jsxs(Avatar, { children: [user.image ? _jsx(AvatarImage, { src: user.image, alt: "" }) : null, _jsx(AvatarFallback, { className: "text-xs font-semibold uppercase", children: initials(user.name, user.email) })] }), variant === "row" ? (_jsxs("span", { className: "min-w-0 flex-1 lg:rail:hidden", children: [_jsx("span", { className: "block truncate text-sm font-medium", children: user.name }), _jsx("span", { className: "block truncate text-xs text-muted-foreground lg:text-shell-muted", children: user.email })] })) : null] }), _jsxs(DropdownMenuContent, { align: variant === "row" ? "start" : "end", side: variant === "row" ? "top" : "bottom", className: "w-64", children: [_jsx(DropdownMenuGroup, { children: _jsxs(DropdownMenuLabel, { children: [_jsx("span", { className: "block truncate text-foreground", children: user.name }), _jsx("span", { className: "block truncate", children: user.email })] }) }), _jsx(DropdownMenuSeparator, {}), onProfile ? (_jsxs(DropdownMenuItem, { onClick: onProfile, children: [_jsx(HugeiconsIcon, { icon: UserIcon, "aria-hidden": true }), "Profil"] })) : null, onSecurity ? (_jsxs(DropdownMenuItem, { onClick: onSecurity, children: [_jsx(HugeiconsIcon, { icon: SecurityLockIcon, "aria-hidden": true }), "S\u00E9curit\u00E9"] })) : null, children, _jsx(ThemeSwitch, {}), onShortcuts ? (_jsxs(DropdownMenuItem, { onClick: onShortcuts, children: [_jsx(HugeiconsIcon, { icon: KeyboardIcon, "aria-hidden": true }), "Raccourcis clavier", _jsx(DropdownMenuShortcut, { children: "?" })] })) : null, 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"] })] })] }));
36
+ return (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { render: variant === "row" ? (_jsx("button", { type: "button", "aria-label": `Compte de ${user.name}`, className: "flex w-full min-w-0 items-center gap-2.5 rounded-xl p-1.5 text-left outline-none transition-colors duration-(--duration-fast) ease-out hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/50 aria-expanded:bg-muted lg:hover:bg-shell-hover lg:aria-expanded:bg-shell-hover lg:rail:justify-center" })) : (_jsx("button", { type: "button", "aria-label": `Compte de ${user.name}`, className: "touch-target grid size-9 place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50" })), children: [_jsxs(Avatar, { children: [user.image ? _jsx(AvatarImage, { src: user.image, alt: "" }) : null, _jsx(AvatarFallback, { className: "text-xs font-semibold uppercase", children: initials(user.name, user.email) })] }), variant === "row" ? (_jsxs("span", { className: "min-w-0 flex-1 lg:rail:hidden", children: [_jsx("span", { className: "block truncate text-sm font-medium", children: user.name }), _jsx("span", { className: "block truncate text-xs text-muted-foreground lg:text-shell-muted", children: user.email })] })) : null] }), _jsxs(DropdownMenuContent, { align: variant === "row" ? "start" : "end", side: variant === "row" ? "top" : "bottom", className: "w-64", children: [_jsx(DropdownMenuGroup, { children: _jsxs(DropdownMenuLabel, { children: [_jsx("span", { className: "block truncate text-foreground", children: user.name }), _jsx("span", { className: "block truncate", children: user.email })] }) }), _jsx(DropdownMenuSeparator, {}), onProfile ? (_jsxs(DropdownMenuItem, { onClick: onProfile, children: [_jsx(HugeiconsIcon, { icon: UserIcon, "aria-hidden": true }), "Profil"] })) : null, onSecurity ? (_jsxs(DropdownMenuItem, { onClick: onSecurity, children: [_jsx(HugeiconsIcon, { icon: SecurityLockIcon, "aria-hidden": true }), "S\u00E9curit\u00E9"] })) : null, children, _jsx(ThemeSwitch, {}), onShortcuts ? (_jsxs(DropdownMenuItem, { onClick: onShortcuts, children: [_jsx(HugeiconsIcon, { icon: KeyboardIcon, "aria-hidden": true }), "Raccourcis clavier", _jsx(DropdownMenuShortcut, { children: "?" })] })) : null, statusPageUrl ? (_jsxs(MenuPrimitive.LinkItem, { href: statusPageUrl, className: MENU_LINK_ITEM, children: [_jsx(HugeiconsIcon, { icon: Activity01Icon, "aria-hidden": true }), "\u00C9tat du service"] })) : null, products.length ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, { className: railShown ? "lg:hidden" : undefined }), _jsxs(DropdownMenuGroup, { className: railShown ? "lg:hidden" : undefined, children: [_jsx(DropdownMenuLabel, { children: "Produits" }), products.map((product) => product.current ? (_jsxs(DropdownMenuItem, { disabled: true, className: "data-disabled:opacity-100", children: [_jsx(ProductMenuIcon, { product: product }), _jsx("span", { className: "flex-1", children: product.name }), _jsx(HugeiconsIcon, { icon: Tick02Icon, "aria-label": "Produit ouvert" })] }, product.name)) : (_jsxs(MenuPrimitive.LinkItem, { href: product.href, onClick: leaveOnClick, className: MENU_LINK_ITEM, children: [_jsx(ProductMenuIcon, { product: product }), _jsx("span", { className: "flex-1", children: product.name }), product.hint ? (_jsx("span", { className: "text-xs font-normal text-muted-foreground", children: product.hint })) : null] }, product.name)))] })] })) : null, _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { onClick: onSignOut, children: [_jsx(HugeiconsIcon, { icon: Logout01Icon, "aria-hidden": true }), "Se d\u00E9connecter"] })] })] }));
34
37
  }
35
38
  function ProductMenuIcon({ product }) {
36
39
  if (product.badge)
@@ -84,7 +84,7 @@ export declare function AppShell({ product, organization, productRail, accountHr
84
84
  /**
85
85
  * Page de statut du produit, dont le bandeau d'état se pose en tête du contenu (ADR 0018) :
86
86
  * `https://status.hgdc.ch/<produit>` par défaut, une autre adresse (prévisualisation sur une page
87
- * de test), ou `false` pour ne rien lire.
87
+ * de test), ou `false` pour ne rien lire. Le menu du compte en tire son entrée « État du service ».
88
88
  */
89
89
  statusPage?: string | false | undefined;
90
90
  }): React.JSX.Element;
package/dist/app-shell.js CHANGED
@@ -14,7 +14,7 @@ import { ProductBadge } from "./product-badge.js";
14
14
  import { ProductRail } from "./product-rail.js";
15
15
  import { useProductSwitch } from "./product-switch.js";
16
16
  import { HGDC_ACCOUNT, HGDC_PRODUCTS } from "./products.js";
17
- import { ProductRailShownContext, ShellLeaveContext } from "./shell-placement.js";
17
+ import { ProductRailShownContext, ShellLeaveContext, StatusPageContext } from "./shell-placement.js";
18
18
  import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip.js";
19
19
  import { cn } from "./cn.js";
20
20
  import { ServiceStatus, statusPageUrlFor } from "./service-status.js";
@@ -201,6 +201,7 @@ export function AppShell({ product, organization, productRail = true, accountHre
201
201
  window.addEventListener("pageshow", onPageShow);
202
202
  return () => window.removeEventListener("pageshow", onPageShow);
203
203
  }, []);
204
+ const statusPageUrl = statusPage === false ? null : (statusPage ?? statusPageUrlFor(product));
204
205
  const loading = useContentLoading({ navigating, pending: pagePending > 0 || leaving });
205
206
  // Avant l'hydratation, le CSS montre le calque et le H dès qu'une page attend (`:has`) ; ensuite
206
207
  // `data-loading` prend le relais dans la même image.
@@ -212,5 +213,5 @@ export function AppShell({ product, organization, productRail = true, accountHre
212
213
  if (loading && contentRef.current)
213
214
  contentRef.current.scrollTop = 0;
214
215
  }, [loading]);
215
- 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 min-h-14 shrink-0 flex-wrap items-center gap-x-1 gap-y-1 border-b border-foreground/5 bg-background px-2 py-1.5 sm:px-4 lg:top-0", children: [_jsx("button", { type: "button", onClick: () => setMenuOpen(true), "aria-label": "Ouvrir le menu", className: "touch-target grid size-10 shrink-0 place-items-center rounded-xl text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 lg:hidden", children: _jsx(HugeiconsIcon, { icon: Menu01Icon, "aria-hidden": true, className: "size-5" }) }), _jsxs("div", { "data-slot": "app-shell-brand", className: "flex shrink-0 items-center gap-2 pr-1 lg:hidden", children: [_jsx(ProductBadge, { product: product, className: "size-6" }), _jsx("span", { className: "font-heading text-[0.9375rem] font-semibold", children: productName(product) })] }), _jsx("div", { className: cn("flex min-w-0 items-center gap-2", header ? "flex-[1_1_7rem]" : "flex-1"), children: header }), onSearch ? (_jsx(CommandMenuButton, { onClick: onSearch, compact: "mobile", className: "shrink-0 lg:w-96 lg:max-w-none", ...(searchLabel !== undefined ? { label: searchLabel } : {}) })) : null] }), _jsx("div", { "data-slot": "app-shell-loading", "aria-hidden": !loading, className: CONTENT_LOADING_LAYER, children: loading ? (_jsx("span", { role: "status", className: "sr-only", children: "Chargement\u2026" })) : null }), _jsxs("main", { id: "contenu", tabIndex: -1, "aria-busy": loading ? true : undefined, inert: loading || undefined, className: cn("mx-auto w-full max-w-6xl flex-1 px-4 pt-6 pb-16 outline-none sm:px-6 lg:px-10 lg:pt-8", mainClassName), children: [_jsx(ServiceStatus, { pageUrl: statusPage === false ? null : (statusPage ?? statusPageUrlFor(product)), className: "mb-6" }), _jsx(ContentLoadingContext, { value: registerPagePending, children: contentKey === undefined ? (children) : (_jsx(Suspense, { fallback: _jsx(PagePending, {}), children: children }, contentKey)) })] })] }) }), hasRoot ? null : _jsx(FamilyLoaderHost, {}), _jsx(Drawer, { side: "bottom", open: menuOpen, onOpenChange: setMenuOpen, children: _jsxs(DrawerContent, { className: "lg:hidden", children: [_jsx(DrawerTitle, { className: "sr-only", children: "Menu" }), organization ? _jsx("div", { className: "px-3 pt-1", children: organization }) : null, nav, sidebarFooter ? (_jsx("div", { className: "grid gap-0.5 border-t border-foreground/5 px-3 pt-2 pb-[max(env(safe-area-inset-bottom),0.75rem)]", children: sidebarFooter })) : null] }) })] }) }) }) }));
216
+ return (_jsx(ShellContext.Provider, { value: { closeMobileNav: () => setMenuOpen(false) }, children: _jsx(ProductRailShownContext, { value: productRail, children: _jsx(ShellLeaveContext, { value: leave, children: _jsx(StatusPageContext, { value: statusPageUrl, children: _jsxs("div", { "data-slot": "app-shell", "data-rail-peek": railPeek ? "" : undefined, "data-product-rail": productRail ? "" : undefined, "data-loading": loading ? "" : undefined, "data-hydrated": hydrated ? "" : undefined, className: cn("min-h-dvh lg:grid lg:h-dvh lg:bg-shell", productRail ? "lg:grid-cols-[auto_auto_minmax(0,1fr)]" : "lg:grid-cols-[auto_minmax(0,1fr)]"), children: [_jsx("a", { href: "#contenu", className: "sr-only z-50 rounded-xl bg-background px-4 py-2 text-sm font-medium focus:not-sr-only focus:fixed focus:top-3 focus:left-3 focus:ring-2 focus:ring-ring", children: "Aller au contenu" }), productRail ? (_jsx(ProductRail, { current: product, organization: organization, accountHref: accountHref, onExpandedChange: setRailPeek })) : null, _jsxs("aside", { "aria-label": "Barre lat\u00E9rale", className: "group/sidebar hidden overflow-hidden text-shell-foreground transition-[width] duration-(--duration-base) ease-ios motion-reduce:transition-none lg:flex lg:w-64 lg:flex-col lg:rail:w-16", children: [_jsx(SidebarHead, { product: product }), !productRail && organization ? _jsx("div", { className: "shrink-0 px-3 pb-1", children: organization }) : null, nav, sidebarFooter ? _jsx("div", { className: "grid gap-0.5 px-3 pt-1 pb-3", children: sidebarFooter }) : null] }), _jsx("div", { className: "flex min-h-dvh flex-col lg:min-h-0 lg:p-2 lg:pl-0", children: _jsxs("div", { ref: contentRef, className: "relative flex flex-1 flex-col lg:min-h-0 lg:overflow-y-auto lg:rounded-xl lg:bg-background lg:[scrollbar-gutter:stable]", children: [_jsxs("header", { className: "sticky top-[env(safe-area-inset-top,0px)] z-30 flex min-h-14 shrink-0 flex-wrap items-center gap-x-1 gap-y-1 border-b border-foreground/5 bg-background px-2 py-1.5 sm:px-4 lg:top-0", children: [_jsx("button", { type: "button", onClick: () => setMenuOpen(true), "aria-label": "Ouvrir le menu", className: "touch-target grid size-10 shrink-0 place-items-center rounded-xl text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 lg:hidden", children: _jsx(HugeiconsIcon, { icon: Menu01Icon, "aria-hidden": true, className: "size-5" }) }), _jsxs("div", { "data-slot": "app-shell-brand", className: "flex shrink-0 items-center gap-2 pr-1 lg:hidden", children: [_jsx(ProductBadge, { product: product, className: "size-6" }), _jsx("span", { className: "font-heading text-[0.9375rem] font-semibold", children: productName(product) })] }), _jsx("div", { className: cn("flex min-w-0 items-center gap-2", header ? "flex-[1_1_7rem]" : "flex-1"), children: header }), onSearch ? (_jsx(CommandMenuButton, { onClick: onSearch, compact: "mobile", className: "shrink-0 lg:w-96 lg:max-w-none", ...(searchLabel !== undefined ? { label: searchLabel } : {}) })) : null] }), _jsx("div", { "data-slot": "app-shell-loading", "aria-hidden": !loading, className: CONTENT_LOADING_LAYER, children: loading ? (_jsx("span", { role: "status", className: "sr-only", children: "Chargement\u2026" })) : null }), _jsxs("main", { id: "contenu", tabIndex: -1, "aria-busy": loading ? true : undefined, inert: loading || undefined, className: cn("mx-auto w-full max-w-6xl flex-1 px-4 pt-6 pb-16 outline-none sm:px-6 lg:px-10 lg:pt-8", mainClassName), children: [_jsx(ServiceStatus, { pageUrl: statusPageUrl, className: "mb-6" }), _jsx(ContentLoadingContext, { value: registerPagePending, children: contentKey === undefined ? (children) : (_jsx(Suspense, { fallback: _jsx(PagePending, {}), children: children }, contentKey)) })] })] }) }), hasRoot ? null : _jsx(FamilyLoaderHost, {}), _jsx(Drawer, { side: "bottom", open: menuOpen, onOpenChange: setMenuOpen, children: _jsxs(DrawerContent, { className: "lg:hidden", children: [_jsx(DrawerTitle, { className: "sr-only", children: "Menu" }), organization ? _jsx("div", { className: "px-3 pt-1", children: organization }) : null, nav, sidebarFooter ? (_jsx("div", { className: "grid gap-0.5 border-t border-foreground/5 px-3 pt-2 pb-[max(env(safe-area-inset-bottom),0.75rem)]", children: sidebarFooter })) : null] }) })] }) }) }) }) }));
216
217
  }
@@ -0,0 +1,14 @@
1
+ /** Fichiers de `family/app/<mot>/`, servis à la racine du produit (favicon, icônes d'application). */
2
+ export declare const APP_ICON_FILES: readonly ["favicon.svg", "favicon.ico", "apple-touch-icon.png", "icon-192.png", "icon-512.png", "icon-maskable-512.png"];
3
+ export type Copy = Readonly<{
4
+ from: string;
5
+ to: string;
6
+ }>;
7
+ /**
8
+ * Copies qui posent les icônes d'un produit dans `publicDir` : `family/app/<mot>/*`, plus l'icône
9
+ * d'e-mail du produit sous chaque nom de `emailAs` (adresses déjà parties dans des e-mails).
10
+ */
11
+ export declare function appIconCopies(word: string, publicDir: string, emailAs?: ReadonlyArray<string>): Array<Copy>;
12
+ /** Copies des icônes d'e-mail de tous les produits (`<mot>.png`) dans `dir`, pour le site qui les sert. */
13
+ export declare function emailIconCopies(dir: string): Array<Copy>;
14
+ export declare function runCopies(copies: ReadonlyArray<Copy>): Promise<void>;
package/dist/brand.js ADDED
@@ -0,0 +1,58 @@
1
+ // Copie des icônes du kit dans un produit ou un site, au build et au démarrage du dev (`hgdc-brand`).
2
+ // Les produits ne versionnent plus ces fichiers : les icônes suivent la version de `@hgdc/ui`
3
+ // installée (ADR 0017, runbook `docs/runbooks/changer-une-icone.md`).
4
+ import { copyFile, mkdir } from "node:fs/promises";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { FAMILY_PRODUCTS, isFamilyProduct } from "./family-svg.js";
8
+ const PACKAGE_ROOT = fileURLToPath(new URL("../", import.meta.url));
9
+ /** Fichiers de `family/app/<mot>/`, servis à la racine du produit (favicon, icônes d'application). */
10
+ export const APP_ICON_FILES = [
11
+ "favicon.svg",
12
+ "favicon.ico",
13
+ "apple-touch-icon.png",
14
+ "icon-192.png",
15
+ "icon-512.png",
16
+ "icon-maskable-512.png",
17
+ ];
18
+ function assertProduct(word) {
19
+ if (!isFamilyProduct(word))
20
+ throw new Error(`produit inconnu « ${word} » (attendu : ${FAMILY_PRODUCTS.join(", ")})`);
21
+ return word;
22
+ }
23
+ /** Nom de fichier simple, sans dossier : une copie ne sort jamais du dossier cible. */
24
+ function assertFileName(name) {
25
+ if (!/^[\w.-]+$/.test(name) || name.startsWith("."))
26
+ throw new Error(`nom de fichier invalide « ${name} »`);
27
+ return name;
28
+ }
29
+ /**
30
+ * Copies qui posent les icônes d'un produit dans `publicDir` : `family/app/<mot>/*`, plus l'icône
31
+ * d'e-mail du produit sous chaque nom de `emailAs` (adresses déjà parties dans des e-mails).
32
+ */
33
+ export function appIconCopies(word, publicDir, emailAs = []) {
34
+ const product = assertProduct(word);
35
+ return [
36
+ ...APP_ICON_FILES.map((file) => ({
37
+ from: join(PACKAGE_ROOT, "family", "app", product, file),
38
+ to: join(publicDir, file),
39
+ })),
40
+ ...emailAs.map((name) => ({
41
+ from: join(PACKAGE_ROOT, "email-icons", `${product}.png`),
42
+ to: join(publicDir, assertFileName(name)),
43
+ })),
44
+ ];
45
+ }
46
+ /** Copies des icônes d'e-mail de tous les produits (`<mot>.png`) dans `dir`, pour le site qui les sert. */
47
+ export function emailIconCopies(dir) {
48
+ return FAMILY_PRODUCTS.map((product) => ({
49
+ from: join(PACKAGE_ROOT, "email-icons", `${product}.png`),
50
+ to: join(dir, `${product}.png`),
51
+ }));
52
+ }
53
+ export async function runCopies(copies) {
54
+ for (const { from, to } of copies) {
55
+ await mkdir(dirname(to), { recursive: true });
56
+ await copyFile(from, to);
57
+ }
58
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ // hgdc-brand : pose les icônes du kit dans un produit ou un site (src/brand.ts).
3
+ import { resolve } from "node:path";
4
+ import { parseArgs } from "node:util";
5
+ import { appIconCopies, emailIconCopies, runCopies } from "./brand.js";
6
+ const USAGE = `Usage : hgdc-brand <commande>
7
+
8
+ sync <mot> [--public <dossier>] [--email-as <fichier>]...
9
+ pose favicon et icônes d'application du produit dans public/ (défaut : ./public) ;
10
+ --email-as copie aussi son icône d'e-mail sous ce nom (adresse déjà envoyée)
11
+ emails <dossier>
12
+ pose les icônes d'e-mail de tous les produits (<mot>.png) dans le dossier
13
+
14
+ Les fichiers posés ne se versionnent pas : les lister dans le .gitignore.`;
15
+ const { positionals, values } = parseArgs({
16
+ allowPositionals: true,
17
+ options: {
18
+ public: { type: "string", default: "public" },
19
+ "email-as": { type: "string", multiple: true, default: [] },
20
+ help: { type: "boolean", short: "h", default: false },
21
+ },
22
+ });
23
+ if (values.help) {
24
+ console.log(USAGE);
25
+ process.exit(0);
26
+ }
27
+ const [command, target] = positionals;
28
+ try {
29
+ if (command === "sync" && target) {
30
+ const copies = appIconCopies(target, resolve(values.public), values["email-as"]);
31
+ await runCopies(copies);
32
+ console.log(`hgdc-brand : ${copies.length} icônes de ${target} posées dans ${values.public}/`);
33
+ }
34
+ else if (command === "emails" && target) {
35
+ const copies = emailIconCopies(resolve(target));
36
+ await runCopies(copies);
37
+ console.log(`hgdc-brand : ${copies.length} icônes d'e-mail posées dans ${target}/`);
38
+ }
39
+ else {
40
+ console.error(USAGE);
41
+ process.exit(2);
42
+ }
43
+ }
44
+ catch (error) {
45
+ console.error(`hgdc-brand : ${error instanceof Error ? error.message : String(error)}`);
46
+ process.exit(1);
47
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Seule adresse des icônes des e-mails : hgdc.ch les sert depuis `packages/ui/email-icons/`, posées
3
+ * à son build par `hgdc-brand emails public/email`.
4
+ */
5
+ export declare const EMAIL_ICON_BASE = "https://hgdc.ch/email/";
6
+ /**
7
+ * Icône en PNG du produit (`mail` → `https://hgdc.ch/email/mail.png?v=<empreinte>`). L'empreinte
8
+ * change avec l'image : un client mail qui l'a gardée en cache charge la nouvelle.
9
+ */
10
+ export declare function emailIconUrl(word: string): string;
@@ -0,0 +1,17 @@
1
+ // Adresse des icônes des e-mails, sans React Email : un code serveur qui assemble son propre HTML
2
+ // (filigrane de Mail) l'importe sans tirer le modèle (`@hgdc/ui/email-icon`).
3
+ import { EMAIL_ICON_VERSION, isFamilyProduct } from "./family-svg.js";
4
+ /**
5
+ * Seule adresse des icônes des e-mails : hgdc.ch les sert depuis `packages/ui/email-icons/`, posées
6
+ * à son build par `hgdc-brand emails public/email`.
7
+ */
8
+ export const EMAIL_ICON_BASE = "https://hgdc.ch/email/";
9
+ /**
10
+ * Icône en PNG du produit (`mail` → `https://hgdc.ch/email/mail.png?v=<empreinte>`). L'empreinte
11
+ * change avec l'image : un client mail qui l'a gardée en cache charge la nouvelle.
12
+ */
13
+ export function emailIconUrl(word) {
14
+ const product = word.toLowerCase();
15
+ const url = `${EMAIL_ICON_BASE}${product}.png`;
16
+ return isFamilyProduct(product) ? `${url}?v=${EMAIL_ICON_VERSION[product]}` : url;
17
+ }
package/dist/email.d.ts CHANGED
@@ -1,15 +1,13 @@
1
1
  import { type ReactElement, type ReactNode } from "react";
2
2
  import { type EmailAccent, type EmailTone } from "./email-palette.ts";
3
+ import { EMAIL_ICON_BASE, emailIconUrl } from "./email-icon.ts";
3
4
  import { type HgdcCatalogProduct } from "./products.ts";
4
5
  /**
5
6
  * Modèle des e-mails transactionnels de la famille : une mise en page (`EmailLayout`) et des blocs,
6
7
  * rendus par React Email en HTML pour clients mail (tableaux, styles en ligne, 600 px) et en texte.
7
8
  * Le rendu se fait côté serveur (action Convex `"use node"`, route serveur) : `renderEmail`.
8
9
  */
9
- /** Où hgdc.ch sert les icônes des e-mails (`packages/ui/email-icons/`). */
10
- export declare const EMAIL_ICON_BASE = "https://hgdc.ch/email/";
11
- /** Icône en PNG du produit (`mail` → `https://hgdc.ch/email/mail.png`). */
12
- export declare function emailIconUrl(word: string): string;
10
+ export { EMAIL_ICON_BASE, emailIconUrl };
13
11
  export type EmailLayoutProps = {
14
12
  /** Produit qui écrit : son mot (« Status ») ou son entrée du catalogue. */
15
13
  product: HgdcCatalogProduct | string;
package/dist/email.js CHANGED
@@ -2,23 +2,19 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { createContext, useContext } from "react";
3
3
  import { Body, Button, Column, Container, Head, Heading, Hr, Html, Img, Link, Preview, Row, Section, Text, render, toPlainText, } from "react-email";
4
4
  import { EMAIL_DARK, EMAIL_LIGHT, EMAIL_TONES, HGDC_EMAIL_ACCENT, isHexColor, readableOn, } from "./email-palette.js";
5
+ import { EMAIL_ICON_BASE, emailIconUrl } from "./email-icon.js";
5
6
  import { HGDC_ACCOUNT, HGDC_PRODUCTS } from "./products.js";
6
7
  /**
7
8
  * Modèle des e-mails transactionnels de la famille : une mise en page (`EmailLayout`) et des blocs,
8
9
  * rendus par React Email en HTML pour clients mail (tableaux, styles en ligne, 600 px) et en texte.
9
10
  * Le rendu se fait côté serveur (action Convex `"use node"`, route serveur) : `renderEmail`.
10
11
  */
11
- /** Où hgdc.ch sert les icônes des e-mails (`packages/ui/email-icons/`). */
12
- export const EMAIL_ICON_BASE = "https://hgdc.ch/email/";
12
+ export { EMAIL_ICON_BASE, emailIconUrl };
13
13
  const MAIL_HREF = "https://mail.hgdc.ch";
14
14
  const LEGAL = "Hugo Gebel Développement Conseil, Lausanne";
15
15
  // Pas de police web : celle du système, Inter ou Geist quand elles sont installées.
16
16
  const FONT = 'Geist, Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif';
17
17
  const MONO = 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace';
18
- /** Icône en PNG du produit (`mail` → `https://hgdc.ch/email/mail.png`). */
19
- export function emailIconUrl(word) {
20
- return `${EMAIL_ICON_BASE}${word.toLowerCase()}.png`;
21
- }
22
18
  function findProduct(product) {
23
19
  if (typeof product !== "string")
24
20
  return product;
@@ -13,5 +13,10 @@ export type ThemedSvg = Readonly<{
13
13
  * sombre (`family/tile/<mot>.svg`).
14
14
  */
15
15
  export declare const PRODUCT_BADGE_SVG: Readonly<Record<FamilyProduct, string>>;
16
+ /**
17
+ * Empreinte de chaque icône d'e-mail (`email-icons/<mot>.png`), ajoutée à son adresse : un client
18
+ * mail qui garde l'ancienne image en cache (proxy d'images de Gmail) charge la nouvelle.
19
+ */
20
+ export declare const EMAIL_ICON_VERSION: Readonly<Record<FamilyProduct, string>>;
16
21
  /** SVG du H « Double », la marque de la H Collection, sans fond (`family/hcollection-icon(-dark).svg`). */
17
22
  export declare const FAMILY_MARK_SVG: ThemedSvg;
@@ -18,6 +18,19 @@ export const PRODUCT_BADGE_SVG = {
18
18
  analytics: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#fff"/><svg x="7.68" y="7.68" width="48.64" height="48.64" viewBox="15 15 34 34"><defs><linearGradient id="g" x1="14" y1="14" x2="50" y2="50" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffb020"/><stop offset="1" stop-color="#e58309"/></linearGradient></defs><path d="M18.3 34H21.7A2.8 2.8 0 0 1 24.5 36.8V46.2A2.8 2.8 0 0 1 21.7 49H18.3A2.8 2.8 0 0 1 15.5 46.2V36.8A2.8 2.8 0 0 1 18.3 34Z" fill="url(#g)" fill-opacity="0.4" fill-rule="evenodd"/><path d="M30.3 25H33.7A2.8 2.8 0 0 1 36.5 27.8V46.2A2.8 2.8 0 0 1 33.7 49H30.3A2.8 2.8 0 0 1 27.5 46.2V27.8A2.8 2.8 0 0 1 30.3 25Z" fill="url(#g)" fill-opacity="0.7" fill-rule="evenodd"/><path d="M42.3 15H45.7A2.8 2.8 0 0 1 48.5 17.8V46.2A2.8 2.8 0 0 1 45.7 49H42.3A2.8 2.8 0 0 1 39.5 46.2V17.8A2.8 2.8 0 0 1 42.3 15Z" fill="url(#g)" fill-rule="evenodd"/></svg></svg>',
19
19
  docs: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#fff"/><svg x="7.68" y="7.68" width="48.64" height="48.64" viewBox="14 13 36 36"><defs><linearGradient id="g" x1="14" y1="14" x2="50" y2="50" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#0ec1dc"/><stop offset="1" stop-color="#0aa3c1"/></linearGradient></defs><path d="M26 13H46A3 3 0 0 1 49 16V43A3 3 0 0 1 46 46H26A3 3 0 0 1 23 43V16A3 3 0 0 1 26 13Z" fill="url(#g)" fill-opacity="0.22" fill-rule="evenodd"/><path d="M18 18H32V24.5A2.5 2.5 0 0 0 34.5 27H41V46A3 3 0 0 1 38 49H18A3 3 0 0 1 15 46V21A3 3 0 0 1 18 18ZM21.3 33H34.7A1.3 1.3 0 0 1 36 34.3V34.300000000000004A1.3 1.3 0 0 1 34.7 35.6H21.3A1.3 1.3 0 0 1 20 34.300000000000004V34.3A1.3 1.3 0 0 1 21.3 33ZM21.3 39H29.7A1.3 1.3 0 0 1 31 40.3V40.300000000000004A1.3 1.3 0 0 1 29.7 41.6H21.3A1.3 1.3 0 0 1 20 40.300000000000004V40.3A1.3 1.3 0 0 1 21.3 39Z" fill="url(#g)" fill-rule="evenodd"/><path d="M32 18L41 27H34.5A2.5 2.5 0 0 1 32 24.5Z" fill="url(#g)" fill-opacity="0.45" fill-rule="evenodd"/></svg></svg>',
20
20
  };
21
+ /**
22
+ * Empreinte de chaque icône d'e-mail (`email-icons/<mot>.png`), ajoutée à son adresse : un client
23
+ * mail qui garde l'ancienne image en cache (proxy d'images de Gmail) charge la nouvelle.
24
+ */
25
+ export const EMAIL_ICON_VERSION = {
26
+ account: "2263211d",
27
+ host: "ceb0451c",
28
+ mail: "5c18df41",
29
+ portal: "e18dd184",
30
+ status: "3f91af62",
31
+ analytics: "be693909",
32
+ docs: "4f1fb8a0",
33
+ };
21
34
  /** SVG du H « Double », la marque de la H Collection, sans fond (`family/hcollection-icon(-dark).svg`). */
22
35
  export const FAMILY_MARK_SVG = {
23
36
  light: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="9 9 46 46"><defs><linearGradient id="hl" x1="0" y1="11.5" x2="0" y2="47.5" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#2c78fd"/><stop offset="1" stop-color="#7343fe"/></linearGradient><linearGradient id="hr" x1="0" y1="11.5" x2="0" y2="47.5" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fe5292"/><stop offset="1" stop-color="#ff9a46"/></linearGradient><linearGradient id="hf" x1="24.5" y1="0" x2="34.5" y2="0" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff"/><stop offset="1" stop-color="#000"/></linearGradient><mask id="hm" maskUnits="userSpaceOnUse" x="0" y="0" width="64" height="64"><rect x="24" y="24" width="11" height="11" fill="url(#hf)"/></mask><g id="h"><path d="M24 25H35V34H24Z" fill="url(#hr)"/><path d="M24 25H35V34H24Z" fill="url(#hl)" mask="url(#hm)"/><path d="M17.5 11.5H21.5A3 3 0 0 1 24.5 14.5V44.5A3 3 0 0 1 21.5 47.5H17.5A3 3 0 0 1 14.5 44.5V14.5A3 3 0 0 1 17.5 11.5Z" fill="url(#hl)"/><path d="M37.5 11.5H41.5A3 3 0 0 1 44.5 14.5V44.5A3 3 0 0 1 41.5 47.5H37.5A3 3 0 0 1 34.5 44.5V14.5A3 3 0 0 1 37.5 11.5Z" fill="url(#hr)"/></g></defs><use href="#h" transform="translate(6 6)" opacity="0.35"/><use href="#h"/></svg>',
@@ -19,3 +19,10 @@ export declare function useProductRailShown(): boolean;
19
19
  export declare const ShellLeaveContext: React.Context<() => void>;
20
20
  /** `onClick` d'un lien vers un autre produit : clic simple seulement (pas Ctrl, Cmd, Maj, molette). */
21
21
  export declare function useLeaveOnClick(): (event: React.MouseEvent<HTMLAnchorElement>) => void;
22
+ /**
23
+ * Page de statut du produit affiché, posée par `AppShell` (son `statusPage`, sinon
24
+ * `https://status.hgdc.ch/<produit>`) : le menu du compte en tire son entrée « État du service ».
25
+ * `null` hors de la coque ou quand la coque ne lit aucune page.
26
+ */
27
+ export declare const StatusPageContext: React.Context<string | null>;
28
+ export declare function useStatusPage(): string | null;
@@ -31,3 +31,12 @@ export function useLeaveOnClick() {
31
31
  leave();
32
32
  };
33
33
  }
34
+ /**
35
+ * Page de statut du produit affiché, posée par `AppShell` (son `statusPage`, sinon
36
+ * `https://status.hgdc.ch/<produit>`) : le menu du compte en tire son entrée « État du service ».
37
+ * `null` hors de la coque ou quand la coque ne lit aucune page.
38
+ */
39
+ export const StatusPageContext = createContext(null);
40
+ export function useStatusPage() {
41
+ return useContext(StatusPageContext);
42
+ }
@@ -6,15 +6,38 @@ export type UptimeDay = {
6
6
  status: ServiceStatus;
7
7
  /** Disponibilité du jour, déjà formatée : « 99,98 % ». */
8
8
  uptime?: string;
9
- /** Ce qui s'est passé : « Panne de 12 min, base de données ». */
9
+ /** Ce qui s'est passé : « Panne de 12 min, base de données ». Ignoré quand `detail` est là. */
10
10
  note?: string;
11
+ /**
12
+ * Détail du jour, comme chez Statuspage : durée par niveau et incidents en liens. Le jour ouvre
13
+ * alors une bulle (survol, toucher, Entrée) dont les liens se suivent au clavier, au lieu de
14
+ * l'infobulle en texte.
15
+ */
16
+ detail?: UptimeDayDetail;
17
+ };
18
+ export type UptimeDayDetail = {
19
+ /** Temps passé à chaque niveau, du plus grave au moins grave : « Panne majeure », « 12 min ». */
20
+ levels: Array<{
21
+ status: ServiceStatus;
22
+ label: string;
23
+ duration: string;
24
+ }>;
25
+ /** Incidents du jour, vers leur page. */
26
+ incidents: Array<{
27
+ title: string;
28
+ href: string;
29
+ }>;
30
+ /** Phrase quand `levels` est vide : « Aucune panne ce jour-là » par défaut. */
31
+ empty?: string;
11
32
  };
12
33
  /** Le jour à atteindre pour une touche, ou `null` si la touche ne déplace pas. */
13
34
  export declare function nextDay(key: string, current: number, count: number): number | null;
14
35
  /**
15
36
  * Une barre par jour, du plus ancien au plus récent, comme les pages de statut de référence.
16
37
  * Chaque barre a sa couleur et, au survol, une infobulle en texte (jour, statut, disponibilité,
17
- * note). Les lecteurs d'écran lisent la liste des jours, dont chacun dit son statut en mots.
38
+ * note). Un jour qui porte un `detail` ouvre à la place une bulle (survol, toucher, Entrée) : durée
39
+ * par niveau et incidents du jour en liens. Les lecteurs d'écran lisent la liste des jours, dont
40
+ * chacun dit son statut en mots.
18
41
  *
19
42
  * Au clavier, la barre est un seul arrêt de tabulation (le dernier jour visité, aujourd'hui au
20
43
  * départ) ; les flèches, Début et Fin passent d'un jour à l'autre et ouvrent son infobulle.
@@ -1,8 +1,10 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useRef, useState } from "react";
3
- import { SERVICE_STATUS_LABEL } from "./status.js";
3
+ import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "./popover.js";
4
+ import { SERVICE_STATUS_LABEL, StatusDot } from "./status.js";
4
5
  import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip.js";
5
6
  import { cn } from "./cn.js";
7
+ const NO_DOWNTIME = "Aucune panne ce jour-là";
6
8
  const BAR = {
7
9
  operational: "bg-status-operational",
8
10
  degraded: "bg-status-degraded",
@@ -10,11 +12,20 @@ const BAR = {
10
12
  maintenance: "bg-status-maintenance",
11
13
  unknown: "bg-foreground/10",
12
14
  };
15
+ const DAY = "block w-full rounded-[3px] outline-none transition-opacity duration-(--duration-fast) ease-out hover:opacity-70 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background data-popup-open:opacity-70";
13
16
  const day = new Intl.DateTimeFormat("fr-CH", { weekday: "short", day: "numeric", month: "short" });
17
+ const longDay = new Intl.DateTimeFormat("fr-CH", { weekday: "long", day: "numeric", month: "long", year: "numeric" });
14
18
  function describe(entry) {
15
- return [day.format(entry.date), SERVICE_STATUS_LABEL[entry.status], entry.uptime, entry.note]
16
- .filter(Boolean)
17
- .join(" · ");
19
+ const detail = entry.detail;
20
+ const note = detail
21
+ ? [
22
+ ...detail.levels.map((level) => `${level.label} ${level.duration}`),
23
+ ...detail.incidents.map((incident) => incident.title),
24
+ ].join(" · ") ||
25
+ detail.empty ||
26
+ NO_DOWNTIME
27
+ : entry.note;
28
+ return [day.format(entry.date), SERVICE_STATUS_LABEL[entry.status], entry.uptime, note].filter(Boolean).join(" · ");
18
29
  }
19
30
  /** Le jour à atteindre pour une touche, ou `null` si la touche ne déplace pas. */
20
31
  export function nextDay(key, current, count) {
@@ -33,10 +44,16 @@ export function nextDay(key, current, count) {
33
44
  return null;
34
45
  }
35
46
  }
47
+ /** Bulle d'un jour : date, disponibilité, durée par niveau (point et mots), incidents en liens. */
48
+ function DayDetail({ entry, detail }) {
49
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { children: [_jsx(PopoverTitle, { className: "first-letter:uppercase", children: longDay.format(entry.date) }), entry.uptime ? _jsx("p", { className: "text-xs text-muted-foreground tabular-nums", children: entry.uptime }) : null] }), detail.levels.length ? (_jsx("ul", { className: "grid gap-1", children: detail.levels.map((level) => (_jsxs("li", { className: "flex items-center gap-2", children: [_jsx(StatusDot, { status: level.status }), _jsx("span", { className: "min-w-0 flex-1", children: level.label }), _jsx("span", { className: "shrink-0 text-muted-foreground tabular-nums", children: level.duration })] }, level.label))) })) : (_jsx("p", { className: "text-muted-foreground", children: detail.empty ?? NO_DOWNTIME })), detail.incidents.length ? (_jsx("ul", { "aria-label": "Incidents du jour", className: "grid gap-1.5 border-t border-border pt-2", children: detail.incidents.map((incident) => (_jsx("li", { children: _jsx("a", { href: incident.href, className: "rounded-sm font-medium text-pretty underline decoration-muted-foreground/50 underline-offset-4 outline-none hover:decoration-foreground focus-visible:ring-2 focus-visible:ring-ring", children: incident.title }) }, incident.href))) })) : null] }));
50
+ }
36
51
  /**
37
52
  * Une barre par jour, du plus ancien au plus récent, comme les pages de statut de référence.
38
53
  * Chaque barre a sa couleur et, au survol, une infobulle en texte (jour, statut, disponibilité,
39
- * note). Les lecteurs d'écran lisent la liste des jours, dont chacun dit son statut en mots.
54
+ * note). Un jour qui porte un `detail` ouvre à la place une bulle (survol, toucher, Entrée) : durée
55
+ * par niveau et incidents du jour en liens. Les lecteurs d'écran lisent la liste des jours, dont
56
+ * chacun dit son statut en mots.
40
57
  *
41
58
  * Au clavier, la barre est un seul arrêt de tabulation (le dernier jour visité, aujourd'hui au
42
59
  * départ) ; les flèches, Début et Fin passent d'un jour à l'autre et ouvrent son infobulle.
@@ -53,5 +70,16 @@ export function UptimeBar({ days, label = "Disponibilité", summary, className,
53
70
  setFocused(target);
54
71
  list.current?.querySelectorAll("[data-slot=uptime-day]")[target]?.focus();
55
72
  }
56
- return (_jsxs("div", { "data-slot": "uptime-bar", className: cn("grid gap-2", className), children: [_jsx("ol", { ref: list, "aria-label": label, className: "flex h-8 items-stretch gap-[2px]", children: days.map((entry, index) => (_jsx("li", { className: "flex min-w-0 flex-1", children: _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { render: _jsx("button", { type: "button" }), "data-slot": "uptime-day", tabIndex: index === current ? 0 : -1, onFocus: () => setFocused(index), onKeyDown: onKeyDown, className: cn("block w-full rounded-[3px] outline-none transition-opacity duration-(--duration-fast) ease-out hover:opacity-70 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background data-popup-open:opacity-70", BAR[entry.status]), children: _jsx("span", { className: "sr-only", children: describe(entry) }) }), _jsx(TooltipContent, { children: describe(entry) })] }) }, new Date(entry.date).toISOString()))) }), _jsxs("div", { "aria-hidden": true, "data-slot": "uptime-legend", className: "flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-xs text-muted-foreground tabular-nums", children: [_jsx("span", { className: "shrink-0", children: days.length > 0 ? `il y a ${days.length} jours` : null }), summary ? (_jsx("span", { className: "min-w-0 text-center text-pretty break-words text-foreground", children: summary })) : null, _jsx("span", { className: "shrink-0", children: "Aujourd'hui" })] })] }));
73
+ return (_jsxs("div", { "data-slot": "uptime-bar", className: cn("grid gap-2", className), children: [_jsx("ol", { ref: list, "aria-label": label, className: "flex h-8 items-stretch gap-[2px]", children: days.map((entry, index) => {
74
+ const trigger = {
75
+ render: _jsx("button", { type: "button" }),
76
+ "data-slot": "uptime-day",
77
+ tabIndex: index === current ? 0 : -1,
78
+ onFocus: () => setFocused(index),
79
+ onKeyDown,
80
+ className: cn(DAY, BAR[entry.status]),
81
+ children: _jsx("span", { className: "sr-only", children: describe(entry) }),
82
+ };
83
+ return (_jsx("li", { className: "flex min-w-0 flex-1", children: entry.detail ? (_jsxs(Popover, { children: [_jsx(PopoverTrigger, { openOnHover: true, ...trigger }), _jsx(PopoverContent, { side: "top", className: "w-64 gap-3", "data-slot": "uptime-day-detail", children: _jsx(DayDetail, { entry: entry, detail: entry.detail }) })] })) : (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { ...trigger }), _jsx(TooltipContent, { children: describe(entry) })] })) }, new Date(entry.date).toISOString()));
84
+ }) }), _jsxs("div", { "aria-hidden": true, "data-slot": "uptime-legend", className: "flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-xs text-muted-foreground tabular-nums", children: [_jsx("span", { className: "shrink-0", children: days.length > 0 ? `il y a ${days.length} jours` : null }), summary ? (_jsx("span", { className: "min-w-0 text-center text-pretty break-words text-foreground", children: summary })) : null, _jsx("span", { className: "shrink-0", children: "Aujourd'hui" })] })] }));
57
85
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hgdc/ui",
3
- "version": "0.25.0",
3
+ "version": "0.27.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",
@@ -20,6 +20,9 @@
20
20
  "email-icons",
21
21
  "family"
22
22
  ],
23
+ "bin": {
24
+ "hgdc-brand": "./dist/cli.js"
25
+ },
23
26
  "sideEffects": [
24
27
  "*.css"
25
28
  ],
@@ -34,7 +37,7 @@
34
37
  }
35
38
  },
36
39
  "scripts": {
37
- "build": "tsc -p tsconfig.build.json"
40
+ "build": "tsc -p tsconfig.build.json && tsc -p tsconfig.cli.json"
38
41
  },
39
42
  "dependencies": {
40
43
  "@base-ui/react": "^1.9.0",