@hgdc/ui 0.71.0 → 0.72.1

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
@@ -53,7 +53,7 @@ Primitives déjà là : `avatar` `badge` `button` `dialog` `drawer` `dropdown-me
53
53
  | `project-switcher` | `<ProjectSwitcher projects={projects} activeId={project?.id} activeName={params.project} allHref="/o/acme" createHref="/o/acme/new" renderLink={(href) => <Link to={href} />} />` : projet actif ou « Tous les projets », marque du projet (`ProjectMark`), « Nouveau projet » |
54
54
  | `account-menu` | `<AccountMenu user={user} onProfile={…} onSecurity={…} onSignOut={signOut} product="Mail" />` : Account et les produits ouverts du catalogue, Mail coché. Dans l'ordre : identité, Profil, Sécurité, Thème (Système, Clair, Sombre, `ThemeSwitch`), entrées du produit, Se déconnecter. Ni « État du service » ni raccourcis : la coque pose le premier au-dessus du compte, l'aide garde sa touche `?` |
55
55
  | `command-menu` | `<CommandMenu groups={[{ label: "Aller à", items }, { label: "Créer", items }, { label: "Changer", items }]} />` (`Ctrl+K`), `<CommandMenuButton compact="mobile" …/>` |
56
- | `products` | `HGDC_PRODUCTS` (mot, nom, sous-domaine, phrase, pastille `badge`, icône, état, `entryHref`), `HGDC_ACCOUNT`, `productMenuEntries({ current: "Mail" })` : chaque produit du menu pointe vers son entrée `/auth/hgdc` (ADR 0010), sauf ID (`https://id.hgdc.ch`, sa racine) |
56
+ | `products` | `HGDC_PRODUCTS` (mot, nom, sous-domaine, phrase, pastille `badge`, icône, état, `entryHref`), `HGDC_ACCOUNT`, `productMenuEntries({ current: "Mail" })` : chaque produit du menu pointe vers son entrée `/auth/hgdc` (ADR 0010), sauf ID (`https://id.hgdc.ch`, sa racine). `productEntryHref("mail", PRODUCT_ORIGINS, "acme")` et `shellProductEntries(…, { organization })` mènent droit à `/o/acme` (slug vérifié, sauf Manager) |
57
57
  | `addresses` | Adresses d'un produit déduites de son mot, sans React (lisible par `vite.config.ts` et Convex) : `productAddresses("mail", import.meta.env)` rend `basePath`, `appOrigin`, `appUrl` (`https://app.hgdc.ch/mail`), `convexUrl` (`https://convex.mail.hgdc.ch`), `convexSiteUrl`, `withBase`. `productBasePath("mail", { override: process.env.HGDC_BASE_PATH, command })` donne la base du build : `/mail` pour `vite build`, la racine pour `vite dev` et Vitest. `APP_PRODUCTS` : produits servis sous `app.hgdc.ch` (d'où `PRODUCT_ORIGINS`). Surcharges : `HGDC_BASE_PATH` (`/` : racine), `VITE_CONVEX_URL`, `VITE_CONVEX_SITE_URL` |
58
58
  | `service-status` | Bandeau d'incident ou de maintenance posé par `AppShell` en tête du contenu (ADR 0018) : `useServiceStatus(pageUrl)`, `ServiceStatusBanner`. `statusPage={false}` pour ne rien lire |
59
59
  | `kbd` | `<KbdGroup><Kbd>Ctrl</Kbd><Kbd>K</Kbd></KbdGroup>` |
@@ -74,6 +74,8 @@ Pendant le passage (0.39) :
74
74
  - **Barre latérale, contenu** : le nom du module et sa navigation passent en fondu de 200 ms. Une page qui attend hors de la coque (`PagePending` sous `FamilyLoaderRoot`) dessine la coque entière : organisation retenue, Manager, retour aux modules, nom du module de l'adresse, navigation en squelette à la hauteur retenue pour ce module, barre du haut.
75
75
  - **H** : un seul nom de transition (`hgdc-loader`), sans fondu, calé sur l'horloge : celui de l'arrivée reprend à la même phase.
76
76
  - **Contenu** : fondu de 150 ms.
77
+ - **Organisation** : la barre ouvre le produit sur la dernière organisation affichée (`next=/o/<slug>`), sans passer par l'accueil.
78
+ - **Relecture des organisations** (ADR 0016) : au plus une fois toutes les 10 minutes par produit (`BACKGROUND_REFRESH_INTERVAL_MS`). Un passage rapide d'un produit à l'autre ne recharge plus de cadre caché.
77
79
 
78
80
  `sidebarInitScript` (dans le `<head>`, déjà posé par tous les produits) applique cette mémoire sur `<html>` avant la première peinture : `data-hgdc-product`, `data-hgdc-org`, `--hgdc-org-initials`, `--hgdc-org-logo` (adresse `https:` seulement), `--hgdc-nav-height`. Elle vit dans `localStorage` (`hgdc-shell:org`, `hgdc-shell:nav:<produit>`) et `sessionStorage` (`hgdc-shell:switch`) ; rien d'autre que des initiales, un logo et une hauteur.
79
81
 
@@ -135,8 +137,8 @@ Une seule barre latérale (ADR 0024) : organisation et bouton Réduire en haut,
135
137
  | `secret-reveal` | `<SecretReveal label="Clé d'API" value={key} onDismiss={forget} />` : affichée une fois, puis retirée de la page |
136
138
  | `assistant-chat` | `<AssistantConversation><AssistantUserMessage>…</AssistantUserMessage><AssistantReply tools={chips} waiting={pending} actions={<AssistantActionCard title=… status="pending" onConfirm={…} onCancel={…} />}><Markdown>{text}</Markdown></AssistantReply></AssistantConversation>`, `<AssistantComposer value={v} onValueChange={setV} onSubmit={send} busy={busy} />`, `<AssistantExamples examples={…} onPick={setV} />` : le produit rend le Markdown |
137
139
  | `assistant-view` | `<AssistantView status={status} threads={threads} threadId={id} onThreadChange={setId} thread={thread} renderText={(t) => <Markdown>{t}</Markdown>} onSend={send} onConfirm={confirm} onCancel={cancel} />` : le panneau complet pour `AppShell` (`assistant.panel`), aux formes de `@hgdc/assistant/convex` ; `subject` (page ouverte), `request` (demande venue de la page), `labels` (vouvoiement) |
138
- | `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` |
139
- | `page-pending` | `createRouter({ routeTree, ...routerPendingDefaults })` (préchargement au survol, squelette après 1 s, au moins 500 ms, ADR 0025) ; `AppShell {...useRouterState({ select: shellNavigation, structuralSharing: true })}` : une navigation montre la barre de progression, l'ancienne page reste ; `PagePending variant="list" | "dashboard" | "form"` : dans la coque, le squelette de la page ; hors de la coque, le H du document |
140
+ | `family-loader` | `<FamilyLoaderRoot>{children}</FamilyLoaderRoot>` une fois dans le `body` de la route racine : la lueur du chargement de page (`EdgeGlow`, une bande tricolore qui ondule le long de la carte du contenu, WebGL, repli CSS rendu par le serveur), calée sur l'horloge (même phase d'une page et d'un produit à l'autre). `<FamilyLoader size="lg" />` : le H en ligne (une boucle de 2 s, `transform` et `opacity`). Dans un bouton : `Spinner` |
141
+ | `page-pending` | `createRouter({ routeTree, ...routerPendingDefaults })` (préchargement au survol, squelette après 1 s, au moins 500 ms, ADR 0025) ; `AppShell {...useRouterState({ select: shellNavigation, structuralSharing: true })}` : une navigation montre la barre de progression, l'ancienne page reste ; `PagePending variant="list" | "dashboard" | "form"` : dans la coque, le squelette de la page ; hors de la coque, la lueur du document |
140
142
  | `page-skeleton` | `<PageSkeleton variant="dashboard" />` : la forme d'une page qui charge, calée sur `Page` (en-tête, colonne, espacement), pour que rien ne saute à l'arrivée |
141
143
  | `skeleton` | `<Pending pending={isLoading} fallback={<TableSkeleton columns={3} />}>…</Pending>` : après 200 ms, tenu 400 ms au moins |
142
144
 
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Réglages de la lueur, choisis par Hugo le 2026-11-11 : épaisseur 47 px, houle 32 %, vitesse
3
+ * 68 %, pulsation 56 % sur 3 s.
4
+ */
5
+ export declare const EDGE_GLOW: {
6
+ readonly width: 47;
7
+ readonly swell: 0.32;
8
+ readonly speed: 0.68;
9
+ readonly pulse: 0.9;
10
+ readonly pulsePeriodMs: 3000;
11
+ };
12
+ /** Temps des vagues (s) et phase de la pulsation (0 à 1) sur l'horloge : même image partout au même instant. */
13
+ export declare function edgeGlowClock(epochMs: number): {
14
+ time: number;
15
+ pulse: number;
16
+ };
17
+ /**
18
+ * La lueur du chargement de page : une bande tricolore le long du bord de son parent (qui fixe la
19
+ * taille et l'arrondi, `overflow: hidden`). Elle pousse depuis le bord quand elle devient visible et
20
+ * s'y retire quand elle se cache ; l'animation ne tourne que pendant ce temps. Sans WebGL, avant
21
+ * l'hydratation, ou avec le mouvement réduit (une image fixe), le CSS dessine une lueur immobile
22
+ * (`[data-slot="edge-glow"]:not([data-live])`).
23
+ */
24
+ export declare function EdgeGlow(): React.JSX.Element;
@@ -0,0 +1,148 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useRef } from "react";
3
+ /**
4
+ * Réglages de la lueur, choisis par Hugo le 2026-11-11 : épaisseur 47 px, houle 32 %, vitesse
5
+ * 68 %, pulsation 56 % sur 3 s.
6
+ */
7
+ export const EDGE_GLOW = { width: 47, swell: 0.32, speed: 0.68, pulse: 0.9, pulsePeriodMs: 3000 };
8
+ const VERTEX = "attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}";
9
+ /**
10
+ * Une seule bande le long du bord : opaque contre le bord, effacée vers l'intérieur. Son bord
11
+ * intérieur ondule (cinq vagues de vitesses différentes, qui ne se répètent pas), toute la bande
12
+ * respire (pulsation), les couleurs du H tournent. La profondeur est un minimum adouci des quatre
13
+ * distances aux bords : les coins intérieurs restent arrondis quelle que soit l'épaisseur.
14
+ */
15
+ const FRAGMENT = `precision highp float;
16
+ uniform vec2 R;uniform float D,T,W,A,I,P,Q;
17
+ vec3 pal(float x){x=fract(x)*6.;vec3 b=vec3(.173,.471,.992),v=vec3(.451,.263,.996),k=vec3(.996,.322,.573),o=vec3(1.,.604,.275);
18
+ if(x<1.)return mix(b,v,x);if(x<2.)return mix(v,k,x-1.);if(x<3.)return mix(k,o,x-2.);if(x<4.)return mix(o,k,x-3.);if(x<5.)return mix(k,v,x-4.);return mix(v,b,x-5.);}
19
+ void main(){vec2 s=R/D;vec2 p=gl_FragCoord.xy/D;
20
+ float k=14.;float d=-k*log(exp(-p.x/k)+exp(-p.y/k)+exp(-(s.x-p.x)/k)+exp(-(s.y-p.y)/k));
21
+ vec2 c=p-s*.5;float a=atan(c.y/s.y,c.x/s.x);
22
+ float w=.55+A*(.22*sin(2.*a+1.1*T)+.16*sin(3.*a-1.7*T+1.3)+.12*sin(5.*a+2.3*T+.4)+.08*sin(8.*a-3.1*T+2.)+.05*sin(13.*a+4.2*T));
23
+ float br=.5-.5*cos(6.2832*Q);br=br*br*(3.-2.*br);
24
+ float th=max(W*w,W*.12)*I*(1.+P*(br*1.6-.5));
25
+ float f=pow(1.-smoothstep(0.,th,d),1.5);
26
+ float al=f*.92*min(I*1.5,1.)*(.7+.3*br);
27
+ gl_FragColor=vec4(pal(a/6.2832+T*.06)*al,al);}`;
28
+ /** Temps des vagues (s) et phase de la pulsation (0 à 1) sur l'horloge : même image partout au même instant. */
29
+ export function edgeGlowClock(epochMs) {
30
+ // Ramené sous 2π × 1000 : la précision des flottants du GPU reste fine, un saut toutes les 2,5 h.
31
+ const time = ((epochMs / 1000) * EDGE_GLOW.speed) % (Math.PI * 2000);
32
+ const pulse = (((epochMs % EDGE_GLOW.pulsePeriodMs) + EDGE_GLOW.pulsePeriodMs) % EDGE_GLOW.pulsePeriodMs) /
33
+ EDGE_GLOW.pulsePeriodMs;
34
+ return { time, pulse };
35
+ }
36
+ function createRenderer(canvas) {
37
+ const gl = canvas.getContext("webgl", { premultipliedAlpha: true, antialias: false, alpha: true });
38
+ if (!gl)
39
+ return null;
40
+ const program = gl.createProgram();
41
+ for (const [type, source] of [
42
+ [gl.VERTEX_SHADER, VERTEX],
43
+ [gl.FRAGMENT_SHADER, FRAGMENT],
44
+ ]) {
45
+ const shader = gl.createShader(type);
46
+ if (!shader)
47
+ return null;
48
+ gl.shaderSource(shader, source);
49
+ gl.compileShader(shader);
50
+ gl.attachShader(program, shader);
51
+ }
52
+ gl.linkProgram(program);
53
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS))
54
+ return null;
55
+ gl.useProgram(program);
56
+ gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
57
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
58
+ const position = gl.getAttribLocation(program, "p");
59
+ gl.enableVertexAttribArray(position);
60
+ gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
61
+ const u = (name) => gl.getUniformLocation(program, name);
62
+ const [uR, uD, uT, uW, uA, uI, uP, uQ] = ["R", "D", "T", "W", "A", "I", "P", "Q"].map(u);
63
+ return {
64
+ draw(width, height, ratio, intensity, epochMs) {
65
+ const w = Math.round(width * ratio);
66
+ const h = Math.round(height * ratio);
67
+ if (canvas.width !== w || canvas.height !== h) {
68
+ canvas.width = w;
69
+ canvas.height = h;
70
+ gl.viewport(0, 0, w, h);
71
+ }
72
+ gl.clearColor(0, 0, 0, 0);
73
+ gl.clear(gl.COLOR_BUFFER_BIT);
74
+ if (intensity < 0.003)
75
+ return;
76
+ const { time, pulse } = edgeGlowClock(epochMs);
77
+ gl.uniform2f(uR ?? null, w, h);
78
+ gl.uniform1f(uD ?? null, ratio);
79
+ gl.uniform1f(uT ?? null, time);
80
+ gl.uniform1f(uW ?? null, EDGE_GLOW.width);
81
+ gl.uniform1f(uA ?? null, EDGE_GLOW.swell);
82
+ gl.uniform1f(uI ?? null, intensity);
83
+ gl.uniform1f(uP ?? null, EDGE_GLOW.pulse);
84
+ gl.uniform1f(uQ ?? null, pulse);
85
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
86
+ },
87
+ };
88
+ }
89
+ function isShown(element) {
90
+ return typeof element.checkVisibility === "function" ? element.checkVisibility({ visibilityProperty: true }) : true;
91
+ }
92
+ /**
93
+ * La lueur du chargement de page : une bande tricolore le long du bord de son parent (qui fixe la
94
+ * taille et l'arrondi, `overflow: hidden`). Elle pousse depuis le bord quand elle devient visible et
95
+ * s'y retire quand elle se cache ; l'animation ne tourne que pendant ce temps. Sans WebGL, avant
96
+ * l'hydratation, ou avec le mouvement réduit (une image fixe), le CSS dessine une lueur immobile
97
+ * (`[data-slot="edge-glow"]:not([data-live])`).
98
+ */
99
+ export function EdgeGlow() {
100
+ const ref = useRef(null);
101
+ useEffect(() => {
102
+ const canvas = ref.current;
103
+ if (!canvas)
104
+ return;
105
+ const renderer = createRenderer(canvas);
106
+ if (!renderer)
107
+ return;
108
+ const still = window.matchMedia("(prefers-reduced-motion: reduce)");
109
+ canvas.dataset["live"] = "";
110
+ let frame = 0;
111
+ let intensity = 0;
112
+ let last = performance.now();
113
+ const tick = (now) => {
114
+ frame = 0;
115
+ const shown = isShown(canvas);
116
+ const dt = Math.min((now - last) / 1000, 0.05);
117
+ last = now;
118
+ const target = shown ? 1 : 0;
119
+ intensity = still.matches ? target : intensity + (target - intensity) * (1 - Math.exp(-dt * (shown ? 5 : 7)));
120
+ // Mouvement réduit : une image fixe, toujours la même.
121
+ const epoch = still.matches ? 0 : performance.timeOrigin + now;
122
+ const { width, height } = canvas.getBoundingClientRect();
123
+ renderer.draw(width, height, Math.min(window.devicePixelRatio || 1, 1.5), intensity, epoch);
124
+ if (still.matches || (!shown && intensity < 0.003))
125
+ return;
126
+ frame = requestAnimationFrame(tick);
127
+ };
128
+ const wake = (event) => {
129
+ if (event && !(event.target instanceof Element && event.target.contains(canvas)))
130
+ return;
131
+ if (frame)
132
+ return;
133
+ last = performance.now();
134
+ frame = requestAnimationFrame(tick);
135
+ };
136
+ wake();
137
+ // Le chargeur s'affiche par une transition CSS (`:has`) : elle réveille la boucle.
138
+ document.addEventListener("transitionrun", wake);
139
+ const onMotionChange = () => wake();
140
+ still.addEventListener("change", onMotionChange);
141
+ return () => {
142
+ cancelAnimationFrame(frame);
143
+ document.removeEventListener("transitionrun", wake);
144
+ still.removeEventListener("change", onMotionChange);
145
+ };
146
+ }, []);
147
+ return _jsx("canvas", { ref: ref, "data-slot": "edge-glow", "aria-hidden": true });
148
+ }
@@ -26,22 +26,23 @@ export declare const FamilyLoaderRootContext: React.Context<boolean>;
26
26
  * H complet, fondu), calée sur l'horloge. CSS seul (`styles.css`), `transform` et `opacity`
27
27
  * seulement. Mouvement réduit : le H plein, immobile.
28
28
  *
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
29
+ * Un chargement de page ne montre pas ce H : c'est `PagePending`, qui passe par la lueur du
30
+ * document (`FamilyLoaderRoot`, `EdgeGlow`). Ce composant sert au H en
31
31
  * ligne (une carte, un panneau) ; dans un bouton ou une ligne, `Spinner` reste la règle.
32
32
  */
33
33
  export declare function FamilyLoader({ size, label, page, className, }: {
34
34
  /** `sm` 24 px, `md` 40 px, `lg` 64 px. Par défaut `lg` en page, `md` sinon. */
35
35
  size?: keyof typeof SIZES;
36
36
  label?: string;
37
- /** Chargement de toute la page : passe par le H du document, comme `PagePending`. */
37
+ /** Chargement de toute la page : passe par la lueur du document, comme `PagePending`. */
38
38
  page?: boolean;
39
39
  className?: string;
40
40
  }): React.JSX.Element;
41
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.
42
+ * Le chargeur du document : la lueur du bord (`EdgeGlow`), en position fixe sur la carte du contenu
43
+ * de la coque (tout l'écran sur téléphone), toujours montée, que le CSS montre quand une page attend
44
+ * ou que la coque charge. Monté par `FamilyLoaderRoot`, ou, à défaut, par la coque ou par
45
+ * `PagePending` eux-mêmes.
45
46
  */
46
47
  export declare function FamilyLoaderHost(): React.JSX.Element;
47
48
  /**
@@ -54,14 +55,14 @@ export declare function FamilyLoaderHost(): React.JSX.Element;
54
55
  * </body>
55
56
  * ```
56
57
  *
57
- * Il rend le H dans le HTML du serveur, avant l'hydratation, à la même place et à la même phase
58
+ * Il rend la lueur dans le HTML du serveur (repli CSS avant l'hydratation), à la même place et à la même phase
58
59
  * que dans la coque et que dans le produit qu'on vient de quitter (ADR 0015).
59
60
  */
60
61
  export declare function FamilyLoaderRoot({ children }: {
61
62
  children: React.ReactNode;
62
63
  }): React.JSX.Element;
63
64
  /**
64
- * La place d'une page qui attend, hors de la coque : sa seule présence montre le H du document
65
+ * La place d'une page qui attend, hors de la coque : sa seule présence montre la lueur du document
65
66
  * (CSS `:has`), dès le rendu serveur. Sans `FamilyLoaderRoot`, elle monte son propre H.
66
67
  */
67
68
  export declare function PageLoadingPlaceholder({ label, hasRoot, className, }: {
@@ -1,6 +1,7 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { createContext, useContext, useEffect, useLayoutEffect, useRef } from "react";
3
3
  import { cn } from "./cn.js";
4
+ import { EdgeGlow } from "./edge-glow.js";
4
5
  import { useBackgroundOrganizationRefresh } from "./product-switch.js";
5
6
  import { ShellFrame } from "./shell-frame.js";
6
7
  const SIZES = { sm: 24, md: 40, lg: 64 };
@@ -35,12 +36,6 @@ export function syncFamilyLoader(root) {
35
36
  animation.startTime = start;
36
37
  }
37
38
  }
38
- /**
39
- * Même calage, en ligne dans le HTML rendu par le serveur, juste après l'élément : il s'exécute
40
- * avant l'hydratation, une fois la feuille de style chargée (un script attend les feuilles de
41
- * style qui le précèdent). Une animation recréée (mouvement réduit basculé) se recale aussi.
42
- */
43
- 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(String(a.animationName).indexOf("family-loader")===0&&a.startTime!==t)a.startTime=t})}s();e.addEventListener("animationstart",s)})(document.currentScript&&document.currentScript.previousElementSibling)`;
44
39
  const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
45
40
  /** Cale le H sur l'horloge au montage et à chaque animation recréée. */
46
41
  function useClockSync(ref) {
@@ -70,8 +65,8 @@ export const FamilyLoaderRootContext = createContext(false);
70
65
  * H complet, fondu), calée sur l'horloge. CSS seul (`styles.css`), `transform` et `opacity`
71
66
  * seulement. Mouvement réduit : le H plein, immobile.
72
67
  *
73
- * Un chargement de page ne dessine pas ce H lui-même : c'est `PagePending`, qui passe par le H du
74
- * document (`FamilyLoaderRoot`), au même pixel dans tous les états. Ce composant sert au H en
68
+ * Un chargement de page ne montre pas ce H : c'est `PagePending`, qui passe par la lueur du
69
+ * document (`FamilyLoaderRoot`, `EdgeGlow`). Ce composant sert au H en
75
70
  * ligne (une carte, un panneau) ; dans un bouton ou une ligne, `Spinner` reste la règle.
76
71
  */
77
72
  export function FamilyLoader({ size, label = "Chargement…", page = false, className, }) {
@@ -86,14 +81,13 @@ function FamilyMark({ px, label, className, }) {
86
81
  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, {}) }));
87
82
  }
88
83
  /**
89
- * Le H du document : un seul élément, en position fixe sur la scène (la zone de contenu de la
90
- * coque), toujours monté, que le CSS montre quand une page attend ou que la coque charge. Monté par
91
- * `FamilyLoaderRoot`, ou, à défaut, par la coque ou par `PagePending` eux-mêmes.
84
+ * Le chargeur du document : la lueur du bord (`EdgeGlow`), en position fixe sur la carte du contenu
85
+ * de la coque (tout l'écran sur téléphone), toujours montée, que le CSS montre quand une page attend
86
+ * ou que la coque charge. Monté par `FamilyLoaderRoot`, ou, à défaut, par la coque ou par
87
+ * `PagePending` eux-mêmes.
92
88
  */
93
89
  export function FamilyLoaderHost() {
94
- const ref = useRef(null);
95
- useClockSync(ref);
96
- return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: ref, "data-slot": "family-loader-root", "aria-hidden": true, children: [_jsx("div", { "data-frame": true, children: _jsx(ShellFrame, {}) }), _jsx("div", { "data-stage": true, children: _jsx("span", { "data-slot": "family-loader", className: "family-loader", style: { ["--family-loader-size"]: "64px" }, children: _jsx(Mark, {}) }) })] }), _jsx("script", { dangerouslySetInnerHTML: { __html: SYNC_SCRIPT } })] }));
90
+ return (_jsxs("div", { "data-slot": "family-loader-root", "aria-hidden": true, children: [_jsx("div", { "data-frame": true, children: _jsx(ShellFrame, {}) }), _jsx("div", { "data-glow": true, children: _jsx(EdgeGlow, {}) })] }));
97
91
  }
98
92
  /**
99
93
  * Le chargeur du document, à poser une fois dans le `body` de la route racine, autour de l'app :
@@ -105,7 +99,7 @@ export function FamilyLoaderHost() {
105
99
  * </body>
106
100
  * ```
107
101
  *
108
- * Il rend le H dans le HTML du serveur, avant l'hydratation, à la même place et à la même phase
102
+ * Il rend la lueur dans le HTML du serveur (repli CSS avant l'hydratation), à la même place et à la même phase
109
103
  * que dans la coque et que dans le produit qu'on vient de quitter (ADR 0015).
110
104
  */
111
105
  export function FamilyLoaderRoot({ children }) {
@@ -114,7 +108,7 @@ export function FamilyLoaderRoot({ children }) {
114
108
  return (_jsxs(FamilyLoaderRootContext, { value: true, children: [children, _jsx(FamilyLoaderHost, {})] }));
115
109
  }
116
110
  /**
117
- * La place d'une page qui attend, hors de la coque : sa seule présence montre le H du document
111
+ * La place d'une page qui attend, hors de la coque : sa seule présence montre la lueur du document
118
112
  * (CSS `:has`), dès le rendu serveur. Sans `FamilyLoaderRoot`, elle monte son propre H.
119
113
  */
120
114
  export function PageLoadingPlaceholder({ label, hasRoot, className, }) {
@@ -17,6 +17,7 @@ import { ProductBadge } from "./product-badge.js";
17
17
  import { PRODUCT_PRERENDER_ATTRIBUTE } from "./product-switch.js";
18
18
  import { shellProductEntries } from "./products.js";
19
19
  import { familyProductName } from "./product-name.js";
20
+ import { lastOrganization } from "./shell-memory.js";
20
21
  import { useLeaveOnClick } from "./shell-placement.js";
21
22
  import { SIDEBAR_ITEM, SidebarNavBack } from "./sidebar-nav.js";
22
23
  import { useSidebarRail } from "./sidebar-rail.js";
@@ -26,6 +27,10 @@ const noSubscription = () => () => { };
26
27
  function usePageOrigin() {
27
28
  return useSyncExternalStore(noSubscription, () => window.location.origin, () => undefined);
28
29
  }
30
+ /** Dernière organisation ouverte après l'hydratation, `null` au rendu serveur (lien vers l'accueil d'abord). */
31
+ function useLastOrganization() {
32
+ return useSyncExternalStore(noSubscription, lastOrganization, () => null);
33
+ }
29
34
  const GROUP_LABEL = "px-3 pt-3 pb-1.5 text-xs font-medium text-muted-foreground lg:text-shell-muted lg:rail:sr-only";
30
35
  function ShellEntryLink({ entry, icon, onOpenCurrent, }) {
31
36
  const rail = useSidebarRail();
@@ -83,8 +88,9 @@ function ModuleBarEditor({ order, onSave, onCancel, }) {
83
88
  */
84
89
  export function ShellNavigation({ product, nav, accountHref, level, onLevelChange, projectModules, }) {
85
90
  const pageOrigin = usePageOrigin();
91
+ const organization = useLastOrganization();
86
92
  const order = useModuleBar();
87
- const { products, account } = shellProductEntries({ current: product, accountHref, pageOrigin });
93
+ const { products, account } = shellProductEntries({ current: product, accountHref, pageOrigin, organization });
88
94
  return (_jsxs("div", { "data-slot": "shell-navigation", className: "flex min-h-0 flex-1 flex-col", children: [_jsxs("div", { "data-slot": "shell-manager", className: "shrink-0 px-3 pt-1", children: [_jsx("ul", { children: _jsx(ShellEntryLink, { entry: account, icon: UserGroupIcon }) }), _jsx("div", { "aria-hidden": true, className: "mx-2 mt-2 h-px bg-foreground/8" })] }), level === "modules" ? (_jsx(ModuleList, { entries: products, order: shownModules(order, product).filter((word) => !projectModules || word === product || projectModules.includes(word)), onOpenCurrent: () => onLevelChange("tabs"), onCustomize: () => onLevelChange("edit") })) : level === "edit" ? (_jsx(ModuleBarEditor, { order: order, onSave: (next) => {
89
95
  saveModuleBar(next);
90
96
  void pushModuleBar(next);
@@ -13,17 +13,17 @@ export declare const NAVIGATION_PROGRESS_MS = 150;
13
13
  export declare const NAVIGATION_PROGRESS_MIN_MS = 300;
14
14
  /**
15
15
  * Calque de la zone de contenu, sous l'en-tête de la coque (56 px), opaque : il cache la page
16
- * pendant le départ vers un autre module, le H du document par-dessus (ADR 0015, 0020).
16
+ * pendant le départ vers un autre module, la lueur du document par-dessus (ADR 0015, 0020).
17
17
  */
18
18
  export declare const CONTENT_LOADING_LAYER = "absolute inset-x-0 top-14 bottom-0 z-20 bg-background";
19
- /** Vrai dans la zone de contenu d'`AppShell` : une page qui attend y montre son squelette, pas le H. */
19
+ /** Vrai dans la zone de contenu d'`AppShell` : une page qui attend y montre son squelette, pas la lueur. */
20
20
  export declare const ShellContentContext: React.Context<boolean>;
21
21
  /**
22
22
  * Le chargement d'une page.
23
23
  *
24
24
  * - **Dans la coque** (`AppShell`) : le squelette de la page (`PageSkeleton`), la coque restant en
25
- * place. Jamais le H : il est réservé au document qui n'a pas encore de coque (ADR 0025).
26
- * - **Hors de la coque** (page d'entrée, rendu serveur sans coque) : le H du document
25
+ * place. Jamais la lueur : elle est réservée au document qui n'a pas encore de coque (ADR 0025).
26
+ * - **Hors de la coque** (page d'entrée, rendu serveur sans coque) : la lueur du document
27
27
  * (`FamilyLoaderRoot`), au même pixel que dans la coque (ADR 0015).
28
28
  *
29
29
  * Composant par défaut de `pendingComponent` (`routerPendingDefaults`) et repli de la frontière
@@ -16,17 +16,17 @@ export const NAVIGATION_PROGRESS_MS = 150;
16
16
  export const NAVIGATION_PROGRESS_MIN_MS = 300;
17
17
  /**
18
18
  * Calque de la zone de contenu, sous l'en-tête de la coque (56 px), opaque : il cache la page
19
- * pendant le départ vers un autre module, le H du document par-dessus (ADR 0015, 0020).
19
+ * pendant le départ vers un autre module, la lueur du document par-dessus (ADR 0015, 0020).
20
20
  */
21
21
  export const CONTENT_LOADING_LAYER = "absolute inset-x-0 top-14 bottom-0 z-20 bg-background";
22
- /** Vrai dans la zone de contenu d'`AppShell` : une page qui attend y montre son squelette, pas le H. */
22
+ /** Vrai dans la zone de contenu d'`AppShell` : une page qui attend y montre son squelette, pas la lueur. */
23
23
  export const ShellContentContext = createContext(false);
24
24
  /**
25
25
  * Le chargement d'une page.
26
26
  *
27
27
  * - **Dans la coque** (`AppShell`) : le squelette de la page (`PageSkeleton`), la coque restant en
28
- * place. Jamais le H : il est réservé au document qui n'a pas encore de coque (ADR 0025).
29
- * - **Hors de la coque** (page d'entrée, rendu serveur sans coque) : le H du document
28
+ * place. Jamais la lueur : elle est réservée au document qui n'a pas encore de coque (ADR 0025).
29
+ * - **Hors de la coque** (page d'entrée, rendu serveur sans coque) : la lueur du document
30
30
  * (`FamilyLoaderRoot`), au même pixel que dans la coque (ADR 0015).
31
31
  *
32
32
  * Composant par défaut de `pendingComponent` (`routerPendingDefaults`) et repli de la frontière
@@ -4,6 +4,15 @@ export declare const ORG_SYNC_COOKIE = "hgdc-org-sync";
4
4
  export declare const BACKGROUND_REFRESH_PATH = "/auth/hgdc?refresh=1&frame=1";
5
5
  /** Abandon du cadre s'il ne s'est pas chargé : Account injoignable, réseau coupé. */
6
6
  export declare const BACKGROUND_REFRESH_TIMEOUT_MS = 20000;
7
+ /**
8
+ * Une relecture au plus par produit pendant ce délai. Elle refait une connexion complète chez ID
9
+ * (environ 3 s côté serveur, pendant le chargement de la page) : la refaire à chaque passage d'un
10
+ * produit à l'autre ralentissait la navigation (2026-11-11). Les changements d'appartenance arrivent
11
+ * déjà en direct par les événements d'Account ; la relecture n'est qu'un filet.
12
+ */
13
+ export declare const BACKGROUND_REFRESH_INTERVAL_MS: number;
14
+ /** Heure de la dernière relecture d'un produit, en `localStorage` : `hgdc-org-sync:<base>` (`/` à la racine). */
15
+ export declare const ORG_SYNC_LAST_KEY_PREFIX = "hgdc-org-sync:";
7
16
  /** Marque d'un lien de la barre vers un autre produit servi sous la même origine : prérendu au survol. */
8
17
  export declare const PRODUCT_PRERENDER_ATTRIBUTE = "data-hgdc-prerender";
9
18
  /**
@@ -30,12 +39,13 @@ export declare function takeOrgSync(doc?: Document): string | null;
30
39
  /** Lit et efface le cookie de relecture. Vrai s'il était posé. */
31
40
  export declare function takeOrgSyncCookie(doc?: Document): boolean;
32
41
  /**
33
- * Relit les organisations dans un cadre caché, une fois, si l'entrée l'a demandé. Le cadre n'a
34
- * ni script ni formulaire (`sandbox`), il suit seulement les redirections d'Account ; la nouvelle
35
- * session du produit recopie les organisations, et les requêtes Convex abonnées se mettent à jour.
36
- * Si la session d'Account a expiré, Account refuse d'être affiché dans un cadre : rien ne se passe,
37
- * la session du produit reste celle d'avant. Une page prérendue au survol attend d'être affichée :
38
- * un survol sans clic ne relit rien.
42
+ * Relit les organisations dans un cadre caché, une fois, si l'entrée l'a demandé et que la dernière
43
+ * relecture de ce produit date de plus de `BACKGROUND_REFRESH_INTERVAL_MS`. Le cadre n'a ni script ni
44
+ * formulaire (`sandbox`), il suit seulement les redirections d'Account ; la nouvelle session du
45
+ * produit recopie les organisations, et les requêtes Convex abonnées se mettent à jour. Si la session
46
+ * d'Account a expiré, Account refuse d'être affiché dans un cadre : rien ne se passe, la session du
47
+ * produit reste celle d'avant. Une page prérendue au survol attend d'être affichée : un survol sans
48
+ * clic ne relit rien.
39
49
  */
40
50
  export declare function startBackgroundRefresh(doc?: Document): HTMLIFrameElement | null;
41
51
  /**
@@ -15,6 +15,15 @@ export const ORG_SYNC_COOKIE = "hgdc-org-sync";
15
15
  export const BACKGROUND_REFRESH_PATH = "/auth/hgdc?refresh=1&frame=1";
16
16
  /** Abandon du cadre s'il ne s'est pas chargé : Account injoignable, réseau coupé. */
17
17
  export const BACKGROUND_REFRESH_TIMEOUT_MS = 20_000;
18
+ /**
19
+ * Une relecture au plus par produit pendant ce délai. Elle refait une connexion complète chez ID
20
+ * (environ 3 s côté serveur, pendant le chargement de la page) : la refaire à chaque passage d'un
21
+ * produit à l'autre ralentissait la navigation (2026-11-11). Les changements d'appartenance arrivent
22
+ * déjà en direct par les événements d'Account ; la relecture n'est qu'un filet.
23
+ */
24
+ export const BACKGROUND_REFRESH_INTERVAL_MS = 10 * 60_000;
25
+ /** Heure de la dernière relecture d'un produit, en `localStorage` : `hgdc-org-sync:<base>` (`/` à la racine). */
26
+ export const ORG_SYNC_LAST_KEY_PREFIX = "hgdc-org-sync:";
18
27
  /** Marque d'un lien de la barre vers un autre produit servi sous la même origine : prérendu au survol. */
19
28
  export const PRODUCT_PRERENDER_ATTRIBUTE = "data-hgdc-prerender";
20
29
  /**
@@ -83,12 +92,33 @@ export function takeOrgSyncCookie(doc = document) {
83
92
  return takeOrgSync(doc) !== null;
84
93
  }
85
94
  /**
86
- * Relit les organisations dans un cadre caché, une fois, si l'entrée l'a demandé. Le cadre n'a
87
- * ni script ni formulaire (`sandbox`), il suit seulement les redirections d'Account ; la nouvelle
88
- * session du produit recopie les organisations, et les requêtes Convex abonnées se mettent à jour.
89
- * Si la session d'Account a expiré, Account refuse d'être affiché dans un cadre : rien ne se passe,
90
- * la session du produit reste celle d'avant. Une page prérendue au survol attend d'être affichée :
91
- * un survol sans clic ne relit rien.
95
+ * Vrai si la relecture de ce produit est due, et note alors l'heure : au plus une toutes les
96
+ * `BACKGROUND_REFRESH_INTERVAL_MS`. Sans `localStorage` (navigation privée stricte), toujours due.
97
+ */
98
+ function refreshDue(base, win, now = Date.now()) {
99
+ const key = `${ORG_SYNC_LAST_KEY_PREFIX}${base || "/"}`;
100
+ try {
101
+ const storage = win?.localStorage;
102
+ if (!storage)
103
+ return true;
104
+ const last = Number(storage.getItem(key));
105
+ if (Number.isFinite(last) && last > 0 && now - last >= 0 && now - last < BACKGROUND_REFRESH_INTERVAL_MS)
106
+ return false;
107
+ storage.setItem(key, String(now));
108
+ }
109
+ catch {
110
+ // Stockage refusé : la relecture part, comme avant.
111
+ }
112
+ return true;
113
+ }
114
+ /**
115
+ * Relit les organisations dans un cadre caché, une fois, si l'entrée l'a demandé et que la dernière
116
+ * relecture de ce produit date de plus de `BACKGROUND_REFRESH_INTERVAL_MS`. Le cadre n'a ni script ni
117
+ * formulaire (`sandbox`), il suit seulement les redirections d'Account ; la nouvelle session du
118
+ * produit recopie les organisations, et les requêtes Convex abonnées se mettent à jour. Si la session
119
+ * d'Account a expiré, Account refuse d'être affiché dans un cadre : rien ne se passe, la session du
120
+ * produit reste celle d'avant. Une page prérendue au survol attend d'être affichée : un survol sans
121
+ * clic ne relit rien.
92
122
  */
93
123
  export function startBackgroundRefresh(doc = document) {
94
124
  if (doc.prerendering) {
@@ -96,7 +126,7 @@ export function startBackgroundRefresh(doc = document) {
96
126
  return null;
97
127
  }
98
128
  const base = takeOrgSync(doc);
99
- if (base === null)
129
+ if (base === null || !refreshDue(base, doc.defaultView))
100
130
  return null;
101
131
  const frame = doc.createElement("iframe");
102
132
  frame.hidden = true;
@@ -65,13 +65,19 @@ export type ProductOrigin = {
65
65
  export declare const PRODUCT_ORIGINS: Readonly<Record<string, ProductOrigin>>;
66
66
  /** Adresse de l'app d'un produit : celle de `PRODUCT_ORIGINS`, sinon `https://<mot>.hgdc.ch`. */
67
67
  export declare function productOrigin(word: string, origins?: Readonly<Record<string, ProductOrigin>>): ProductOrigin;
68
- /** Entrée dans l'app d'un produit avec la session HGDC : `https://app.hgdc.ch/analytics/auth/hgdc?refresh=background&next=/`. */
69
- export declare function productEntryHref(word: string, origins?: Readonly<Record<string, ProductOrigin>>): string;
68
+ /**
69
+ * Entrée dans l'app d'un produit avec la session HGDC : `https://app.hgdc.ch/analytics/auth/hgdc?refresh=background&next=/`.
70
+ * Avec `organization` (slug de la dernière organisation ouverte), l'entrée mène droit à
71
+ * `/o/<organisation>` : sans les redirections de l'accueil (`/`, puis `/dashboard` ou `/o/…`).
72
+ */
73
+ export declare function productEntryHref(word: string, origins?: Readonly<Record<string, ProductOrigin>>, organization?: string | null): string;
70
74
  /**
71
75
  * Requête de l'entrée depuis un autre produit : arriver sur l'accueil, relire les organisations en
72
76
  * arrière-plan (`refresh=background`, ADR 0016).
73
77
  */
74
78
  export declare const PRODUCT_ENTRY_REFRESH_QUERY = "refresh=background&next=/";
79
+ /** Requête de l'entrée : `refresh=background` toujours en tête (règles de prérendu), `next` vers l'organisation si connue. */
80
+ export declare function productEntryQuery(word: string, organization?: string | null): string;
75
81
  export declare const HGDC_ACCOUNT: HgdcCatalogProduct;
76
82
  /** Manager : les organisations, leurs membres et leurs rôles. En tête du menu et de la barre des produits. */
77
83
  export declare const HGDC_MANAGER: HgdcCatalogProduct;
@@ -137,6 +143,8 @@ export declare function shellProductEntries(options?: {
137
143
  accountHref?: string | undefined;
138
144
  catalog?: ReadonlyArray<HgdcCatalogProduct> | undefined;
139
145
  pageOrigin?: string | undefined;
146
+ /** Dernière organisation ouverte (`lastOrganization()`, absente au rendu serveur) : l'entrée y mène droit. */
147
+ organization?: string | null | undefined;
140
148
  }): {
141
149
  products: Array<ShellProductEntry>;
142
150
  account: ShellProductEntry;
package/dist/products.js CHANGED
@@ -37,16 +37,32 @@ export function productOrigin(word, origins = PRODUCT_ORIGINS) {
37
37
  const key = word.toLowerCase();
38
38
  return Object.hasOwn(origins, key) ? origins[key] : { origin: `https://${key}.hgdc.ch`, basePath: "" };
39
39
  }
40
- /** Entrée dans l'app d'un produit avec la session HGDC : `https://app.hgdc.ch/analytics/auth/hgdc?refresh=background&next=/`. */
41
- export function productEntryHref(word, origins = PRODUCT_ORIGINS) {
40
+ /**
41
+ * Entrée dans l'app d'un produit avec la session HGDC : `https://app.hgdc.ch/analytics/auth/hgdc?refresh=background&next=/`.
42
+ * Avec `organization` (slug de la dernière organisation ouverte), l'entrée mène droit à
43
+ * `/o/<organisation>` : sans les redirections de l'accueil (`/`, puis `/dashboard` ou `/o/…`).
44
+ */
45
+ export function productEntryHref(word, origins = PRODUCT_ORIGINS, organization) {
42
46
  const app = productOrigin(word, origins);
43
- return `${app.origin}${app.basePath}${PRODUCT_ENTRY_PATH}?${PRODUCT_ENTRY_REFRESH_QUERY}`;
47
+ return `${app.origin}${app.basePath}${PRODUCT_ENTRY_PATH}?${productEntryQuery(word, organization)}`;
44
48
  }
45
49
  /**
46
50
  * Requête de l'entrée depuis un autre produit : arriver sur l'accueil, relire les organisations en
47
51
  * arrière-plan (`refresh=background`, ADR 0016).
48
52
  */
49
53
  export const PRODUCT_ENTRY_REFRESH_QUERY = "refresh=background&next=/";
54
+ /** Slug d'organisation tel que les produits l'écrivent dans `/o/<slug>` : rien d'autre ne part dans `next`. */
55
+ const ORGANIZATION_SLUG = /^[a-z0-9][a-z0-9-]{0,62}$/;
56
+ /** Produits dont `/o/<slug>` est la page d'une organisation. Manager les désigne par leur identifiant. */
57
+ function opensOrganizationBySlug(word) {
58
+ return word.toLowerCase() !== "manager";
59
+ }
60
+ /** Requête de l'entrée : `refresh=background` toujours en tête (règles de prérendu), `next` vers l'organisation si connue. */
61
+ export function productEntryQuery(word, organization) {
62
+ if (!organization || !ORGANIZATION_SLUG.test(organization) || !opensOrganizationBySlug(word))
63
+ return PRODUCT_ENTRY_REFRESH_QUERY;
64
+ return `refresh=background&next=${encodeURIComponent(`/o/${organization}`)}`;
65
+ }
50
66
  function product(word, pitch, icon, stage,
51
67
  /** Nom affiché quand il diffère du mot de l'adresse : le nom du module (« Déploiements » pour `host`). */
52
68
  name = word) {
@@ -147,7 +163,7 @@ export function sameOriginPath(href, pageOrigin) {
147
163
  */
148
164
  export function shellProductEntries(options = {}) {
149
165
  const current = options.current?.toLowerCase();
150
- const { pageOrigin } = options;
166
+ const { pageOrigin, organization } = options;
151
167
  const entry = (item, href, product) => {
152
168
  const base = {
153
169
  name: item.name,
@@ -165,7 +181,9 @@ export function shellProductEntries(options = {}) {
165
181
  const head = manager ?? HGDC_MANAGER;
166
182
  const local = localManagerHref(options.accountHref);
167
183
  return {
168
- products: others.filter(isProductOpen).map((item) => entry(item, item.entryHref, true)),
184
+ products: others
185
+ .filter(isProductOpen)
186
+ .map((item) => entry(item, organization ? productEntryHref(item.badge, PRODUCT_ORIGINS, organization) : item.entryHref, true)),
169
187
  account: entry(head, local ?? head.entryHref, local === undefined),
170
188
  };
171
189
  }
package/dist/spinner.d.ts CHANGED
@@ -6,10 +6,11 @@ declare const SIZES: {
6
6
  readonly 8: "size-8";
7
7
  };
8
8
  /**
9
- * L'anneau d'attente de la DA : un arc arrondi qui tourne sur une piste à peine visible, le
10
- * même que la page d'attente du portail. Dans un bouton ou une ligne, il prend la couleur du
11
- * texte (`tone="current"`) ; seul sur une page, il prend l'accent (`tone="accent"`).
12
- * Mouvement réduit : l'arc ne tourne plus, il respire.
9
+ * L'anneau d'attente de la DA : un arc arrondi sur une piste à peine visible. La tête avance
10
+ * toujours, la queue la rattrape, puis l'arc repart d'un point ; l'anneau tourne lentement en même
11
+ * temps (`styles.css`, `.spinner-*`). Dans un bouton ou une ligne, il prend la couleur du texte
12
+ * (`tone="current"`) ; seul sur une page, les couleurs du H (`tone="accent"`).
13
+ * Mouvement réduit : un arc fixe qui respire.
13
14
  */
14
15
  export declare function Spinner({ className, label, size, tone, }: {
15
16
  className?: string;
package/dist/spinner.js CHANGED
@@ -1,12 +1,15 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId } from "react";
2
3
  import { cn } from "./cn.js";
3
4
  const SIZES = { 3: "size-3", 4: "size-4", 5: "size-5", 6: "size-6", 8: "size-8" };
4
5
  /**
5
- * L'anneau d'attente de la DA : un arc arrondi qui tourne sur une piste à peine visible, le
6
- * même que la page d'attente du portail. Dans un bouton ou une ligne, il prend la couleur du
7
- * texte (`tone="current"`) ; seul sur une page, il prend l'accent (`tone="accent"`).
8
- * Mouvement réduit : l'arc ne tourne plus, il respire.
6
+ * L'anneau d'attente de la DA : un arc arrondi sur une piste à peine visible. La tête avance
7
+ * toujours, la queue la rattrape, puis l'arc repart d'un point ; l'anneau tourne lentement en même
8
+ * temps (`styles.css`, `.spinner-*`). Dans un bouton ou une ligne, il prend la couleur du texte
9
+ * (`tone="current"`) ; seul sur une page, les couleurs du H (`tone="accent"`).
10
+ * Mouvement réduit : un arc fixe qui respire.
9
11
  */
10
12
  export function Spinner({ className, label, size = 4, tone = "current", }) {
11
- return (_jsxs("span", { role: "status", className: cn("inline-flex items-center gap-2", className), children: [_jsxs("svg", { viewBox: "0 0 32 32", fill: "none", "aria-hidden": true, className: cn("shrink-0", SIZES[size]), children: [_jsx("circle", { cx: "16", cy: "16", r: "13", strokeWidth: "4", className: "stroke-current opacity-15" }), _jsx("circle", { cx: "16", cy: "16", r: "13", strokeWidth: "4", strokeLinecap: "round", strokeDasharray: "22 60", className: cn("origin-center animate-spin motion-reduce:animate-pulse", tone === "accent" ? "stroke-accent" : "stroke-current") })] }), label ? _jsx("span", { children: label }) : _jsx("span", { className: "sr-only", children: "Chargement" })] }));
13
+ const gradient = useId();
14
+ return (_jsxs("span", { role: "status", className: cn("inline-flex items-center gap-2", className), children: [_jsxs("svg", { viewBox: "0 0 32 32", fill: "none", "aria-hidden": true, className: cn("shrink-0", SIZES[size]), children: [tone === "accent" ? (_jsx("defs", { children: _jsxs("linearGradient", { id: gradient, x1: "0", y1: "0", x2: "1", y2: "1", children: [_jsx("stop", { offset: "0", stopColor: "#2c78fd" }), _jsx("stop", { offset: "0.45", stopColor: "#7343fe" }), _jsx("stop", { offset: "0.8", stopColor: "#fe5292" }), _jsx("stop", { offset: "1", stopColor: "#ff9a46" })] }) })) : null, _jsx("circle", { cx: "16", cy: "16", r: "12", strokeWidth: "4", className: "stroke-current opacity-15" }), _jsx("g", { className: "spinner-rotor", children: _jsx("circle", { cx: "16", cy: "16", r: "12", strokeWidth: "4", strokeLinecap: "round", pathLength: 100, transform: "rotate(-90 16 16)", className: cn("spinner-arc", tone === "accent" ? null : "stroke-current"), ...(tone === "accent" ? { stroke: `url(#${gradient})` } : {}) }) })] }), label ? _jsx("span", { children: label }) : _jsx("span", { className: "sr-only", children: "Chargement" })] }));
12
15
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hgdc/ui",
3
- "version": "0.71.0",
3
+ "version": "0.72.1",
4
4
  "description": "Interface de la H Collection : jetons de la DA, thèmes, polices et composants.",
5
5
  "license": "UNLICENSED",
6
6
  "homepage": "https://github.com/hugogebel-boop/hgdc-kit/tree/main/packages/ui#readme",
package/styles.css CHANGED
@@ -558,6 +558,50 @@
558
558
  animation: rise 420ms var(--ease-ios) both;
559
559
  }
560
560
 
561
+ /* L'anneau d'attente (`Spinner`) : la tête de l'arc avance de 1 à 99 % du tour pendant que la
562
+ queue reste, puis la queue la rattrape (la tête ne recule jamais) ; l'anneau tourne lentement,
563
+ chaque départ tombe un peu plus loin. */
564
+ @keyframes spinner-arc {
565
+ 0% {
566
+ stroke-dasharray: 1 150;
567
+ stroke-dashoffset: 0;
568
+ }
569
+ 50% {
570
+ stroke-dasharray: 71 150;
571
+ stroke-dashoffset: -28;
572
+ }
573
+ 100% {
574
+ stroke-dasharray: 71 150;
575
+ stroke-dashoffset: -99;
576
+ }
577
+ }
578
+ @keyframes spinner-rotor {
579
+ to {
580
+ transform: rotate(360deg);
581
+ }
582
+ }
583
+ @keyframes spinner-breathe {
584
+ 50% {
585
+ opacity: 0.45;
586
+ }
587
+ }
588
+ .spinner-rotor {
589
+ transform-origin: 50% 50%;
590
+ animation: spinner-rotor 1.6s linear infinite;
591
+ }
592
+ .spinner-arc {
593
+ animation: spinner-arc 1.2s cubic-bezier(0.4, 0, 0.2, 1) infinite;
594
+ }
595
+ @media (prefers-reduced-motion: reduce) {
596
+ .spinner-rotor {
597
+ animation: none;
598
+ }
599
+ .spinner-arc {
600
+ stroke-dasharray: 30 150;
601
+ animation: spinner-breathe 1.6s ease-in-out infinite;
602
+ }
603
+ }
604
+
561
605
  @media (prefers-reduced-motion: reduce) {
562
606
  .soft-in,
563
607
  .pop-in,
@@ -792,15 +836,15 @@
792
836
  }
793
837
  }
794
838
 
795
- /* Le chargeur du document (`FamilyLoaderRoot`) : un seul H par page, en position fixe sur la scène.
796
- Visible quand une page attend (`PagePending`) hors de la coque, ou quand la coque part vers un
797
- autre module (`data-loading`). Dans la coque, une page qui attend montre son squelette, jamais
798
- le H (ADR 0025). Masqué avec 80 ms de retenue : un `PagePending` qui laisse la place à un autre ne fait
799
- pas clignoter le H. */
839
+ /* Le chargeur du document (`FamilyLoaderRoot`) : une seule lueur par page, en position fixe sur la
840
+ carte du contenu, au-dessus de la barre du haut (`z-30`) pour en faire tout le tour. Visible quand une page attend (`PagePending`) hors de la coque, ou quand la
841
+ coque part vers un autre module (`data-loading`). Dans la coque, une page qui attend montre son
842
+ squelette, jamais la lueur (ADR 0025). Masqué avec 80 ms de retenue : un `PagePending` qui laisse
843
+ la place à un autre ne fait pas clignoter la lueur. */
800
844
  [data-slot="family-loader-root"] {
801
845
  position: fixed;
802
846
  inset: 0;
803
- z-index: 25;
847
+ z-index: 35;
804
848
  pointer-events: none;
805
849
  opacity: 0;
806
850
  visibility: hidden;
@@ -816,14 +860,33 @@
816
860
  opacity 150ms var(--ease-out),
817
861
  visibility 0s;
818
862
  }
819
- [data-slot="family-loader-root"] > [data-stage] {
863
+ /* La lueur du chargement (`EdgeGlow`) : tout l'écran sur téléphone ; sur ordinateur, la carte du
864
+ contenu de la coque (marge de 8 px, `rounded-xl`), en-tête compris. */
865
+ [data-slot="family-loader-root"] > [data-glow] {
820
866
  position: fixed;
821
- top: var(--hgdc-stage-top);
822
- right: var(--hgdc-stage-right);
823
- bottom: var(--hgdc-stage-bottom);
824
- left: var(--hgdc-stage-left);
825
- display: grid;
826
- place-items: center;
867
+ inset: 0;
868
+ overflow: hidden;
869
+ }
870
+ @media (width >= 64rem) {
871
+ [data-slot="family-loader-root"] > [data-glow] {
872
+ inset: 0.5rem 0.5rem 0.5rem var(--hgdc-content-left);
873
+ border-radius: var(--radius-xl);
874
+ }
875
+ }
876
+ [data-slot="edge-glow"] {
877
+ display: block;
878
+ width: 100%;
879
+ height: 100%;
880
+ }
881
+ /* Sans WebGL ou avant l'hydratation : une lueur immobile, mêmes couleurs, dessinée par le CSS. */
882
+ [data-slot="edge-glow"]:not([data-live]) {
883
+ padding: 1.5rem;
884
+ background: conic-gradient(#2c78fd, #7343fe, #fe5292, #ff9a46, #fe5292, #7343fe, #2c78fd);
885
+ mask:
886
+ linear-gradient(#000 0 0) content-box exclude,
887
+ linear-gradient(#000 0 0);
888
+ filter: blur(1.25rem);
889
+ opacity: 0.6;
827
890
  }
828
891
  /* Hors de la coque, le cadre de la coque : la page qui arrive a déjà sa forme, le H sa place. */
829
892
  [data-slot="family-loader-root"] > [data-frame] {
@@ -877,7 +940,7 @@
877
940
  }
878
941
 
879
942
  /* Calque de la zone de contenu (`AppShell`) pendant le départ vers un autre module : opaque,
880
- au-dessus de la page, sous le H du document. Même rythme que le H. */
943
+ au-dessus de la page, sous la lueur du document. Même rythme que la lueur. */
881
944
  [data-slot="app-shell-loading"] {
882
945
  opacity: 0;
883
946
  visibility: hidden;
@@ -970,7 +1033,7 @@
970
1033
  }
971
1034
  :root:has([data-slot="page-pending"]):not(:has([data-slot="app-shell"])),
972
1035
  :root:has([data-slot="app-shell"][data-loading]) {
973
- & [data-slot="family-loader-root"] > [data-stage] {
1036
+ & [data-slot="family-loader-root"] > [data-glow] {
974
1037
  view-transition-name: hgdc-loader;
975
1038
  }
976
1039
  }