PageSourceSearch

https://lorvert-paris.com/confidentialite

html lorvert-paris.com collected 2026-10-08 05:51:46 UTC 185,151 bytes, 3,704 lines download raw bytes

1
2
3
4<!DOCTYPE html>
5
6
7
8  
9  
10
11<html lang="fr" data-theme="light">
12<head>
13  <meta charset="UTF-8">
14  <meta http-equiv="X-UA-Compatible" content="IE=edge">
15  <meta name="viewport" content="width=device-width, initial-scale=1">
16  <title>
17  Politique de confidentialité
18  — L&#39;Or Vert
19</title>
20
21  
22  
23  
24  
25  
26  
27  <meta name="description" content="Politique de confidentialité : données collectées, finalités, durées de conservation, cookies et vos droits RGPD (accès, rectification, effacement).">
28<meta name="robots" content="index,follow">
29<link rel="canonical" href="https://lorvert-paris.com/confidentialite">
30<meta property="og:title" content="Politique de confidentialité">
31<meta property="og:description" content="Politique de confidentialité : données collectées, finalités, durées de conservation, cookies et vos droits RGPD (accès, rectification, effacement).">
32<meta property="og:type" content="website">
33<meta property="og:url" content="https://lorvert-paris.com/confidentialite">
34<meta name="twitter:card" content="summary_large_image">
35<meta name="twitter:title" content="Politique de confidentialité">
36<meta name="twitter:description" content="Politique de confidentialité : données collectées, finalités, durées de conservation, cookies et vos droits RGPD (accès, rectification, effacement).">
37<meta name="author" content="L&#x27;Or Vert">
38  
39  
40
41  
42  
42<script>
43    (function(){
44      var u=new URLSearchParams(location.search), q=u.get('theme');
45      var saved=localStorage.getItem('gk-theme');
46      var siteDefault=null;
47      var prefDark=window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)');
48      function osTheme(){return (prefDark && prefDark.matches) ? 'dark' : 'light';}
49      // siteDefault wins over saved — brand identity is non-negotiable
50      // for sites that declare a default_theme; user's per-product
51      // preference still applies on sites without a pin.
52      var t=(q==='dark'||q==='light')?q:(siteDefault||saved||osTheme());
53      document.documentElement.setAttribute('data-theme',t);
54      if (prefDark && prefDark.addEventListener){
55        prefDark.addEventListener('change',function(){
56          if (!localStorage.getItem('gk-theme') && !siteDefault){
57            document.documentElement.setAttribute('data-theme', osTheme());
58          }
59        });
60      }
61      var a=u.get('accent');
62      if(a&&/^#[0-9a-f]{3,8}$/i.test(a)){
63        var s=document.createElement('style');
64        s.textContent=':root{--accent:'+a+'!important;--gk-accent:'+a+'!important;--gk-primary:'+a+'!important;--color-accent-blue:'+a+'!important;}';
65        document.head.appendChild(s);
66      }
67    })();
68  </script>
68
69
70  
71
72
73<meta name="theme-color" content="#1f7a3a" />
74
75
76
77
78
79<link rel="icon" href="/lorvert-paris-static/img/favicon.png?v=prod-20260926-a" />
80
81<link rel="manifest" href="/manifest.webmanifest" />
82
83
84<link rel="apple-touch-icon" href="/lorvert-paris-static/img/apple-touch-icon.png?v=prod-20260926-a" />
85<meta name="apple-mobile-web-app-capable" content="yes" />
86<meta name="mobile-web-app-capable" content="yes" />
87
88
89
90
91<style>
92:root {
93  --role-color-maintainer: #0ea5e9;
94  --role-color-owner: #3b82f6;
95  --role-color-salesrep: #0284c7;
96  --role-color-desk: #16a34a;
97  --role-color-client: #22c55e;
98  --role-color-agent: #f3c84b;
99  --role-color-user: #94a3b8;
100  
101}
102
103[data-role="maintainer"],
104.role-pill--maintainer,
105.usr-role.maintainer,
106.gk-member-role--maintainer,
107.feat-role-pill[data-role="maintainer"],
108.pill--role-maintainer {
109  --role-color: #0ea5e9;
110}
111
112[data-role="owner"],
113.role-pill--owner,
114.usr-role.owner,
115.gk-member-role--owner,
116.feat-role-pill[data-role="owner"],
117.pill--role-owner {
118  --role-color: #3b82f6;
119}
120
121[data-role="salesrep"],
122.role-pill--salesrep,
123.usr-role.salesrep,
124.gk-member-role--salesrep,
125.feat-role-pill[data-role="salesrep"],
126.pill--role-salesrep {
127  --role-color: #0284c7;
128}
129
130[data-role="desk"],
131.role-pill--desk,
132.usr-role.desk,
133.gk-member-role--desk,
134.feat-role-pill[data-role="desk"],
135.pill--role-desk {
136  --role-color: #16a34a;
137}
138
139[data-role="client"],
140.role-pill--client,
141.usr-role.client,
142.gk-member-role--client,
143.feat-role-pill[data-role="client"],
144.pill--role-client {
145  --role-color: #22c55e;
146}
147
148[data-role="agent"],
149.role-pill--agent,
150.usr-role.agent,
151.gk-member-role--agent,
152.feat-role-pill[data-role="agent"],
153.pill--role-agent {
154  --role-color: #f3c84b;
155}
156
157[data-role="user"],
158.role-pill--user,
159.usr-role.user,
160.gk-member-role--user,
161.feat-role-pill[data-role="user"],
162.pill--role-user {
163  --role-color: #94a3b8;
164}
165
166</style>
167<script>
168  // Single global — every JS consumer reads from here.
169  // Shape: [{role, color, icon, label, description, assignable, priority}]
170  window.GK_ROLE_CATALOG = [{"assignable": true, "color": "#0ea5e9", "description": "Full admin \u2014 member / config management", "icon": "fa-solid fa-screwdriver-wrench", "label": "Maintainer", "priority": 10, "role": "maintainer", "share": false}, {"assignable": true, "color": "#3b82f6", "description": "Product owner \u2014 top governance", "icon": "fa-solid fa-crown", "label": "Owner", "priority": 20, "role": "owner", "share": false}, {"assignable": true, "color": "#0284c7", "description": "Commercial \u2014 sales representative", "icon": "fa-solid fa-handshake", "label": "Commercial", "priority": 30, "role": "salesrep", "share": false}, {"assignable": true, "color": "#16a34a", "description": "Accueil \u2014 front desk / reception", "icon": "fa-solid fa-desktop", "label": "Accueil", "priority": 40, "role": "desk", "share": false}, {"assignable": true, "color": "#22c55e", "description": "Wholesale buyer", "icon": "fa-solid fa-briefcase", "label": "Client", "priority": 50, "role": "client", "share": true}, {"assignable": true, "color": "#f3c84b", "description": "AI / automation", "icon": "fa-solid fa-robot", "label": "Agent", "priority": 60, "role": "agent", "share": false}, {"assignable": true, "color": "#94a3b8", "description": "Default end user", "icon": "fa-regular fa-user", "label": "User", "priority": 70, "role": "user", "share": false}];
171  window.GK_ROLE = (function () {
172    var map = {};
173    window.GK_ROLE_CATALOG.forEach(function (r) { map[r.role] = r; });
174    return {
175      all:    function ()     { return window.GK_ROLE_CATALOG; },
176      names:  function ()     { return window.GK_ROLE_CATALOG.filter(function (r) { return r.assignable !== false; }).map(function (r) { return r.role; }); },
177      color:  function (role) { return (map[role] && map[role].color) || '#94a3b8'; },
178      icon:   function (role) { return (map[role] && map[role].icon)  || 'fa-regular fa-user'; },
179      label:  function (role) { return (map[role] && map[role].label) || (role || '').charAt(0).toUpperCase() + (role || '').slice(1); },
180    };
181  })();
182</script>
182
183
184<script>
185  (function () {
186    try {
187      // Desktop preference (icon-only mode) is the only state we
188      // restore at first paint. Mobile expand is intentionally NOT
189      // restored — on phones the rail must start collapsed every load
190      // so the content column stays wide. Wipe any stale flag too.
191      // REPLIE PAR DEFAUT (owner 2026-08-19 : « please leave the features
192      // replier by default now »). Le test est `!== '0'` et non `=== '1'` :
193      // absence de preference = replie, seul un depliage EXPLICITE
194      // (le bouton, qui ecrit '0') ouvre la barre. Le meme test vit dans
195      // `_admin_script.html` ; les deux doivent dire la meme chose, sinon
196      // la barre s'ouvre a la premiere peinture puis se replie sous l'oeil.
197      var d = localStorage.getItem('gk-sidebar-collapsed') !== '0';
198      if (d) document.documentElement.classList.add('feat-sidebar--collapsed');
199      localStorage.removeItem('gk-sidebar-expanded');
200    } catch (_) {}
201  })();
202  // Register the gateka-core service worker for Web Push delivery
203  // (ADR 0007 + 0009). Every product gets push capability via this
204  // single registration — the SW is served by the manifest router on
205  // every fapi (gateka.routers.manifest_routes.service_worker).
206  // Errors are swallowed: push is optional, the page must work even
207  // if SW registration fails (Safari private mode, blocked by CSP,
208  // corporate proxies, etc.).
209  if ('serviceWorker' in navigator) {
210    window.addEventListener('load', function () {
211      navigator.serviceWorker.register('/sw.js', { scope: '/' })
212        .catch(function (e) { console.warn('SW register failed', e); });
213    });
214  }
215</script>
215
216
217  
218  
219<script>
220  /* Read theme from localStorage before <body> paints → no FOUC.
221     Lookup order:
222       1. localStorage 'gk-theme'  (user's explicit click on the theme switch)
223       2. OS preference via prefers-color-scheme  (default for first visit)
224       3. 'light'                                 (last-resort fallback when
225                                                   matchMedia isn't available) */
226  (function () {
227    try {
228      var t = localStorage.getItem('gk-theme');
229      if (!t) {
230        var mm = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)');
231        t = (mm && mm.matches) ? 'dark' : 'light';
232      }
233      document.documentElement.setAttribute('data-theme', t);
234    } catch (e) {}
235  })();
236</script>
236
237<style>
238  :root {
239    --gk-bg:            #f8f9fa;
240    --gk-bg-surface:    #ffffff;
241    --gk-text:          #1a202c;
242    --gk-text-muted:    #6b7280;
243    --gk-border:        #e5e7eb;
244    --gk-primary:       #2d3748;
245    --gk-primary-hover: #1a202c;
246    --gk-danger:        #ef4444;
247    --gk-radius:        10px;
248    --gk-font:          system-ui, -apple-system, 'Segoe UI', sans-serif;
249
250    /* Bridge vars consumed by admin_shell.css. Default-aliased to the
251       canonical --gk-* palette; per-product packages.yaml.theme_color
252       overrides --accent via _data_theme.html (included later in
253       gk_base.html). */
254    --accent:     var(--gk-primary);
255    --text:       var(--gk-text);
256    --text2:      var(--gk-text-muted);
257    --text3:      var(--gk-text-muted);
258    --surface:    var(--gk-bg-surface);
259    --bg2:        var(--gk-bg-surface);
260    --bg3:        var(--gk-bg);
261    --border:     var(--gk-border);
262    --radius:     var(--gk-radius);
263    --overlay-bg: var(--gk-bg-surface);
264
265    /* Legacy `--color-*` vocabulary. ~260 invocations across
266       admin_shell.css and the panel templates name these tokens, and
267       not one of them was ever defined (measured 2026-08-13, zero
268       definitions tree-wide). They survived only as the middle rung of
269       fallback chains — `var(--bg2, var(--color-bg-surface, #fff))` —
270       so every chain skipped straight to its hardcoded literal and the
271       theme looked two levels deep while being one. 39 invocations had
272       no fallback at all, which makes the declaration INVALID and
273       paints nothing (white cards on dark, unpainted hovers).
274
275       Aliased to the bridge vars above rather than to literals: one
276       notion, one source, and dark mode follows with no second block.
277       Aliasing --color-accent-blue to --accent is also what keeps a
278       product's brand colour flowing through — 19 call sites already
279       chained to --accent explicitly, and pinning a literal here would
280       regress every branded product to blue. */
281    --color-accent-blue:  var(--accent, #3b82f6);
282    --color-bg-surface:   var(--bg2, #ffffff);
283    --color-bg-elevated:  var(--bg3, #f8f9fa);
284    --color-text-primary: var(--text, #111111);
285    --color-text-muted:   var(--text3, #888888);
286    --color-border:       var(--border, rgba(0, 0, 0, .08));
287
288    /* These two have no --gk-* counterpart because they are missing
289       notions, not synonyms. --surface-hover is a translucent veil that
290       composes over whatever sits beneath it, so it is the one token
291       that must inverse in dark rather than inherit. --surface-solid is
292       an opaque surface for elements that must not show through
293       (header_auth.css knockout rings, admin_shell.css:282). */
294    --surface-hover:  rgba(0, 0, 0, .05);
295    --surface-solid:  var(--bg2, #ffffff);
296
297    /* ── The non-colour half of the design system (ux-system-epic §D1) ──
298       Colour was the only notion this block carried. Everything else —
299       spacing, type scale, radius steps, motion — was invented per panel:
300       93 templates, 5221 hardcoded colour values, and ZERO occurrences of
301       `--gk-space` / `--space-` / `--gk-gap` tree-wide when this was
302       written (2026-08-14). `padding: 14px 18px` appears in one panel and
303       `12px 14px` in the next because nothing said which one was right.
304
305       They live HERE, beside the colours, rather than in gateka.css:
306       inline in <head> so they apply before the first paint, present on
307       every gk_base page, and inheriting the html[data-theme] switch for
308       free. gateka.css consumes them with literal fallbacks — it is also
309       served standalone over the CDN to sites that never include this
310       partial. Architect consult 2026-08-14-lot-d1-de-ux-system-epic-571a79
311       (extend_existing).
312
313       ⚠ Adding a token here is cheap; adding a SECOND scale is not. If a
314       value you need is one step off the scale, take the nearest step. */
315
316    /* Spacing — a 4px base, seven steps. Deliberately short: a scale with
317       twelve steps is a scale nobody can hold in their head, and the
318       panels then pick by eye anyway. */
319    --gk-space-05: 2px;   /* the sub-step. Badges and table cells at 11px
320                             genuinely need less than 4px of block padding;
321                             without a step for it they were writing 2px and
322                             3px by eye. One named half-step beats two. */
323    --gk-space-1: 4px;
324    --gk-space-2: 8px;
325    --gk-space-3: 12px;
326    --gk-space-4: 16px;
327    --gk-space-5: 24px;
328    --gk-space-6: 32px;
329    --gk-space-7: 48px;
330
331    /* Type. --gk-text-base is 13px, not 16px: this is an operational
332       admin surface where density is the point (Stripe Dashboard is the
333       declared reference). Marketing surfaces override in head_extra. */
334    --gk-text-xs:   11px;
335    --gk-text-sm:   12px;
336    --gk-text-base: 13px;
337    --gk-text-md:   14px;
338    --gk-text-lg:   16px;
339    --gk-text-xl:   18px;
340    /* The DISPLAY steps — a single number a panel shows as its headline,
341       not running text. They stop at 18px until 2026-08-15 because no
342       converted panel had one; §D4 lot 3 converted two that did, and both
343       had reached for a literal.
344
345       Both values are INHERITED, not chosen: 28px is what
346       `.cp-rgpd-card-count` already rendered and 42px is what
347       `.gk-seo-score-big` already rendered. Picking a rounder number
348       would have moved two surfaces for no measured reason — the same
349       reasoning `.gk-card-grid--tight` records for its 140px. */
350    --gk-text-2xl:  28px;
351    --gk-text-3xl:  42px;
352    --gk-leading-tight: 1.3;
353    --gk-leading:       1.55;
354    --gk-weight-medium: 600;
355    --gk-weight-bold:   700;
356
357    /* Radius — --gk-radius (10px) already existed and stays the middle
358       step, so no existing surface moves. */
359    --gk-radius-sm:   6px;
360    --gk-radius-lg:   14px;
361    --gk-radius-full: 999px;
362
363    --gk-border-width: 1px;
364    /* The second step exists because §D6 needed a border to carry MEANING
365       rather than separation: `.gk-empty--denied` marks a refusal with a
366       rule on its inline edge, so the state survives greyscale and a
367       `prefers-contrast` palette that flattens the tone. A hairline reads
368       as a divider; this one has to read as a marker. */
369    --gk-border-width-thick: 2px;
370    /* The THIRD width, and it is a different notion from the two above:
371       a state STRIPE on an element's inline edge, not a rule between
372       things. `.gk-card[data-tone]` has read it as `var(--gk-stripe, 3px)`
373       since §D4 lot 1 and nothing ever declared it, so every caller
374       painted the fallback and a panel that wanted the same stripe had to
375       repeat the literal. Declaring it here costs nothing — a token that
376       equals the fallback every caller already resolved to cannot repaint
377       a screen (§D5 ter) — and it turns the next caller's `3px` into a
378       read. 2026-08-16, §D4. */
379    --gk-stripe: 3px;
380
381    /* Semantic tones. --gk-danger was already here alone, which is why
382       every panel hardcoded its own green and amber (#16a34a, #fef3c7,
383       #d1fae5 …). Surfaces and inks are DERIVED from these four with
384       color-mix() in gateka.css, so a tinted badge composes over whatever
385       is beneath it and follows dark mode with no second declaration —
386       the same reasoning as --surface-hover above. */
387    --gk-success: #16a34a;
388    --gk-warning: #d97706;
389    --gk-info:    #3b82f6;
390
391    /* Motion. Moved here from gateka.css so there is one source; that
392       file now reads them with fallbacks. §C of the epic is binding:
393       nothing above 200ms on an administration gesture, and
394       prefers-reduced-motion is honoured — the reset now lives at the
395       foot of this file, moved out of gateka.css by §D5. Two steps and
396       no more: a duration scale with a long tail is what §D5 removed. */
397    --gk-ease:          cubic-bezier(.4, 0, .2, 1);
398    --gk-ease-out:      cubic-bezier(.22, 1, .36, 1);
399    --gk-duration-fast: 120ms;
400    --gk-duration:      180ms;
401
402    /* The stagger STEP — a third axis, not a third duration. §D5 removed a
403       long duration tail and its test still refuses a third --gk-duration-*;
404       this is deliberately not one. It is the offset BETWEEN siblings, and
405       it exists because the alternative is what the language pill shipped on
406       2026-08-15: `calc(30ms + var(--i) * 50ms)`, a rule whose cost grows
407       with the number of children. At three languages that landed the last
408       segment at 250ms — over §C's ceiling, with no number in the rule above
409       200. That is §D5's "a stagger hides its own budget", verbatim.
410
411       A step alone is not enough: a caller MUST also cap it, so the worst
412       case is bounded by the child count it does not control. The pill caps
413       at two steps, which puts its slowest segment at 60 + 120 = 180ms —
414       one --gk-duration for the whole unfold, at two languages as at six. */
415    --gk-stagger-step:  30ms;
416
417    /* La DURÉE DU RUBAN — un cinquième axe, et le premier qui ne borne
418       aucun geste. `.gk-ribbon` (gateka.css §C) est un ORNEMENT INFINI :
419       il n'annonce aucun changement d'état, donc il ne promet rien qu'une
420       durée puisse trahir. Le plafond de 200 ms de §C lie une CLASSE
421       D'INTERACTION — ce qui répond à un clic, un survol, un focus — pas
422       une classe de surface.
423
424       ⚠ IL LUI FAUT SON PROPRE NOM, et ce n'est pas une coquetterie.
425       Le plafond est tenu PAR JETON et non par revue : si le ruban lisait
426       `--gk-duration`, il rentrerait dans l'ensemble plafonné et quelqu'un
427       le ramènerait un jour à 180 ms en toute bonne foi — un ruban à
428       180 ms est un clignotement, et il mourrait sans que personne
429       comprenne pourquoi. Consult 2026-08-16 …-45bc25, étape 6.
430
431       40 s pour dix bannières fait ~4 s par bannière traversant l'œil :
432       assez lent pour ne pas tirer le regard hors du contenu, assez rapide
433       pour que les dix passent pendant qu'on lit la page. */
434    --gk-ribbon-duration: 40s;
435
436    /* The loading THRESHOLD — a fourth axis, and the only token here that
437       is deliberately ABOVE §C's 200ms ceiling-adjacent numbers without
438       breaking it, because it does not time a gesture: it times the wait
439       BEFORE the screen admits it is waiting.
440
441       Below ~200ms a human reads an appear-then-disappear as a flicker,
442       not as information. An indicator shown for a 40ms request therefore
443       costs more than saying nothing: the screen blinks and the reader
444       learns nothing. So `.gk-empty--loading` holds itself invisible for
445       this long, and the DATA is never delayed — only the admission.
446
447       It is not a --gk-duration-*: nothing moves for 200ms, nothing is
448       animated across it. test_motion_contract.py's refusal of a third
449       duration still stands, and this is not one. */
450    --gk-loading-delay: 200ms;
451
452    --gk-mono: ui-monospace, SFMono-Regular, 'JetBrains Mono', Menlo, monospace;
453
454    /* Keyboard-first (§D3) needs a ring that is one decision, not one per
455       component. Composed from --accent so a product's brand colour
456       carries into its focus ring. */
457    --gk-focus-ring: 0 0 0 2px var(--gk-bg-surface, #fff),
458                     0 0 0 4px var(--accent, #2d3748);
459
460    --gk-shadow-sm: 0 1px 2px rgba(0, 0, 0, .06);
461    --gk-shadow:    0 4px 12px rgba(0, 0, 0, .10);
462
463    /* The scrim behind a modal surface (§D2's command palette). It obeys
464       the --surface-hover / --gk-shadow rule and not the palette one: it
465       is INK laid over the page, so it stays black in both themes and
466       only its opacity moves. Deriving it from --gk-text with color-mix()
467       would flip it to a WHITE veil in dark mode, which is the one thing
468       a scrim must never do. */
469    --gk-scrim: rgba(0, 0, 0, .45);
470
471    /* Floating dark chrome, and its ink. The zone editor's pen pill, its
472       context toolbar and its session banner are surfaces laid OVER the
473       document, not part of it — the same category as --gk-scrim, and
474       governed by the same rule: they stay dark in both themes and only
475       the page beneath them changes. Aliasing them to --gk-bg-surface
476       would turn a toolbar that reads as "above the page" into one that
477       reads as part of it, and would make the pill invisible over a dark
478       hero image in light mode.
479
480       --gk-overlay-ink exists so the white is a decision made once,
481       instead of the eleven `#fff` / `#eee` / `rgba(255,255,255,…)`
482       literals the drawer carried. Same call gateka.css already makes in
483       .gk-btn--primary: ink on a saturated surface is not a palette
484       entry, it is a contrast requirement. */
485    --gk-overlay:     rgba(26, 26, 46, .94);
486    --gk-overlay-ink: #ffffff;
487  }
488  html[data-theme="dark"] {
489    --gk-bg:            #0f141c;
490    --gk-bg-surface:    #1a1e27;
491    --gk-text:          #f3f6fb;
492    --gk-text-muted:    #9ca3af;
493    --gk-border:        rgba(128, 128, 128, .3);
494    --gk-primary:       #3b82f6;
495    --gk-primary-hover: #2563eb;
496
497    /* The only legacy token needing a dark value: a hover veil made of
498       black ink is invisible on a dark surface, so it inverts to white
499       ink. Every other --color- and --surface- token above aliases a
500       bridge var and inherits this block's --gk-* changes for free. */
501    --surface-hover:    rgba(255, 255, 255, .07);
502
503    /* Same rule as --surface-hover: a shadow is ink composed over what
504       sits beneath it. Black ink on a dark surface is invisible, so the
505       shadow deepens instead of tinting, and the focus ring's knockout
506       has to follow --gk-bg-surface's dark value (it does — it reads the
507       token, not a literal). Spacing, type, radius and motion need no
508       dark values at all: they are not colour. */
509    --gk-shadow-sm:     0 1px 2px rgba(0, 0, 0, .40);
510    --gk-shadow:        0 4px 14px rgba(0, 0, 0, .55);
511    --gk-scrim:         rgba(0, 0, 0, .65);
512    color-scheme: dark;
513  }
514  html[data-theme="dark"] body {
515    background: var(--gk-bg);
516    color: var(--gk-text);
517  }
518</style>
519
520
521<style>
522  @media (prefers-reduced-motion: reduce) {
523    *, *::before, *::after {
524      animation-duration: .01ms !important;
525      animation-iteration-count: 1 !important;
526      transition-duration: .01ms !important;
527      scroll-behavior: auto !important;
528    }
529  }
530</style>
531
532  
533  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
534  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
535
536  
537  <link rel="stylesheet" href="/gateka/static/gateka.css?v=prod-20260926-a">
538  
539
540  
541  
542  
543
544  
545  
546
547<style>
548  :root {
549    --gk-accent: #1f7a3a;
550    --gk-primary: #1f7a3a;
551    --accent: #1f7a3a;
552    --accent-dim: #0f4d24;
553    --accent-hover: #0f4d24;
554    --text: #17231b;
555    --text2: #3b4a40;
556    --text3: #5b6b60;
557    --border: #e3ebe2;
558    --bg2: #ffffff;
559    --bg3: #f3f8f1;
560    --surface: #ffffff;
561    --surface-hover: #eef6ec;
562    --chrome-bg: #ffffff;
563    --chrome-text: #17231b;
564    --chrome-muted: rgba(23,35,27,0.66);
565    --chrome-border: rgba(23,35,27,0.12);
566    --chrome-hover: rgba(31,122,58,0.08);
567  }
568</style>
569
570
571
572  
573  
574  
575    
576    
577  
578  
579  
580  
581  
582  
583    
584  
585  
586  
587  
587<script defer src="/gateka/static/gk.js?v=prod-20260926-a" data-site="lorvert-paris"
588          data-autocapture="0"
589          ></script>
589
590  
591</head>
592
593
594<body class="" data-gk-product="Lorvert-Poppies" data-gk-site="lorvert-paris">
595  
596  
597
598
599  
600  
601  
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643<script>(function(){try{if(!localStorage.getItem('gk-theme'))document.documentElement.setAttribute('data-theme','light');}catch(e){}})();</script>
643
644
645
646<link rel="stylesheet" href="/gateka/static/gk-nav.css?v=prod-20260926-a">
647
648<style>
649  /* Tokens — kept in lockstep with header.php :root + html[data-theme="dark"]. */
650  :root {
651    /* --nav-h was 64px before the search row landed. Search row =
652       ~38px (input 30px + 8px bottom padding), so the topbar is
653       now ~110px tall on desktop. nav_spacer (gk_base.html) and
654       sticky sidebars (catalog #index_app .sidebar) read this var
655       to clear the topbar; under-sizing it clips the top of the
656       page body. */
657    /* Search row was inlined into nav-row 2026-05-16, freeing ~40px.
658       Topbar now ~75px tall on desktop (nav buttons + upgrade alert).
659       Real height is measured by JS at the bottom of this file and
660       written to --navbar-h, so this fallback only matters before JS
661       runs (FOUC clearance). */
662    --nav-h: 75px;
663    /* --chrome-bg lets a site override the brand bar without touching
664       --text (which also serves as body ink). Per-site value comes from
665       packages.yaml theme.css_vars (get_theme_tokens, ADR-0036/0045);
666       absent → falls back to the brand navy. Consult 7ce800. */
667    /* ⚠ UN TERMINUS LITTERAL AU BOUT DE LA CHAINE. `--text` n'est emis que
668       par `gk_base.html` : une page AUTONOME qui inclut cette barre sans
669       l'etendre — la vue tuiles du catalogue — n'a donc ni `--chrome-bg` ni
670       `--text`, et `var()` sans repli final rend VIDE. Resultat mesure le
671       2026-08-31 : barre transparente sur lorvert en vue tuiles, navy
672       correcte en vue liste, meme site, meme barre. Le navy de marque ferme
673       la chaine ; les sites qui declarent `--chrome-bg` (sphaigne creme,
674       poppies sombre) n'y arrivent jamais. */
675    --topbar-bg:        var(--chrome-bg, var(--text, #1a202c));
676    /* L'ENCRE de la barre suit le même contrat que son fond : littérale
677       jusqu'ici, elle empêchait toute barre CLAIRE. Un site au fond crème
678       (sphaigne) héritait bien de `--chrome-bg`, puis écrivait par-dessus en
679       blanc sur blanc. Ces quatre tokens ne changent rien là où
680       `--chrome-*` n'est pas déclaré : les valeurs par défaut sont celles
681       d'avant. Générique, comme `--chrome-bg` — aucune couleur de produit
682       n'entre ici. */
683    --topbar-text:      var(--chrome-text, #f1f5fa);
684    --topbar-muted:     var(--chrome-muted, rgba(255,255,255,0.75));
685    --topbar-border:    var(--chrome-border, rgba(255,255,255,0.18));
686    --topbar-hover-bg:  var(--chrome-hover, rgba(255,255,255,0.10));
687    --topbar-accent:    var(--accent);
688    --topbar-on-accent: var(--text);
689    --topbar-shadow:    0 1px 0 rgba(0,0,0,0.20);
690  }
691  html[data-theme="dark"] {
692    /* 2026-05-16: pulled the dark palette toward the brand's navy (var(--text))
693       instead of the near-black it had before. Topbar gets a slightly
694       deeper navy than body so the chrome still reads as "elevated"
695       above the page (body is #0f1827 across all customer templates). */
696    --topbar-bg:        var(--chrome-bg-dark, #0c1422);
697    --topbar-text:      #f3f6fb;
698    --topbar-muted:     rgba(255,255,255,0.55);
699    --topbar-border:    rgba(255,255,255,0.10);
700    --topbar-hover-bg:  rgba(255,255,255,0.06);
701    /* Derive from the per-site accent (persists at :root into dark via
702       _theme.html --accent: var(--gk-primary)) instead of a hardcoded
703       brand-yellow literal — that was a Rule 4 drift baking lorvert's
704       colour into the shared nav. Consult 7ce800. */
705    --topbar-accent:    var(--accent);
706    --topbar-on-accent: #0a0f17;
707    --topbar-shadow:    0 1px 0 rgba(0,0,0,0.50);
708  }
709  /* Lorvert customer-page baseline — restore the body font-size +
710     font-family that lived in the archived header.php (line 88) so
711     untyped text (nav buttons, panel labels, default copy) inherits
712     the same 14px as production. Without this, gk_base falls back
713     to the browser default (16px Times) and per-element px sizes
714     downstream look "smaller" by comparison. */
715  /* padding-top tracks --navbar-h; since 1a67386 --navbar-h only
716     updates at transitionend (no more per-scroll-frame measure),
717     the recompute on var-change would snap body content up ~60px.
718     Easing padding-top over the same 220ms keeps the whole header
719     squeeze visually coherent on EVERY page (sticky sidebars or not). */
720  /* ⚠ CETTE BARRE PADDE LE BODY ELLE-MEME (regle ci-dessous), donc
721     l'ESPACEUR PARTAGE de gateka-core doit valoir zero, sinon le decalage
722     est COMPTE DEUX FOIS. Mesure du 2026-08-21 sur /backoffice :
723     body padding-top 143px (qui degageait deja exactement le header fixe,
724     bas mesure a 143px) + espaceur 75px = la barre laterale demarrait a
725     234px au lieu de 116px chez les autres produits — ce que l'owner a vu.
726     Sept gabarits lorvert neutralisaient deja le bloc `nav_spacer`,
727     mais `product_admin.html` REDECLARE le bloc et gagne (il est plus
728     derive), donc la neutralisation par bloc ne protege PAS les pages
729     d'administration. On le dit donc en DONNEE, pas en gabarit : le
730     defaut de la variable cote core garde tous les autres produits
731     inchanges (eux n'ont pas de padding sur body — ils ont BESOIN de
732     l'espaceur : sans lui les en-tetes de colonnes kanban passaient sous
733     le header, Guillaume 2026-05-20). */
734  :root { --gk-nav-spacer-h: 0px; }
735  body { padding-top: var(--navbar-h, 75px);
736         transition: padding-top .22s ease;
737         font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-size: 13px; }
738  .topbar { position: fixed; top: 0; left: 0; right: 0; z-index: 1030; background: var(--topbar-bg); color: var(--topbar-text); padding: 0; overflow: visible; border-bottom: 1px solid var(--topbar-border); box-shadow: var(--topbar-shadow); }
739  .topbar-inner { display: flex; align-items: flex-start; gap: 0; padding: 0 12px 0 0; }
740  /* Fixed 200px logo column with a big 200px-tall badge (Guillaume
741     2026-05-23). Height is a STATIC literal — it MUST NOT reference
742     --navbar-h. --navbar-h is set by applyTopOffset to the MEASURED topbar
743     height, which itself includes the logo; binding the logo to it created a
744     feedback loop (logo→topbar→logo) that grew the header on every
745     render/resize ("the header keeps growing"). A fixed px keeps the badge
746     large AND stable. max-width + object-fit:contain keep the wide poppies
747     "POPPIES SEEDS" wordmark letterboxed instead of spilling across the
748     header. Shrinks on scroll (see .topbar.is-scrolled below) + on mobile. */
749  /* ⚠ AUCUNE compensation par produit ici. Un `padding-top` calculé sur le
750     déséquilibre de logo7.png (7,4 % de vide en haut, 17,8 % en bas) a été
751     essayé puis RETIRÉ le 2026-08-18 : cette barre est générique, servie à
752     18 produits, et une constante tirée d'un seul fichier décentrerait les
753     dix-sept autres. Un logo mal cadré se corrige dans l'ASSET, pas dans le
754     gabarit partagé. La barre centre ; elle ne rattrape pas. */
755  .topbar-logo { flex: 0 0 200px; padding: 0 12px; text-align: center; display: flex; align-items: center; justify-content: center; }
756  /* Single knob: --gk-logo-height. History: 200px badge (2026-05-23, oversized)
757     → 48px → 40px (too small, Guillaume 2026-05-27) → 72px. Fills the fixed
758     200px container better without re-triggering the header-growth loop (still
759     a fixed px, never bound to --navbar-h). One place to dial the resting size. */
760  :root { --gk-logo-height: 72px; }
761  /* Bigger logo when there's room (wide screens). Square-ish badge stays well
762     under the 200px container/max-width, so no letterboxing. Still fixed px —
763     header-growth loop stays dead. (Guillaume 2026-05-27) */
764  @media (min-width: 1200px) { :root { --gk-logo-height: 96px; } }
765  .topbar-logo img { height: var(--gk-logo-height, 64px); width: auto; max-width: 200px; object-fit: contain; border-radius: 8px; display: block; }
766  @media (max-width: 640px) {
767    .topbar-logo img { height: 48px; max-width: 160px; }
768  }
769  .topbar-logo a { display: inline-block; line-height: 0; vertical-align: top; }
770  .topbar-content { flex: 1; min-width: 0; padding: 4px 0; }
771
772  /* ── Header layout model (Guillaume 2026-05-27 rebuild) ──────────────────
773     ONE non-wrapping flex row inside .topbar-content (the logo is the fixed
774     200px left zone, outside this row — see .topbar-logo). Three parts:
775       [ nav-links (features) | search (ELASTIC) | nav-actions (login) ]
776     The search is flex:1 — it IS the whitespace. As the viewport narrows the
777     search shrinks first (space closes, no layout change); only once it's out
778     of slack do the breakpoints below compact the buttons (labels→icons,
779     contact→icon, search→icon). No justify-content:space-between (that
780     manufactured the voids) and no flex-wrap (that orphaned the actions row). */
781  .nav-row { display: flex; align-items: center; flex-wrap: nowrap;
782             gap: 12px; min-width: 0; margin-bottom: 12px; }
783  /* `flex: 0 1 auto` + `min-width: 0`, PAS `0 0 auto` : la liste centrale doit
784     CEDER avant les actions. Mesure du 2026-08-31 sur beta.sphaigne-chili.com
785     /catalog en 1280 : avec six entrees de nav, « Se connecter » commencait a
786     x=1251 et « Creer un compte » a x=1382 — hors de l'ecran, et `.nav-row`
787     etant en `overflow-x: visible` le bouton n'etait meme pas atteignable au
788     defilement. Les actions sont deja `flex: 0 0 auto` (l. ~311) : c'est donc
789     ici que la place doit se prendre. La liste defile en interne quand elle
790     deborde. Lorvert/poppies (trois entrees courtes) tiennent toujours dans la
791     ligne, donc `0 1 auto` ne les retrecit jamais — rien ne bouge pour eux. */
792  .nav-links { display: flex; gap: 4px; list-style: none; margin: 0; padding: 0;
793               align-items: center; flex: 0 1 auto; min-width: 0;
794               overflow-x: auto; scrollbar-width: none; }
795  .nav-links::-webkit-scrollbar { display: none; }
796  /* Mobile: the header stays a single one-line row (Guillaume 2026-05-23,
797     superseding the 2026-05-22 "wrap onto 2-3 lines" rule). The canonical
798     mobile layout lives in the `@media (max-width: 767px)` block below —
799     nowrap row, icon-only, with horizontal scroll as the overflow valve so
800     it never breaks onto multiple lines. */
801  /* Center nav buttons — Le Monde style (Guillaume 2026-05-18, mirrors
802     gateka-core's .gk-nav-link contract). Icon stacked above a small
803     label, like gateka.fr/features sidebar buttons. At ≥900px switches
804     to row layout with a readable 14px label, matching the canonical
805     wide-viewport pattern. */
806  .nav-links li a,
807  .nav-links li button.nav-btn-newcmd {
808    position: relative;
809    display: inline-flex;
810    flex-direction: column;
811    align-items: center; justify-content: center;
812    gap: 3px;
813    min-width: 44px; min-height: 36px;
814    padding: 4px 8px;
815    color: var(--topbar-muted); background: transparent;
816    font-size: 10px; font-weight: 600;
817    letter-spacing: .2px; line-height: 1.1;
818    text-align: center;
819    text-decoration: none;
820    border: none; border-radius: 8px;
821    cursor: pointer; font-family: inherit;
822    transition: color .15s, background .15s, filter .15s;
823  }
824  .nav-links li a > .fa-solid,
825  .nav-links li button.nav-btn-newcmd > .fa-solid { font-size: 18px !important; line-height: 1; }
826  @media (min-width: 900px) {
827    .nav-links li a,
828    .nav-links li button.nav-btn-newcmd {
829      flex-direction: row;
830      gap: 8px;
831      padding: 6px 12px;
832      font-size: 14px;
833      min-height: 44px;
834    }
835    .nav-links li a > .fa-solid,
836    .nav-links li button.nav-btn-newcmd > .fa-solid { font-size: 16px !important; }
837  }
838  .nav-links li a:hover,
839  .nav-links li button.nav-btn-newcmd:hover { color: var(--topbar-text); background: var(--topbar-hover-bg); }
840  /* Active state — minimal (Guillaume 2026-05-18). No background,
841     no underline. Just accent text + icon color and slight weight
842     bump. The hover-tint pill (inherited from `.nav-links li a:hover`)
843     is the only background that ever paints; the active page is
844     called out purely by color difference. Smallest possible
845     selection zone. */
846  .nav-links li.active a {
847    color: var(--topbar-accent);
848    font-weight: 600;
849  }
850  .nav-links li.active a:hover { color: var(--topbar-accent); }
851  .nav-links li.active a > .fa-solid { color: var(--topbar-accent); }
852  .nav-links li button.nav-btn-newcmd { background: var(--topbar-accent); color: var(--topbar-on-accent); font-weight: 600; }
853  .nav-links li button.nav-btn-newcmd:hover { background: var(--topbar-accent); filter: brightness(1.10); }
854
855  .nav-actions { display: flex; gap: 6px; align-items: center; flex-wrap: nowrap; flex: 0 0 auto; }
856  .nav-accordion-btn { display: none; }
857  /* Sans la recherche (sites ou `nav_search` est faux ou reserve aux connectes),
858     la rangee n'a plus d'element elastique : les actions se collaient aux liens
859     et laissaient 305 px de vide a droite (mesure sphaigne, 1500 px, 2026-08-31).
860     `margin-left: auto` reprend ce role — uniquement dans ce cas. */
861  .nav-row.is-nav-nosearch .nav-actions { margin-left: auto; }
862
863  /* Anon login/register CTAs (shown instead of the account menu when logged
864     out — unify, 2026-05-27). Labels collapse to icons on the narrow strip. */
865  .nav-cta { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px;
866    border-radius: 8px; font: inherit; font-size: 13px; font-weight: 600;
867    text-decoration: none; white-space: nowrap; flex: 0 0 auto; cursor: pointer; }
868  .nav-cta--primary { background: var(--topbar-accent); color: var(--topbar-on-accent); }
869  .nav-cta--primary:hover { filter: brightness(1.08); }
870  .nav-cta--ghost { background: transparent; color: var(--topbar-text); border: 1px solid var(--topbar-border); }
871  .nav-cta--ghost:hover { background: var(--topbar-hover-bg); }
872  /* Variante discrete : ni aplat ni contour, le poids retombe a 500 et la
873     couleur d'accent n'apparait qu'au survol. Les deux boutons gardent leur
874     zone de clic (padding inchange) — on baisse le contraste, pas la cible. */
875  .is-auth-quiet .nav-cta { font-weight: 500; }
876  .is-auth-quiet .nav-cta--primary { background: transparent; color: var(--topbar-text); }
877  .is-auth-quiet .nav-cta--primary:hover { filter: none; }
878  .is-auth-quiet .nav-cta--ghost { border-color: transparent; }
879  .is-auth-quiet .nav-cta:hover { background: var(--topbar-hover-bg); color: var(--topbar-accent); }
880  /* Icones SEULES, a toute largeur — demande de l'owner 2026-08-31.
881     Le libelle tombe (`.nav-text`), le bouton devient une pastille ronde de
882     34px : meme diametre que la pastille de langue et que le couple
883     `.gk-cta-signin`/`.gk-cta-signup` de la barre du noyau, pour que les deux
884     barres se ressemblent. Le `title`/`aria-label` portent deja le texte,
885     donc rien n'est perdu pour un lecteur d'ecran ni au survol. */
886  .is-auth-quiet .nav-cta .nav-text { display: none; }
887  .is-auth-quiet .nav-cta {
888    width: 34px; height: 34px; padding: 0; gap: 0;
889    border-radius: 999px; justify-content: center;
890    border: 1px solid var(--topbar-border);
891  }
892  .is-auth-quiet .nav-cta i { font-size: 14px; }
893  /* Verrou optique du bloc de marque — voir gk-nav.css. L'icone est centree
894     sur le lockup entier, donc 6px sous le centre du mot-marque ; on la
895     remonte pour qu'elle s'aligne sur la ligne que l'oeil lit. */
896  .gk-nav-brand > svg {
897  /* ⚠ ON ALIGNE L'ENCRE, PAS LA BOITE — et on donne au signe sa taille.
898     Mesure au grossissement x4 : dans une boite de 30px, le dessin n'occupe
899     que 20.3px (haut 5.9px de marge, bas 3.8px), il est donc DECENTRE dans
900     sa propre boite de ~1px vers le bas. Centrer les boites — ce que fait
901     `align-items: center` — laissait donc l'encre a 50.1 quand le bloc de
902     marque est a 49.
903     Et surtout : 20px d'encre contre un bloc de 34px, le signe ne tenait pas
904     son rang a cote de deux lignes de texte ; sa masse (les feuilles) se
905     lisait en haut et sa tige pendait le long de « PAR L'OR VERT ».
906     38px de boite portent ~26px d'encre, et le decalage compense l'asymetrie
907     du trace. Trois iterations a l'oeil avant de mesurer l'encre : c'etait
908     la mesure qui manquait, pas la patience. */
909  /* MISE A JOUR 2026-09-01 — le signe a change (feuille pleine de Font
910     Awesome, owner). Le `-1.3px` compensait l'asymetrie du BRIN d'avant ;
911     applique au nouveau trace il laissait l'encre 1,3px trop haut et son
912     sommet debordait la barre. Le decalage est desormais porte par le
913     dessin (son `viewBox` centre l'encre dans sa propre boite, cote
914     packages.yaml) : changer de logo ne demande plus de toucher ici. */
915  position: relative; top: 0;
916  width: 38px; height: 38px; flex-shrink: 0;
917}
918/* top: 0 — le CENTRE, mesure. L'ancre est en `align-items: center`, donc
919   sans decalage l'icone tombe a 34-64 et son milieu a 49, exactement
920   celui du bloc de marque. Les valeurs essayees avant : -6px la faisait
921   SORTIR du bloc par le haut (28 contre 32), -2px la laissait a fleur du
922   bord (haut 32, milieu 47) — alignee sur le mot-marque, mais lue comme
923   collee en haut. L'owner a demande l'alignement vertical : c'est le
924   milieu du bloc, pas celui de la premiere ligne. */
925/* -2px, pas -6px : a -6 l'icone sortait du bloc de marque par le HAUT
926   (haut de l'icone 28, haut du bloc 32) et se lisait comme decollee de
927   la barre. -2 la garde DANS le bloc tout en la remontant vers le
928   centre optique du mot-marque, entre le centre geometrique du lockup
929   (49) et celui du mot-marque (42.8). Owner 2026-08-31. */
930  @media (max-width: 719px) {
931    .nav-cta .nav-text { display: none; }
932    .nav-cta { padding: 7px 9px; }
933  }
934
935  /* Service Client PRO contact block — sits in nav-actions so it's
936     always visible (logged-in customer chrome). Two stacked lines
937     (phone + email) keep the height in line with the connect-pill;
938     gold email + neutral phone match the legacy header.php styling. */
939  .gk-support-pill {
940    display: inline-flex; flex-direction: column; gap: 1px;
941    padding: 4px 10px; border: 1px solid var(--topbar-border);
942    border-radius: 6px; background: rgba(0,0,0,0.15);
943    line-height: 1.15; font-size: 11px;
944  }
945  .gk-support-pill .gk-support-label {
946    font-size: 9px; font-weight: 700; letter-spacing: 0.06em;
947    text-transform: uppercase; color: var(--topbar-muted);
948  }
949  .gk-support-pill .gk-support-lines { display: flex; flex-direction: column; gap: 1px; }
950  .gk-support-pill a { text-decoration: none; display: inline-flex; align-items: center; gap: 5px; font-variant-numeric: tabular-nums; }
951  .gk-support-pill .gk-support-phone { color: var(--topbar-text); font-weight: 600; }
952  .gk-support-pill .gk-support-phone:hover { color: var(--topbar-accent); }
953  .gk-support-pill .gk-support-mail  { color: var(--topbar-accent); font-weight: 500; }
954  .gk-support-pill .gk-support-mail:hover { filter: brightness(1.15); }
955  .gk-support-pill i { font-size: 9px; opacity: 0.8; }
956  /* Below 900px the email is the chattiest line — drop it; phone stays. */
957  @media (max-width: 900px) {
958    .gk-support-pill .gk-support-mail-text { display: none; }
959    .gk-support-pill .gk-support-mail i { font-size: 11px; }
960  }
961  /* Below 600px collapse to icon-only phone link (one icon, taps to dial). */
962  @media (max-width: 600px) {
963    .gk-support-pill { padding: 6px 8px; }
964    .gk-support-pill .gk-support-label,
965    .gk-support-pill .gk-support-phone-text,
966    .gk-support-pill .gk-support-mail-text { display: none; }
967    .gk-support-pill i { font-size: 13px; opacity: 1; }
968  }
969  .nav-btn-ghost { padding: 6px 10px; font-size: 13px; background: transparent; border: 1px solid var(--topbar-border); border-radius: 6px; color: var(--topbar-muted); cursor: pointer; display: inline-flex; align-items: center; text-decoration: none; transition: color .15s, border-color .15s, background .15s; }
970  .nav-btn-ghost:hover { color: var(--topbar-text); border-color: var(--topbar-text); background: var(--topbar-hover-bg); }
971  #admin_btn { padding: 6px 8px; border: none; background: transparent; }
972  #admin_btn:hover { background: transparent; border-color: transparent; }
973  /* Canonical Features & bricks trigger (_features_menu.html) — the
974     upstream .gk-nav-btn class isn't in Lorvert's fork so the button
975     was rendering as a browser-default 17x19 grey rectangle around a
976     tiny gear. Style the fm-trigger to match the retired #admin_btn
977     look: no chrome, 18px gear, hover-spin animation. Scoped so it
978     doesn't fight #admin_btn if that ever comes back. */
979  [data-gk-feat-menu] .gk-nav-btn.fm-trigger {
980    padding: 6px 8px; border: none; background: transparent; cursor: pointer;
981    color: var(--topbar-muted); display: inline-flex; align-items: center;
982    justify-content: center; line-height: 1;
983  }
984  [data-gk-feat-menu] .gk-nav-btn.fm-trigger:hover { background: transparent; }
985  [data-gk-feat-menu] .fm-trigger .fa-gear { transition: color .15s; font-size: 18px !important; }
986  [data-gk-feat-menu] .fm-trigger:hover .fa-gear,
987  [data-gk-feat-menu].is-open .fm-trigger .fa-gear {
988    color: var(--topbar-text);
989    animation: lorv-crank-spin 2s linear infinite;
990  }
991  /* Crank — continuous spin while hovered, same shape as the canonical
992     gateka-core gk-nav-btn .fa-gear contract (gk-nav.css). Was a single
993     45° tick before; aligned 2026-05-17 so Lorvert-Poppies matches
994     every other Gateka site's chrome. */
995  #admin_btn .fa-gear { transition: color .15s; font-size: 18px !important; }
996  #admin_btn:hover .fa-gear {
997    color: var(--topbar-text);
998    animation: lorv-crank-spin 2s linear infinite;
999  }
1000  @keyframes lorv-crank-spin {
1001    from { transform: rotate(0deg); }
1002    to   { transform: rotate(360deg); }
1003  }
1004
1005  /* Connect pill — avatar + stacked firstname hint */
1006  .connect-pill { position: relative; width: 30px; height: 30px; padding: 0; border-radius: 50%; background: transparent; border: none; color: var(--topbar-text); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; transition: filter .15s; }
1007  .connect-pill:hover { filter: brightness(1.10); }
1008  .connect-pill-avatar { width: 26px;
1008 height: 26px; border-radius: 50%; background: var(--topbar-accent); color: var(--topbar-on-accent); font-weight: 700; font-size: 11px; display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; }
1009  /* ⚠ L'image REMPLIT la pastille sans la deformer : `cover` recadre, il ne
1010     comprime pas. Sans `overflow:hidden` le carre deborderait du cercle. */
1011  .connect-pill-avatar, .connect-pill-head-avatar { overflow: hidden; }
1012  .connect-pill-avatar img, .connect-pill-head-avatar img {
1013    width: 100%; height: 100%; object-fit: cover; display: block; }
1014  /* Firstname hint under the profile pill — hidden per Guillaume
1015     2026-05-18. Avatar initials inside the pill already identify the
1016     user; the "GUYDEGN..." truncated label was visual noise. The full
1017     name + email still surface in the dropdown header on click. */
1018  .connect-pill-name { display: none; }
1019  .connect-pill-menu { display: none; position: absolute; top: calc(100% + 6px); right: 0; background: #fff; color: #1f2937; border: 1px solid #e5e7eb; border-radius: 10px; box-shadow: 0 8px 24px rgba(0,0,0,.18); min-width: 240px; z-index: 2000; overflow: hidden; }
1020  #connect_pill_wrap.is-open .connect-pill-menu { display: block; }
1021  .connect-pill-head { display: flex; align-items: center; gap: 10px; padding: 14px; background: #f8fafc; border-bottom: 1px solid #e5e7eb; }
1022  .connect-pill-head-avatar { width: 36px; height: 36px; border-radius: 50%; background: var(--topbar-accent); color: var(--topbar-on-accent); font-weight: 700; font-size: 13px; display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; }
1023  /* ⚠ La vignette du menu passe de 36 a 52 px (owner 2026-08-30). Le menu
1024     est l'endroit ou l'on VERIFIE son identite avant d'agir — une vignette
1025     de 36 px n'y sert a rien. La pastille de la barre reste petite : elle,
1026     doit tenir dans la hauteur de nav. */
1027  .connect-pill-head-avatar { width: 52px !important; height: 52px !important;
1028    flex: 0 0 52px; }
1029  .connect-pill-head-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 5px; }
1030  .cph-tag { font-size: .68rem; font-weight: 600; letter-spacing: .03em;
1031    padding: 2px 8px; border-radius: 999px; font-variant-numeric: tabular-nums;
1032    background: var(--gk-accent, #f5c518); color: #3a2c00; }
1033  .cph-tag--soft { background: rgba(0,0,0,.07); color: inherit; opacity: .75; }
1034  html[data-theme="dark"] .cph-tag--soft { background: rgba(255,255,255,.12); }
1035  .connect-pill-head-text { min-width: 0; flex: 1; }
1036  .connect-pill-head-name { font-weight: 600; font-size: 13px; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1037  .connect-pill-head-email { font-size: 11px; color: #6b7280; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1038  .connect-pill-item { display: flex; align-items: center; gap: 10px; padding: 9px 14px; color: #374151; text-decoration: none; font-size: 13px; cursor: pointer; transition: background .12s; border: none; background: none; width: 100%; text-align: left; font-family: inherit; }
1039  .connect-pill-item:hover { background: #f9fafb; color: #111; }
1040  .connect-pill-item--active { background: #f0f9ff; color: #0ea5e9; font-weight: 600; }
1041  .connect-pill-item--active:hover { background: #e0f2fe; color: #0284c7; }
1042  .connect-pill-item--danger { color: #dc2626; }
1043  .connect-pill-item--danger:hover { background: #fef2f2; color: #b91c1c; }
1044  .connect-pill-sep { height: 1px; background: #e5e7eb; margin: 4px 0; }
1045
1046  /* B2C-mode toggle (replaces the old theme toggle 2026-06-04). Off:
1047     subtle outline like the lang toggle. On: filled amber so the operator
1048     can't miss it AND the body gets `lorvert-b2c-mode` so the whole
1049     topbar tints warm — visible confirmation that current display is
1050     public retail prices, not the operator's B2B view. */
1051  .gk-b2c-toggle { position: relative; display: inline-flex; align-items: center; gap: 5px; height: 30px; padding: 0 10px; background: transparent; border: 1px solid var(--topbar-border); border-radius: 6px; cursor: pointer; color: var(--topbar-muted); transition: background .15s, border-color .15s, color .15s; font: 600 11px 'Inter','Helvetica Neue',sans-serif; }
1052  .gk-b2c-toggle:hover { background: var(--topbar-hover-bg); border-color: var(--topbar-text); color: var(--topbar-text); }
1053  .gk-b2c-toggle .fa-solid { font-size: 12px; }
1054  .gk-b2c-toggle .gk-b2c-label { letter-spacing: .04em; }
1055  /* Active state — orange amber, applies to button + body wrapper. */
1056  body.lorvert-b2c-mode .gk-b2c-toggle { background: #f59e0b; border-color: #b45309; color: #1f2937; }
1057  body.lorvert-b2c-mode .gk-b2c-toggle:hover { background: #d97706; color: #fff; }
1058  body.lorvert-b2c-mode .topbar { box-shadow: inset 0 -2px 0 #f59e0b; }
1059  body.lorvert-b2c-mode .topbar::before { content: ''; position: absolute; inset: 0; pointer-events: none; background: linear-gradient(to right, rgba(245,158,11,0.06), rgba(245,158,11,0) 30%, rgba(245,158,11,0) 70%, rgba(245,158,11,0.06)); }
1060  /* B2C simulation banner — full-width strip, only visible when the
1061     body has `lorvert-b2c-mode`. Loud amber + diagonal stripe so the
1062     operator literally cannot miss they're in client-facing mode. */
1063  .lorvert-b2c-banner { display: none; align-items: center; gap: 12px;
1064    padding: 8px 16px; font: 600 13px 'Inter','Helvetica Neue',sans-serif;
1065    color: #1f2937; background: repeating-linear-gradient(135deg, #f59e0b 0 24px, #fbbf24 24px 48px);
1066    border-bottom: 2px solid #b45309; position: sticky; top: 0; z-index: 990; }
1067  body.lorvert-b2c-mode .lorvert-b2c-banner { display: flex; }
1068  .lorvert-b2c-banner-tag { flex: 0 0 auto; background: #1f2937; color: #fbbf24;
1069    padding: 3px 8px; border-radius: 4px; font-size: 11px; letter-spacing: .06em; font-weight: 800; }
1070  .lorvert-b2c-banner-text { flex: 1 1 auto; }
1071  .lorvert-b2c-banner-text strong { color: #7c2d12; }
1072  .lorvert-b2c-banner-exit { flex: 0 0 auto; cursor: pointer; background: #1f2937; color: #fbbf24;
1073    border: 0; padding: 4px 10px; border-radius: 4px; font: inherit; font-size: 11px;
1074    text-transform: uppercase; letter-spacing: .04em; }
1075  .lorvert-b2c-banner-exit:hover { background: #111827; }
1076  @media (max-width: 560px) {
1077    .lorvert-b2c-banner { font-size: 11px; padding: 6px 10px; gap: 6px; }
1078    .lorvert-b2c-banner-tag { font-size: 9.5px; padding: 2px 5px; }
1079  }
1080  /* Subtle page background tint while in B2C mode — secondary signal
1081     for when the banner scrolls off the viewport. */
1082  body.lorvert-b2c-mode { background: linear-gradient(180deg, rgba(245,158,11,0.04) 0 600px, transparent 600px); }
1083
1084  /* Le selecteur de langue N'A PLUS DE REGLES ICI — 2026-08-16.
1085     `.nav-lang-toggle` / `.nav-lang-opt` / `.nav-lang-slider` etaient la
1086     quatrieme declaration de la meme notion dans la flotte. La pilule
1087     partagee (`_lang_switch.html`) porte les siennes dans `gk-nav.css`,
1088     liee en tete de ce fichier. Ne les reintroduis pas : une regle locale
1089     posee apres ce <link> gagnerait en silence et la pilule divergerait a
1090     nouveau, ce qui est precisement ce que la migration retire.
1091
1092     Ce qui reste ci-dessous, c'est `.rg-lang-*` dans `landing.html` — le
1093     champ de langue du FORMULAIRE d'inscription, sur fond clair, qui
1094     soumet une valeur. Un champ de formulaire, pas un commutateur de
1095     chrome : meme mot, autre notion. */
1096
1097  /* Connect-pill dropdown adapts to dark mode (mirrors theme_toggle.php). */
1098  html[data-theme="dark"] .connect-pill-menu { background: #1a1e27; color: #f3f6fb; border-color: rgba(255,255,255,0.10); box-shadow: 0 8px 24px rgba(0,0,0,.5); }
1099  html[data-theme="dark"] .connect-pill-head { background: rgba(255,255,255,0.04); border-bottom-color: rgba(255,255,255,0.10); }
1100  html[data-theme="dark"] .connect-pill-head-name { color: #f3f6fb; }
1101  html[data-theme="dark"] .connect-pill-head-email { color: rgba(255,255,255,0.55); }
1102  html[data-theme="dark"] .connect-pill-item { color: #f3f6fb; }
1103  html[data-theme="dark"] .connect-pill-item:hover { background: rgba(255,255,255,0.06); color: #fff; }
1104  html[data-theme="dark"] .connect-pill-item--active { background: rgba(59,130,246,0.15); color: #93c5fd; }
1105  html[data-theme="dark"] .connect-pill-item--active:hover { background: rgba(59,130,246,0.25); color: #bfdbfe; }
1106  html[data-theme="dark"] .connect-pill-item--danger { color: #f87171; }
1107  html[data-theme="dark"] .connect-pill-item--danger:hover { background: rgba(220,38,38,0.15); color: #fca5a5; }
1108  html[data-theme="dark"] .connect-pill-sep { background: rgba(255,255,255,0.10); }
1109
1110  /* Upgrade-in-progress alert — ported from legacy header.php so customers
1111     see the same notice across the migration. Gold-tinted, sits below the
1112     nav-row inside the topbar. */
1113  /* Data-driven site notice (get_site_notice → packages.yaml.notices).
1114     `.upgrade-alert` is the base; `--<type>` sets the palette. `info`
1115     keeps the original gold tint; alert=red, warning=amber, success=
1116     green. Default (no modifier) = info. */
1117  .upgrade-alert { margin: 2px 0 6px 0; padding: 6px 10px; border-radius: 6px;
1118                   font-size: 12px; text-align: center;
1119                   max-height: 40px; overflow: hidden;
1120                   background: color-mix(in srgb, var(--topbar-accent) 15%, transparent);
1121                   border: 1px solid color-mix(in srgb, var(--topbar-accent) 40%, transparent);
1122                   color: var(--topbar-accent);
1123                   /* max-height transition only animates visibly while the
1124                      ceiling is BELOW the natural content height — keep the
1125                      ceiling tight (40px ≈ content) so frame-1 of the collapse
1126                      is immediately visible. 80px used to leave the first
1127                      ~60% of the 220ms doing nothing. */
1128                   transition: max-height .22s ease, padding .22s ease,
1129                               margin .22s ease, opacity .22s ease,
1130                               border-width .22s ease; }
1131  .upgrade-alert i { margin-right: 4px; }
1132  .upgrade-alert-cta { display: inline-flex; align-items: center; gap: 4px; margin-left: 8px; font-weight: 700; text-decoration: underline; color: inherit; white-space: nowrap; }
1133  .upgrade-alert-cta:hover { filter: brightness(1.15); }
1134  .upgrade-alert-cta i { margin: 0; font-size: 10px; }
1135  .upgrade-alert--alert {
1136    background: rgba(220,38,38,0.14); border-color: rgba(220,38,38,0.45); color: #fca5a5;
1137  }
1138  .upgrade-alert--warning {
1139    background: rgba(245,158,11,0.14); border-color: rgba(245,158,11,0.45); color: #fcd34d;
1140  }
1141  .upgrade-alert--success {
1142    background: rgba(34,197,94,0.14); border-color: rgba(34,197,94,0.45); color: #86efac;
1143  }
1144  /* Squeeze mode (Guillaume 2026-05-18) — collapse the upgrade notice
1145     to zero height on scroll. Visitors land on the page, see it once,
1146     then it folds away as they scroll into the catalog so the screen
1147     fills with product content. Tied to the same .topbar.is-scrolled
1148     state that shrinks the logo (see the shrink-on-scroll block below). */
1149  .topbar.is-scrolled .upgrade-alert {
1150    max-height: 0;
1151    padding-top: 0; padding-bottom: 0;
1152    margin: 0;
1153    opacity: 0;
1154    border-width: 0;
1155  }
1156
1157  @media (max-width: 767px) {
1158    .upgrade-alert { display: none; }
1159    .topbar-inner { flex-wrap: nowrap; align-items: center; padding: 4px 8px; gap: 8px; }
1160    /* `flex: 0 0 auto` = « ne retrecis JAMAIS ». C'est la colonne du logo qui
1161       figeait la rangee : bord droit mesure a 405 en 390 px, 405 en 360 et
1162       405 en 320 — identique, donc la rangee ignorait l'ecran et tout le
1163       manque sortait par la droite. `0 1 auto` + `min-width: 0` (sans lui un
1164       enfant flex ne descend jamais sous la largeur de son contenu) la rend
1165       compressible ; elle cede la premiere parce qu'elle est la plus large et
1166       la seule qu'on puisse tronquer sans perdre une fonction. */
1167    .topbar-logo { flex: 0 1 auto; min-width: 0; overflow: hidden; padding: 2px 4px;
1168      /* `0 1 auto` seul l'a fait passer de 210 a 171 px en 320 — puis il s'est
1169         arrete, et la pastille de langue sortait encore de 33 px. Un plafond
1170         RELATIF a l'ecran le force a continuer de ceder tant qu'il le faut,
1171         a toutes les largeurs, au lieu d'un seuil fixe a re-mesurer au
1172         prochain telephone. */
1173      max-width: 46vw; }
1174    /* Reduction mode: shrink the badge to ~line height so it sits inline with
1175       the icon row instead of dominating the strip (Guillaume 2026-05-27). */
1176    .topbar-logo { --gk-logo-height: 24px; }
1177    .topbar-logo img { max-width: 90px; }
1178    .topbar.is-scrolled .topbar-logo { --gk-logo-height: 22px; }
1179    .topbar-content { flex: 1 1 auto; min-width: 0; padding: 0; }
1180    .nav-collapsible { min-width: 0; }
1181    /* ── Single one-line header (Guillaume 2026-05-23) ──
1182       Everything on ONE row. nowrap is forced (!important) to beat the base
1183       `.nav-row { flex-wrap: wrap }` + the old per-element wrap rules. If the
1184       cluster is wider than the viewport on a very narrow phone it SCROLLS
1185       horizontally rather than wrapping — the scrollbar is hidden so it reads
1186       as a tidy single bar that the thumb can nudge. */
1187    .nav-row {
1188      flex-wrap: nowrap !important;
1189      justify-content: flex-start;   /* pack icons left — no mid-strip void */
1190      margin: 0; gap: 6px;
1191      min-width: 0;
1192      /* Le defilement descend sur `.nav-links` (regle juste dessous) au lieu
1193         de porter sur toute la rangee. La rangee reste UNE ligne, decision
1194         de Guillaume du 2026-05-23 inchangee ; ce qui change est QUI defile.
1195         Avec la rangee entiere en `overflow-x: auto`, les six entrees de
1196         sphaigne repoussaient « Se connecter »/« Creer un compte » a x=535 et
1197         x=570 dans un ecran de 390 : presents, mais atteignables seulement
1198         par un glissement lateral sans affordance. Les actions restent
1199         maintenant visibles, et seules les entrees de nav defilent. */
1200      -webkit-overflow-scrolling: touch;
1201    }
1202    .nav-row::-webkit-scrollbar { display: none; }   /* WebKit/Blink */
1203    /* ── Menu accordeon, rangee chargee (>=5 entrees) ────────────────
1204       Le defilement horizontal des entrees (regle ci-dessous) supposait
1205       que le pouce trouve une bande qui ne dit pas qu'elle defile. Mesure
1206       du 2026-08-31 sur sphaigne en 390 px : des six entrees, une seule
1207       etait visible, coupee en deux par la pastille de connexion — les
1208       cinq autres n'existaient pas pour le visiteur. Un ticket proposait
1209       « mettre un scroll horizontal » : c'est ce qui etait deja en place
1210       et qui echouait.
1211       On replie donc les entrees derriere un bouton, en case a cocher
1212       PURE CSS — meme mecanique que la nav du noyau (gk-nav.css, « pure
1213       CSS, no JS ») : la case precede `.nav-links`, `:checked ~` ouvre le
1214       panneau. Naviguer recharge la page, donc la case se decoche seule.
1215       Uniquement sur `.is-nav-dense` : lorvert et poppies n'ont pas
1216       d'entree publique, leur barre n'a rien a replier. */
1217    .is-nav-dense .nav-accordion-btn {
1218      display: inline-flex; align-items: center; justify-content: center;
1219      flex: 0 0 auto; width: 38px; height: 34px;
1220      border: 1px solid var(--topbar-border); border-radius: 8px;
1221      background: rgba(0,0,0,0.15); color: var(--topbar-text);
1222      font-size: 16px; cursor: pointer;
1223    }
1224    .is-nav-dense .nav-links { display: none; }
1225    .is-nav-dense .nav-accordion-toggle:checked ~ .nav-links {
1226      display: flex; flex-direction: column; align-items: stretch;
1227      position: absolute; top: 100%; left: 0; right: 0;
1228      width: auto !important; max-width: none;
1229      max-height: calc(100vh - 120px); overflow-y: auto; overflow-x: hidden;
1230      background: var(--topbar-bg); color: var(--topbar-text);
1231      border-bottom: 1px solid var(--topbar-border);
1232      box-shadow: 0 10px 26px rgba(0,0,0,.22);
1233      padding: 8px; gap: 4px; z-index: 1031;
1234    }
1235    .is-nav-dense .nav-accordion-toggle:checked ~ .nav-links li { width: 100%; }
1236    .is-nav-dense .nav-accordion-toggle:checked ~ .nav-links li a,
1237    .is-nav-dense .nav-accordion-toggle:checked ~ .nav-links li button.nav-btn-newcmd {
1238      display: flex; flex-direction: row; align-items: center;
1239      justify-content: flex-start; text-align: left;
1240      box-sizing: border-box;
1241      gap: 14px; width: 100%; min-height: 52px;
1242      padding: 12px 14px; border-radius: 8px; font-size: 15px;
1243    }
1244    .is-nav-dense .nav-accordion-toggle:checked ~ .nav-links li a .nav-text,
1245    .is-nav-dense .nav-accordion-toggle:checked ~ .nav-links li button.nav-btn-newcmd .nav-text {
1246      display: inline;
1247    }
1248    .is-nav-dense .nav-accordion-toggle:checked ~ .nav-links li a > .fa-solid,
1249    .is-nav-dense .nav-accordion-toggle:checked ~ .nav-links li button.nav-btn-newcmd > .fa-solid {
1250      font-size: 18px !important; min-width: 26px; text-align: center;
1251    }
1252    /* Center nav must not hog width (the old width:100% pushed search +
1253       actions onto new lines). Size to content, never wrap internally. */
1254    .nav-links {
1255      flex: 0 1 auto;
1256      min-width: 0;
1257      width: auto !important;
1258      flex-wrap: nowrap !important;
1259      flex-direction: row;
1260      gap: 2px;
1261      overflow-x: auto;
1262      -webkit-overflow-scrolling: touch;
1263      scrollbar-width: none;
1264    }
1265    .nav-links::-webkit-scrollbar { display: none; }
1266    .nav-links li a,
1267    .nav-links li button.nav-btn-newcmd { padding: 5px 6px; min-width: 36px; min-height: 34px; font-size: 18px; }
1268    .nav-links li a > .fa-solid,
1269    .nav-links li button.nav-btn-newcmd > .fa-solid { font-size: 20px !important; }
1270    .nav-links li a .nav-text,
1271    .nav-links li button.nav-btn-newcmd .nav-text { display: none; }
1272    /* Actions sit right after the search — no margin-left:auto, so there's
1273       no gap between the search and the controls (Guillaume 2026-05-27). */
1274    .nav-actions { flex: 0 0 auto; flex-wrap: nowrap !important; gap: 4px; margin: 0; }
1275    .gk-search-trigger { flex: 0 0 auto; margin-right: 0; }
1276    /* Contact info (Service Client PRO) is hidden on the mobile strip —
1277       it's on the landing + footer, and the phone is a tap-to-call link
1278       there. Frees the row (Guillaume 2026-05-27). */
1279    .gk-support-pill { display: none; }
1280    .connect-pill-name { display: none; }
1281    /* ── Les 40 px qui manquent au telephone ────────────────────────────
1282       Mesure du 2026-09-01 en 390 px sur beta.sphaigne-chili.com/catalog :
1283       le bord droit de la pastille de langue tombe a 411 — elle est COUPEE.
1284       Connecte c'est pire : la capture de l'operateur montre l'engrenage et
1285       la pastille de compte tranches au bord droit, parce que la rangee
1286       porte en plus la recherche, le compte et la bascule B2C.
1287
1288       On COMPACTE, on ne supprime pas : `gk-b2c-btn` est une BASCULE (elle
1289       change l'affichage des prix), pas un badge — la cacher retirerait une
1290       fonction. Le pictogramme suffit a l'identifier, comme le bloc contact
1291       plus haut se replie deja sur son telephone. */
1292    .gk-b2c-label { display: none; }
1293    .gk-langpill-globe { display: none; }
1294    .nav-actions { gap: 3px; }
1295    /* Mesure APRES ces deux compactages : le bord droit tombait de 411 a 409
1296       pour un ecran de 390 — le globe et l'etiquette B2C ne suffisaient pas
1297       (la bascule B2C est masquee pour un anonyme, elle ne rendait rien).
1298       Les deux pastilles d'authentification portent 7px de padding vertical
1299       et 9 lateral ; les resserrer rend les ~20 px qui manquent, sans rien
1300       retirer de l'ecran. */
1301    .nav-cta { padding: 6px 7px; }
1302    .nav-row { gap: 4px; }
1303    .topbar-inner { padding-left: 6px; padding-right: 6px; }
1304    /* ⚠ LA VRAIE CAUSE. Mesure du 2026-09-01 : le bord droit valait 405 en
1305       390 px, 405 en 360 ET 405 en 320 — IDENTIQUE. Une rangee qui ne bouge
1306       pas quand l'ecran retrecit ne « deborde » pas un peu : elle ignore
1307       l'ecran. Rien dedans ne pouvait ceder, donc chaque pixel manquant
1308       sortait par la droite, et compacter les pastilles une a une ne rendait
1309       que quelques pixels a chaque fois.
1310       On rend la marque COMPRESSIBLE : `flex: 0 1 auto` + `min-width: 0`
1311       (sans lui, un enfant flex ne descend jamais sous son contenu) et une
1312       coupure a l'ellipse. Elle cede la premiere parce qu'elle est la plus
1313       large et la seule qu'on puisse tronquer sans perdre une fonction : un
1314       nom de site raccourci reste lisible, un bouton hors ecran ne se clique
1315       pas. */
1316    .gk-nav-brand { flex: 0 1 auto; min-width: 0; overflow: hidden; }
1317    .gk-nav-brand > span { min-width: 0; overflow: hidden; }
1318    /* Pour qu'une ellipse s'AFFICHE, c'est l'element porteur du texte qui
1319       doit etre contraint — sinon le parent le rogne et la coupure tombe au
1320       milieu d'une lettre, sans point de suspension. On propage donc la
1321       contrainte : la marque ne depasse pas sa colonne, et chaque ligne de
1322       texte est un bloc compressible. */
1323    .topbar-logo > .gk-nav-brand { max-width: 100%; }
1324    .gk-nav-brand > span { flex: 0 1 auto; }
1325    .gk-nav-brand > span > span { display: block; white-space: nowrap;
1326      overflow: hidden; text-overflow: ellipsis;
1327      /* `max-width` EST la piece qui manquait. Mesure du 2026-09-01 en
1328         390 px : la ligne de texte faisait 166 px dans un parent de 99 —
1329         `brand_html` pose `align-items: flex-start` en ligne, donc les
1330         enfants se dimensionnent sur leur CONTENU et non sur le conteneur.
1331         Sans contrainte, `text-overflow` n'a rien a tronquer : le parent
1332         rognait, et la coupure tombait au milieu d'une lettre. */
1333      max-width: 100%; }
1334    .connect-pill { padding: 3px; }
1335  }
1336
1337  /* Palier le plus etroit. En 46vw la rangee tient a 412, 390 et 360 px avec
1338     le nom du site ENTIER ; a 320 elle depasse de 18. Ce bloc DOIT suivre
1339     celui de 767 : meme specificite, donc c'est l'ordre qui tranche. */
1340  @media (max-width: 359px) {
1341    .topbar-logo { max-width: 38vw; }
1342  }
1343
1344  /* Header search row (option C — ported from _archive/header.php).
1345     Appears below the nav-row inside the topbar; visible on every page
1346     that includes _gk_nav (catalog, orders, brands, cart). Each input
1347     drives the catalog Vue via window.headerSearch when present, or
1348     navigates to /catalog?searchField=X&searchTerm=Y on non-catalog
1349     pages (catalog.html's mount reads those params). Autocomplete is
1350     fed by /api/lorvert/autocomplete. */
1351  .search-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; padding: 0 4px 8px; }
1352  .search-wrap { position: relative; }
1353  .search-wrap input { width: 100%; padding: 5px 28px 5px 10px; font-size: 13px; border: 1px solid rgba(255,255,255,0.25); border-radius: 6px; height: 30px; background: #fff; color: #1f2937; transition: border-color .15s, box-shadow .15s, background .15s; }
1354  .search-wrap input::placeholder { color: #6b7280; }
1355  .search-wrap input:focus { outline: none; border-color: var(--topbar-accent); background: #fff; box-shadow: 0 0 0 2px color-mix(in srgb, var(--topbar-accent) 30%, transparent); }
1356  .search-arrow { position: absolute; right: 1px; top: 1px; bottom: 1px; width: 24px; background: #f3f4f6; border: none; border-left: 1px solid #e5e7eb; border-radius: 0 5px 5px 0; cursor: pointer; display: flex; align-items: center; justify-content: center; color: #4b5563; font-size: 10px; }
1357  .search-arrow:hover { background: #e5e7eb; color: #111; }
1358  .ac-list { position: absolute; top: 100%; left: 0; right: 0; z-index: 1100; background: #fff; border: 1px solid #d1d5db; border-top: none; border-radius: 0 0 6px 6px; max-height: 300px; overflow-y: auto; box-shadow: 0 4px 12px rgba(0,0,0,0.16);
1358 margin-top: 1px; }
1359  .ac-item { padding: 6px 10px; font-size: 13px; cursor: pointer; color: #333; }
1360  .ac-item:hover, .ac-item.ac-active { background: #fff7d6; color: #111; }
1361  html[data-theme="dark"] .ac-list { background: #1a1e27; border-color: rgba(255,255,255,0.10); box-shadow: 0 4px 12px rgba(0,0,0,0.6); }
1362  html[data-theme="dark"] .ac-item { color: rgba(255,255,255,0.85); }
1363  html[data-theme="dark"] .ac-item:hover, html[data-theme="dark"] .ac-item.ac-active { background: rgba(252,211,77,0.18); color: #fff; }
1364  @media (max-width: 900px) { .search-row { grid-template-columns: repeat(2, 1fr); } }
1365  @media (max-width: 480px) { .search-row { grid-template-columns: 1fr; } }
1366
1367  /* Hortitec-style unified search — trigger + modal. The trigger
1368     looks like a full-width search input but is a button: click anywhere
1369     to open the modal. */
1370  /* Search trigger — fixed-width pill (no more typewriter / max-width
1371     dance). Bluish translucent surface sits naturally against the navy
1372     topbar; the constant gold blinking cursor at the end signals
1373     "click to type" without taking up extra space. */
1374  .gk-search-trigger {
1375    display: inline-flex; align-items: center; gap: 10px;
1376    flex: 1 1 auto;                 /* the elastic middle — absorbs/returns slack */
1377    width: auto; min-width: 0; max-width: 720px; height: 38px;
1378    padding: 4px 16px; margin: 0;
1379    /* ── La recherche prend la couleur de SA barre ────────────────────
1380       Ces trois valeurs etaient un bleu indigo en dur, taille pour la barre
1381       navy de lorvert. Sur sphaigne, dont la barre est creme, le champ
1382       ressortait en periwinkle sur fond ivoire — « the research colors are
1383       ugly », owner 2026-08-31, capture a l'appui. Les jetons `--topbar-*`
1384       sont deja resolus par site (`_data_theme.html`), donc le champ suit
1385       desormais la barre au lieu de supposer laquelle il habite. */
1386    border: 1px solid var(--topbar-border, rgba(147,197,253,0.30));
1387    border-radius: 8px;
1388    background: var(--topbar-hover-bg, rgba(30,58,138,0.28));
1389    color: var(--topbar-muted, rgba(226,232,240,0.78));
1390    font: inherit; font-size: 14px; text-align: left; cursor: pointer;
1391    transition: background .15s, border-color .15s, box-shadow .15s;
1392  }
1393  .gk-search-trigger:hover, .gk-search-trigger:focus {
1394    background: color-mix(in srgb, var(--topbar-accent, #1e3a8a) 10%, var(--topbar-hover-bg, rgba(30,58,138,0.42)));
1395    border-color: var(--topbar-accent, rgba(147,197,253,0.55));
1396    box-shadow: 0 0 0 2px color-mix(in srgb, var(--topbar-accent) 25%, transparent);
1397    color: var(--topbar-text, #f1f5f9);
1398  }
1399  .gk-search-trigger i { color: var(--topbar-muted, rgba(191,219,254,0.80)); font-size: 14px; }
1400  /* flex:0 1 auto (not 1) so the text sizes to its content and the gold
1401     ::after caret hugs the end of the placeholder instead of being pushed
1402     to the right edge; min-width:0 keeps ellipsis truncation when tight. */
1403  .gk-search-trigger-text { flex: 0 1 auto; min-width: 0; display: inline-block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1404  /* Permanent gold-blinking cursor at the right edge of the trigger —
1405     replaces the per-char typewriter caret. Tells the user "press here
1406     to start typing" without any width animation. */
1407  .gk-search-trigger::after {
1408    content: '▌'; flex: 0 0 auto;
1409    color: var(--topbar-accent);
1410    font-weight: 700;
1411    animation: gkCaretBlink 1s steps(2, end) infinite;
1412  }
1413  @keyframes gkCaretBlink { 50% { opacity: 0; } }
1414
1415  /* ── Responsive search trigger (Guillaume 2026-05-18) ─────────────
1416     Three smooth steps as the viewport narrows:
1417       - ≥1100px : full 280px, placeholder + caret (default rules above)
1418       - 900-1099 : 200px, caret hidden (less twitch in tight rows)
1419       - 720-899  : 140px, placeholder still visible but truncated
1420       - ≤719px   : icon-only square button (collapses to ~36px) — the
1421                    placeholder + caret hide, click still opens the
1422                    same modal. UX-equivalent to a search-icon-button.
1423     Width + padding transition together so the collapse reads as a
1424     smooth fold rather than a snap. */
1425  .gk-search-trigger { transition: width .25s ease, padding .25s ease,
1426                                   background .15s, border-color .15s,
1427                                   box-shadow .15s; }
1428  @media (max-width: 1099px) {
1429    /* search stays elastic (base flex:1) — just drop the blinking caret */
1430    .gk-search-trigger::after { display: none; }
1431  }
1432  @media (max-width: 899px) {
1433    .gk-search-trigger { gap: 8px; padding-left: 10px; padding-right: 10px; }
1434  }
1435  @media (max-width: 719px) {
1436    .gk-search-trigger {
1437      flex: 0 0 auto;          /* stop the elastic grow — fixed icon on the strip */
1438      width: 36px; height: 36px;
1439      padding: 0;
1440      gap: 0;
1441      justify-content: center;
1442      margin-right: 6px;
1443    }
1444    .gk-search-trigger .gk-search-trigger-text { display: none; }
1445    .gk-search-trigger i { font-size: 14px; color: var(--topbar-text, rgba(226,232,240,0.92)); }
1446  }
1447
1448  /* Search modal — themed via the site CSS variables emitted by
1449     _data_theme.html so the panel feels native to lorvert/poppies
1450     instead of a generic gray sheet. Overlay tints with the brand
1451     navy; panel has a thin accent stripe along the top. */
1452  .gk-search-modal { display: none; position: fixed; inset: 0; z-index: 3000; background: color-mix(in srgb, var(--text, #15233e) 70%, transparent); align-items: flex-start; justify-content: center; padding: 60px 20px 20px; overflow-y: auto; }
1453  .gk-search-modal.is-open { display: flex; animation: gkSearchFade .18s ease-out; }
1454  @keyframes gkSearchFade { from { opacity: 0; } to { opacity: 1; } }
1455  .gk-search-panel { width: 100%; max-width: 720px; background: var(--surface, #fff); border-radius: 14px; box-shadow: 0 20px 60px rgba(15,23,42,0.35); overflow: hidden; animation: gkSearchSlide .22s cubic-bezier(.16,1,.3,1); border-top: 3px solid var(--accent, #f3c84b); }
1456  @keyframes gkSearchSlide { from { transform: translateY(-12px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
1457  .gk-search-head { display: flex; align-items: center; gap: 10px; padding: 14px 18px; border-bottom: 1px solid var(--border, #e5e7eb); }
1458  .gk-search-head input { flex: 1; min-width: 0; border: none; outline: none; font: inherit; font-size: 16px; padding: 6px 4px; color: #1f2937; background: transparent; }
1459  /* Suppress browser-native <input type="search"> clear button. Without
1460     this rule Chromium/Safari render their own X next to the custom
1461     `.gk-search-clear` + `.gk-search-close` → three X icons in a row. */
1462  .gk-search-head input::-webkit-search-cancel-button,
1463  .gk-search-head input::-webkit-search-decoration { -webkit-appearance: none; appearance: none; }
1464  .gk-search-head input[type="search"] { -moz-appearance: textfield; appearance: textfield; }
1465  .gk-search-head .gk-search-clear { width: 28px; height: 28px; border: none; background: transparent; color: #9ca3af; font-size: 14px; cursor: pointer; border-radius: 6px; }
1466  .gk-search-head .gk-search-clear:hover { background: #f3f4f6; color: #1f2937; }
1467  .gk-search-head .gk-search-close { width: 32px; height: 32px; border: none; background: transparent; color: #6b7280; font-size: 16px; cursor: pointer; border-radius: 8px; }
1468  .gk-search-head .gk-search-close:hover { background: #f3f4f6; color: #1f2937; }
1469  .gk-search-head .gk-search-icon { color: #6b7280; font-size: 18px; padding: 0 4px; }
1470  .gk-search-body { max-height: calc(100vh - 180px); overflow-y: auto; }
1471  .gk-search-empty { padding: 48px 24px; text-align: center; color: #9ca3af; font-size: 13px; }
1472  /* Quick-action shortcuts (Nouveautés / Promotions / Mes favoris).
1473     One-liner inline pills — all three share a single row on desktop,
1474     wrap to two/one on narrow viewports. Semantic icon colour is
1475     inline (blue=new, red=promo, pink=favoris) matching the catalog
1476     sidebar's `.sb-item` vocabulary so the customer recognises the
1477     destination instantly. Adding a 4th action drops in markup-only;
1478     the row stays flex-1 so the new chip resizes the others. */
1479  .gk-search-quick { display: flex; flex-wrap: wrap; gap: 6px; padding: 10px 16px; border-bottom: 1px solid var(--border, #e5e7eb); }
1480  .gk-search-quick-btn {
1481    flex: 1 1 0; min-width: 110px;
1482    display: inline-flex; align-items: center; justify-content: center; gap: 6px;
1483    padding: 5px 10px;
1484    font-size: 12px; font-weight: 600;
1485    color: var(--text, #1f2937);
1486    background: var(--surface, #fff);
1487    border: 1px solid var(--border, #e5e7eb);
1488    border-radius: 999px;
1489    text-decoration: none; cursor: pointer;
1490    transition: border-color .15s, background .15s, color .15s;
1491  }
1492  .gk-search-quick-btn:hover {
1493    border-color: var(--accent, #f3c84b);
1494    background: color-mix(in srgb, var(--accent, #f3c84b) 8%, var(--surface, #fff));
1495  }
1496  .gk-search-quick-icon { font-size: 13px; flex-shrink: 0; }
1497  .gk-search-section { padding: 14px 22px 4px; }
1498  .gk-search-section + .gk-search-section { border-top: 1px solid var(--border, #f3f4f6); padding-top: 14px; }
1499  .gk-search-section-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; font-size: 11px; font-weight: 700; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.08em; }
1500  .gk-search-section-head a { color: var(--topbar-accent); text-decoration: none; font-weight: 600; font-size: 11px; text-transform: none; letter-spacing: 0; }
1501  .gk-search-section-head a:hover { text-decoration: underline; }
1502  .gk-search-product { display: flex; align-items: center; gap: 14px; padding: 10px 4px; border-bottom: 1px solid #f3f4f6; color: inherit; text-decoration: none; cursor: pointer; }
1503  .gk-search-product:last-child { border-bottom: none; }
1504  .gk-search-product:hover { background: #fafafa; }
1505  .gk-search-product .gk-sp-img { flex: 0 0 56px; width: 56px; height: 56px; border-radius: 6px; background: #f9fafb; display: flex; align-items: center; justify-content: center; overflow: hidden; }
1506  .gk-search-product .gk-sp-img img { max-width: 100%; max-height: 100%; object-fit: contain; }
1507  .gk-search-product .gk-sp-img i { color: #d1d5db; font-size: 20px; }
1508  .gk-search-product .gk-sp-body { flex: 1 1 auto; min-width: 0; }
1509  .gk-search-product .gk-sp-brand { font-size: 11px; font-weight: 700; color: #16a34a; text-transform: uppercase; letter-spacing: 0.04em; }
1510  .gk-search-product .gk-sp-name { font-size: 14px; color: #1f2937; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1511  .gk-search-product .gk-sp-ref { font-size: 11px; color: #9ca3af; margin-top: 2px; }
1512  .gk-search-product .gk-sp-fam { font-size: 11px; font-weight: 600; color: #6b7280; margin-top: 3px; display: flex; align-items: center; gap: 5px; }
1513  .gk-search-product .gk-sp-fam .gk-sp-dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; }
1514  .gk-search-product .gk-sp-price { flex: 0 0 auto; text-align: right; }
1515  .gk-search-product .gk-sp-price-lbl { font-size: 10px; color: #9ca3af; }
1516  .gk-search-product .gk-sp-price-val { font-size: 14px; font-weight: 700; color: #1f2937; }
1517  .gk-search-chips { display: flex; flex-wrap: wrap; gap: 6px; padding-bottom: 4px; }
1518  .gk-search-chip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 12px 4px 6px; font-size: 12px; color: #374151; background: #f3f4f6; border: none; border-radius: 999px; cursor: pointer; font-weight: 500; text-decoration: none; line-height: 1.2; }
1519  .gk-search-chip:hover { background: #e5e7eb; }
1520  .gk-search-chip.is-on { background: var(--topbar-accent); color: var(--topbar-on-accent); font-weight: 600; }
1521  .gk-search-chip--brand { padding-left: 4px; }
1522  .gk-search-chip-logo { width: 20px; height: 20px; object-fit: contain; background: #fff; border-radius: 50%; padding: 1px; flex: 0 0 auto; }
1523  .gk-search-hl { background: #fef08a; padding: 0 1px; border-radius: 2px; color: inherit; }
1524
1525  /* ── Dark mode (2026-05-21) — the search modal had hardcoded light
1526     colors that stayed white-on-dark. Mirror the landing's dark palette
1527     (#1a1e27 panel / #f3f6fb text) so the modal matches the page. ── */
1528  html[data-theme="dark"] .gk-search-panel { background: #1a1e27; box-shadow: 0 20px 60px rgba(0,0,0,0.55); }
1529  html[data-theme="dark"] .gk-search-head { border-bottom-color: rgba(255,255,255,0.10); }
1530  html[data-theme="dark"] .gk-search-head input { color: #f3f6fb; }
1531  html[data-theme="dark"] .gk-search-head input::placeholder { color: rgba(255,255,255,0.45); }
1532  html[data-theme="dark"] .gk-search-head .gk-search-icon,
1533  html[data-theme="dark"] .gk-search-head .gk-search-close { color: rgba(255,255,255,0.65); }
1534  html[data-theme="dark"] .gk-search-head .gk-search-clear { color: rgba(255,255,255,0.50); }
1535  html[data-theme="dark"] .gk-search-head .gk-search-clear:hover,
1536  html[data-theme="dark"] .gk-search-head .gk-search-close:hover { background: rgba(255,255,255,0.10); color: #fff; }
1537  html[data-theme="dark"] .gk-search-quick { border-bottom-color: rgba(255,255,255,0.10); }
1538  html[data-theme="dark"] .gk-search-quick-btn { background: rgba(255,255,255,0.06); border-color: rgba(255,255,255,0.14); color: #e5e7eb; }
1539  html[data-theme="dark"] .gk-search-quick-btn:hover { background: color-mix(in srgb, var(--accent, #f3c84b) 14%, #1a1e27); border-color: var(--accent, #f3c84b); }
1540  html[data-theme="dark"] .gk-search-section + .gk-search-section { border-top-color: rgba(255,255,255,0.08); }
1541  html[data-theme="dark"] .gk-search-section-head { color: rgba(255,255,255,0.50); }
1542  html[data-theme="dark"] .gk-search-product { border-bottom-color: rgba(255,255,255,0.07); }
1543  html[data-theme="dark"] .gk-search-product:hover { background: rgba(255,255,255,0.04); }
1544  html[data-theme="dark"] .gk-search-product .gk-sp-img { background: rgba(255,255,255,0.05); }
1545  html[data-theme="dark"] .gk-search-product .gk-sp-name,
1546  html[data-theme="dark"] .gk-search-product .gk-sp-price-val { color: #f3f6fb; }
1547  html[data-theme="dark"] .gk-search-product .gk-sp-ref,
1548  html[data-theme="dark"] .gk-search-product .gk-sp-price-lbl { color: rgba(255,255,255,0.45); }
1549  html[data-theme="dark"] .gk-search-empty { color: rgba(255,255,255,0.45); }
1550  html[data-theme="dark"] .gk-search-chip { background: rgba(255,255,255,0.08); color: #e5e7eb; }
1551  html[data-theme="dark"] .gk-search-chip:hover { background: rgba(255,255,255,0.14); }
1552  html[data-theme="dark"] .gk-search-hl { background: rgba(243,200,75,0.30); color: #fff; }
1553
1554  /* ── Premium confirm modal (replaces window.confirm) ─────────────── */
1555  .gk-confirm-ov { position: fixed; inset: 0; z-index: 4000; display: flex; align-items: center; justify-content: center; padding: 20px; background: color-mix(in srgb, #0b1220 55%, transparent); backdrop-filter: blur(3px); opacity: 0; transition: opacity .18s ease; }
1556  .gk-confirm-ov.is-open { opacity: 1; }
1557  .gk-confirm { width: 100%; max-width: 400px; background: var(--surface, #fff); color: var(--text, #1f2937); border-radius: 16px; padding: 28px 26px 22px; text-align: center; box-shadow: 0 24px 70px rgba(0,0,0,0.35); border-top: 3px solid var(--accent, #f3c84b); transform: translateY(10px) scale(.97); transition: transform .22s cubic-bezier(.16,1,.3,1); }
1558  .gk-confirm-ov.is-open .gk-confirm { transform: translateY(0) scale(1); }
1559  .gk-confirm-icon { width: 54px; height: 54px; margin: 0 auto 14px; border-radius: 50%; display: flex; align-items: center; justify-content: center; background: color-mix(in srgb, var(--accent, #f3c84b) 18%, transparent); color: var(--accent, #f3c84b); font-size: 22px; }
1560  .gk-confirm-title { margin: 0 0 8px; font-size: 17px; font-weight: 700; line-height: 1.3; }
1561  .gk-confirm-msg { margin: 0 0 22px; font-size: 14px; line-height: 1.55; color: color-mix(in srgb, var(--text, #1f2937) 72%, transparent); }
1562  .gk-confirm-msg strong { color: var(--text, #1f2937); font-weight: 700; }
1563  .gk-confirm-actions { display: flex; gap: 10px; justify-content: center; }
1564  .gk-confirm-actions button { flex: 1; padding: 11px 16px; font-size: 14px; font-weight: 600; border-radius: 10px; cursor: pointer; border: 1px solid transparent; transition: filter .15s, background .15s, border-color .15s; font-family: inherit; }
1565  .gk-confirm-cancel { background: transparent; border-color: var(--border, #e5e7eb); color: color-mix(in srgb, var(--text, #1f2937) 70%, transparent); }
1566  .gk-confirm-cancel:hover { background: color-mix(in srgb, var(--text, #1f2937) 6%, transparent); }
1567  .gk-confirm-ok { background: var(--accent, #f3c84b); color: var(--topbar-on-accent, #1A2C42); }
1568  .gk-confirm-ok:hover { filter: brightness(1.07); box-shadow: 0 4px 14px color-mix(in srgb, var(--accent, #f3c84b) 45%, transparent); }
1569  html[data-theme="dark"] .gk-confirm { background: #1a1e27; color: #f3f6fb; box-shadow: 0 24px 70px rgba(0,0,0,0.6); }
1570  html[data-theme="dark"] .gk-confirm-msg { color: rgba(255,255,255,0.72); }
1571  html[data-theme="dark"] .gk-confirm-msg strong { color: #f3f6fb; }
1572  html[data-theme="dark"] .gk-confirm-cancel { border-color: rgba(255,255,255,0.18); color: rgba(255,255,255,0.78); }
1573  html[data-theme="dark"] .gk-confirm-cancel:hover { background: rgba(255,255,255,0.07); }
1574  @keyframes gkConfirmIn { from { opacity: 0; } to { opacity: 1; } }
1575
1576  @media (max-width: 480px) {
1577    .gk-search-modal { padding: 12px; }
1578    .gk-search-product .gk-sp-img { flex: 0 0 44px; width: 44px; height: 44px; }
1579  }
1580
1581  /* ── Shrink-on-scroll (Guillaume 2026-05-18) ──────────────────────
1582     Mirrors the canonical .gk-nav.is-scrolled pattern shipped in
1583     gateka-core/static/gk-nav.css, but targets THIS file's .topbar
1584     element (lorvert/poppies are still on the bespoke .topbar chrome,
1585     not the canonical .gk-nav). Logo height drives the topbar height;
1586     shrinking the logo therefore shrinks the whole nav. The logo
1587     container has `flex: 0 0 200px` so its width is fixed regardless
1588     of inner img size — horizontal positions of the right cluster stay
1589     stable (Guillaume's hard rule from the elen iteration). JS toggle
1590     lives at the bottom of this file. */
1591  .topbar { transition: box-shadow .22s ease; }
1592  .topbar.is-scrolled { box-shadow: 0 2px 12px rgba(0,0,0,.08); }
1593  .topbar-logo img { transition: height .22s ease; }
1594  .topbar-logo { transition: padding .22s ease; }
1595  /* Compacted (scrolled): shrink the logo to ~line height so the whole bar
1596     collapses to a single tight row (Guillaume 2026-05-27). Was var-8px
1597     (~64–88px), barely smaller than resting. */
1598  /* Le compactage règle la VARIABLE, il n'écrase plus `height` : c'est ce
1599     qui garde le centrage optique valable dans tous les états. Écrasé en
1600     dur, le logo rétrécissait pendant que le calage au-dessus restait
1601     calculé sur la taille au repos (Guillaume : « when compacted, not
1602     centered anymore »). Le fichier annonçait déjà « Single knob:
1603     --gk-logo-height » — deux règles le contredisaient. */
1604  .topbar.is-scrolled { --gk-logo-height: 40px; }
1605  @media (max-width: 640px) {
1606    .topbar.is-scrolled { --gk-logo-height: 36px; }
1607  }
1608  /* Extra vertical squeeze (Guillaume 2026-05-18) — beyond the logo
1609     shrink + alert collapse, also tighten the inner padding +
1610     button geometry so the topbar's effective height drops by another
1611     ~20px when scrolled. Every reclaimed pixel goes to the catalog
1612     below. All transitions match the 220ms canonical so the whole
1613     fold reads as one motion, not a cascade. */
1614  .topbar-logo, .topbar-content, .nav-row,
1615  .nav-links li a, .nav-links li button.nav-btn-newcmd {
1616    transition: padding .22s ease, margin .22s ease,
1617                min-height .22s ease, gap .22s ease;
1618  }
1619  .topbar.is-scrolled .topbar-logo { padding-top: 0; padding-bottom: 0; }
1620  .topbar.is-scrolled .topbar-content { padding: 0; }
1621  .topbar.is-scrolled .nav-row { margin-bottom: 0; }
1622  .topbar.is-scrolled .nav-links li a,
1623  .topbar.is-scrolled .nav-links li button.nav-btn-newcmd {
1624    padding: 2px 8px;
1625    min-height: 30px;
1626    gap: 1px;
1627  }
1628  @media (min-width: 900px) {
1629    .topbar.is-scrolled .nav-links li a,
1630    .topbar.is-scrolled .nav-links li button.nav-btn-newcmd {
1631      padding: 4px 12px;
1632      min-height: 36px;
1633    }
1634  }
1635
1636  /* ── Contact compaction step (part of the layout ladder above) ──────────
1637     Full Service Client PRO pill (label + phone + email) ≥1200px where the
1638     elastic row has room; folds to a single phone icon in the tight band
1639     768–1199; hidden entirely on the ≤767 mobile strip (see that block).
1640     The base nav-row model (search-elastic, nowrap) already keeps it on one
1641     line at every width — this only swaps the pill's *content* density. */
1642  /* Rangee chargee (>=5 entrees publiques) : le palier « icone seule »
1643     court jusqu'a 1439. Il montait a 1599, ce qui repliait le bloc contact
1644     en une simple icone de telephone sur un ecran de 1440 — l'adresse de
1645     courriel, la seule information que le visiteur ne peut pas deviner,
1646     disparaissait alors qu'il restait de la place. Mesure du 2026-08-31 sur
1647     sphaigne (six entrees, pastille complete forcee) : la rangee demande
1648     1085px pour 1190 disponibles a 1440, et 1085 pour 1116 des 1366 — aucun
1649     debordement. On garde 1440 comme seuil et non 1366 : la nav connectee
1650     ajoute « Catalogue » et la recherche, et c'est la marge qui les absorbe.
1651     Meme declaration que le bloc 768-1199 ci-dessous, portee par .is-nav-dense. */
1652  @media (min-width: 1200px) and (max-width: 1439px) {
1653    .is-nav-dense .gk-support-pill { flex-direction: row; align-items: center;
1654                       padding: 0; gap: 0; border: none; background: transparent; }
1655    .is-nav-dense .gk-support-pill .gk-support-label,
1656    .is-nav-dense .gk-support-pill .gk-support-phone-text,
1657    .is-nav-dense .gk-support-pill .gk-support-mail { display: none; }
1658    .is-nav-dense .gk-support-pill .gk-support-phone {
1659      display: inline-flex; align-items: center; justify-content: center;
1660      width: 38px; height: 38px; border: 1px solid var(--topbar-border);
1661      border-radius: 8px; background: rgba(0,0,0,0.15);
1662    }
1663    .is-nav-dense .gk-support-pill .gk-support-phone i { font-size: 15px; opacity: 1; }
1664    /* Dernier cran : en 1280 il manquait ~40px pour « Boutique » apres la
1665       compaction de la pastille. On resserre l'entre-deux et les flancs des
1666       entrees plutot que de rogner la taille du texte — mesure du
1667       2026-08-31 : six entrees passent de 697px a ~640px, la liste tient. */
1668    .is-nav-dense .nav-links { gap: 1px; }
1669    .is-nav-dense .nav-links li a { padding-left: 6px; padding-right: 6px; }
1670  }
1671  @media (min-width: 768px) and (max-width: 1199px) {
1672    .gk-support-pill { flex-direction: row; align-items: center;
1673                       padding: 0; gap: 0; border: none; background: transparent; }
1674    .gk-support-pill .gk-support-label,
1675    .gk-support-pill .gk-support-phone-text,
1676    .gk-support-pill .gk-support-mail { display: none; }
1677    .gk-support-pill .gk-support-phone {
1678      display: inline-flex; align-items: center; justify-content: center;
1679      width: 38px; height: 38px; border: 1px solid var(--topbar-border);
1680      border-radius: 8px; background: rgba(0,0,0,0.15);
1681    }
1682    .gk-support-pill .gk-support-phone i { font-size: 15px; opacity: 1; }
1683  }
1684</style>
1685
1686
1687
1688<div class="topbar" id="topbar" data-brand="L&#39;Or Vert">
1689  <div class="topbar-inner">
1690    <div class="topbar-logo">
1691      
1692      
1693      
1694        <a href="/" class="gk-nav-brand" title="L'Or Vert — Paris" style="display:inline-flex;align-items:center;gap:10px;text-decoration:none">
1695  <img src="/lorvert-paris-static/img/logo.png" alt="L'Or Vert" style="height:40px;width:auto">
1696  <span style="font-weight:800;font-size:1.15rem;letter-spacing:-.01em;color:var(--accent-dim,#0f4d24)">L'Or Vert</span>
1697</a>
1698      
1699    </div>
1700    <div class="topbar-content">
1701      <div class="nav-collapsible">
1702        <div class="nav-row is-nav-nosearch">
1703          
1704          
1705          
1706          <ul class="nav-links">
1707            
1708          </ul>
1709          
1710          
1711          
1712          <div class="nav-actions">
1713            
1714            
1715            
1716            
1717            
1718            
1719            <a href="/#signin" class="nav-cta nav-cta--primary">
1720              <i class="fa-solid fa-right-to-bracket"></i>
1721              <span class="nav-text"> Se connecter</span>
1722            </a>
1723            <a href="/#register" class="nav-cta nav-cta--ghost">
1724              <i class="fa-solid fa-user-plus"></i>
1725              <span class="nav-text"> Créer un compte</span>
1726            </a>
1727            
1728            
1729            <button class="gk-b2c-toggle" id="gk-b2c-btn" type="button"
1730                    onclick="toggleB2cMode()" title="Affichage prix B2C — panier reste en B2B"
1731                    aria-label="Toggle B2C price display"
1732                    style="display:none;">
1733              <i class="fa-solid fa-tag"></i>
1734              <span class="gk-b2c-label">B2C</span>
1735            </button>
1736            
1737            
1738              
1739
1740
1741
1742
1743
1744
1745
1746<script src="/gateka/static/gk-nav.js?v=prod-20260926-a" defer></script>
1746
1747
1748
1749
1750<noscript><style>
1751  /* Sans JS, rien ne repose jamais `aria-expanded` : les segments doivent
1752     redevenir lisibles et cliquables, exactement comme avant ce lot.
1753     `!important` parce que cette feuille est en ligne dans le CORPS — même
1754     spécificité que `gk-nav.css`, et je ne veux pas dépendre de l'ordre
1755     source pour une règle d'accessibilité. */
1756  /* Le garde de collision a raison de les signaler : ce sont bien deux
1757     ecritures de la classe `gk-langpill`. Elle est DELIBEREE et bornee : ces
1758     regles ne font QUE neutraliser les deux replis de `gk-nav.css`, sous
1759     `<noscript>`, donc elles ne peuvent pas s'appliquer en meme temps que
1760     lui. Consolider n'est pas possible : une feuille externe ne sait pas
1761     si JS est actif, c'est precisement ce que `<noscript>` repond. */
1762  .gk-langpill:has(> .gk-langpill-trigger[aria-expanded="false"]) .gk-langpill-opts { /* noqa: class-collision */
1763    grid-template-columns: 1fr !important; padding-left: 2px !important;
1764  }
1765  .gk-langpill:has(> .gk-langpill-trigger[aria-expanded="false"]) .gk-langpill-seg { /* noqa: class-collision */
1766    opacity: 1 !important; transform: none !important;
1767  }
1768</style></noscript>
1769<details class="gk-langpill" id="gk-lang-pill" open>
1770  <summary class="gk-langpill-trigger"
1771           aria-expanded="false"
1772           aria-label="Changer de langue — Français"
1773           title="Changer de langue — Français">
1774    <i class="fa-solid fa-globe gk-langpill-globe" aria-hidden="true"></i>
1775    
1776    <span class="gk-langpill-code"><span class="gk-langpill-roll"><span>FR</span></span></span>
1777  </summary>
1778  <div class="gk-langpill-opts">
1779    
1780    
1781    <div class="gk-langpill-track gk-stagger">
1782      
1783      <a class="gk-langpill-seg gk-stagger-item" href="?lang=en" style="--i: 0"
1784         title="Passer en English" aria-label="Passer en English">EN</a>
1785      
1786      <a class="gk-langpill-seg gk-stagger-item" href="?lang=es" style="--i: 1"
1787         title="Passer en Español" aria-label="Passer en Español">ES</a>
1788      
1789    </div>
1790  </div>
1791</details>
1792
1793            
1794            
1795            
1796            
1797          </div>
1798        </div>
1799        
1800        
1801        
1802        
1803        <div class="upgrade-alert upgrade-alert--warning">
1804          <i class="fa-solid fa-triangle-exclamation"></i>
1805          Boutique de vente au détail en ligne en cours de rénovation.
1806          
1807        </div>
1808        
1809      </div>
1810    </div>
1811  </div>
1812</div>
1813
1814
1815<div class="lorvert-b2c-banner" role="status" aria-live="polite">
1816  <span class="lorvert-b2c-banner-tag">🏷 MODE B2C</span>
1817  <span class="lorvert-b2c-banner-text">
1818    Vous affichez les prix <strong>publics TTC</strong> pour un client particulier — le panier et la commande restent en tarif pro B2B.
1819  </span>
1820  <button type="button" class="lorvert-b2c-banner-exit" onclick="if(window.toggleB2cMode)window.toggleB2cMode();" title="Quitter le mode B2C">
1821    Quitter
1822  </button>
1823</div>
1824
1825
1826<div class="gk-search-modal" id="gk_search_modal" role="dialog" aria-modal="true" aria-label="Recherche">
1827  <div class="gk-search-panel">
1828    <div class="gk-search-head">
1829      <i class="fa-solid fa-magnifying-glass gk-search-icon"></i>
1830      <input type="search" id="gk_search_input" autocomplete="off"
1831             placeholder="Produit, marque, catégorie, réf…">
1832      <button type="button" class="gk-search-clear" id="gk_search_clear"
1833              aria-label="Effacer" hidden>
1834        <i class="fa-solid fa-xmark"></i>
1835      </button>
1836      <button type="button" class="gk-search-close" id="gk_search_close"
1837              aria-label="Fermer">
1838        <i class="fa-solid fa-xmark"></i>
1839      </button>
1840    </div>
1841    <div class="gk-search-body" id="gk_search_body">
1842      
1843      <div class="gk-search-quick" id="gk_search_quick">
1844        <a class="gk-search-quick-btn" href="/catalog?state=new">
1845          <i class="gk-search-quick-icon fa-solid fa-bolt" style="color:#3b82f6;"></i>
1846          <span class="gk-search-quick-label">Nouveautés</span>
1847        </a>
1848        <a class="gk-search-quick-btn" href="/catalog?state=promos">
1849          <i class="gk-search-quick-icon fa-solid fa-percent" style="color:#ef4444;"></i>
1850          <span class="gk-search-quick-label">Promotions</span>
1851        </a>
1852        <a class="gk-search-quick-btn" href="/catalog?state=favorites">
1853          <i class="gk-search-quick-icon fa-solid fa-heart" style="color:#ec4899;"></i>
1854          <span class="gk-search-quick-label">Mes favoris</span>
1855        </a>
1856      </div>
1857      
1858      <div class="gk-search-section gk-search-recent" id="gk_search_recent" hidden>
1859        <div class="gk-search-section-head">
1860          <span>Dernières recherches</span>
1861          <a href="#" id="gk_search_recent_clear">Effacer</a>
1862        </div>
1863        <div class="gk-search-chips" id="gk_search_recent_chips"></div>
1864      </div>
1865      
1866      <div class="gk-search-footer" id="gk_search_footer"></div>
1867      
1868      <div class="gk-search-results" id="gk_search_results">
1869        <div class="gk-search-empty">
1870          Commencez à taper pour rechercher.
1871        </div>
1872      </div>
1873    </div>
1874  </div>
1875</div>
1876<script>
1877/* Server-rendered copy for the JS below. The search-results builder
1878   and the archive-confirm dialog assemble HTML in JS, so their
1879   labels cannot be Jinja expressions inline — they arrive as
1880   constants instead of being hardcoded French, which is what they
1881   were. ADR-0038 forbids a client-side catalogue; this is the
1882   server rendering one language, which is the rule it asks for. */
1883var T_CATEGORIES = "Cat\u00e9gories";
1884var T_REF        = "Ref";
1885var T_ITEMS      = "articles";
1886var T_ARCHIVE_TITLE = "Archiver cette commande ?";
1887var T_ARCHIVE_BODY  = "La commande {order} sera archiv\u00e9e et une nouvelle d\u00e9marr\u00e9e. Vous la retrouverez dans votre historique.";
1888var T_ARCHIVE_OK    = "Archiver";
1889var T_CANCEL        = "Annuler";
1890/* The page language, for the JS that legitimately needs it (the
1891   decimal separator). Copy goes through the constants above, never
1892   through a branch on this. */
1893window.LORVERT_LANG = "fr";
1894(function () {
1895  /* Active-page highlight — flip `.active` on the matching <li> based
1896     on location.pathname so the CSS `.nav-links li.active a` rule
1897     (defined above) actually fires. Without this the topbar buttons
1898     all rendered base-styled regardless of which page was on screen —
1899     the only "highlighted" one was Nouvelle commande, and only
1900     because it carries `nav-btn-primary` (a CTA-gold class, not an
1901     active-page indicator). Lookup uses startsWith so `/orders/13310`
1902     still matches `/orders`, etc. */
1903  /* Expose the actual topbar height to the rest of the page (and to
1904     the beta banner's body padding-top calc) so content never hides
1905     behind the topbar. The beta banner sets
1906       body.gk-env-beta { padding-top: calc(banner-h + var(--navbar-h, 0)) }
1907     and without this the topbar (~110px) covered the top of the page. */
1908  var setNavH = function () {
1909    var tb = document.getElementById('topbar');
1910    if (!tb) return;
1911    var h = tb.offsetHeight || 0;
1912    document.body.style.setProperty('--navbar-h', h + 'px');
1913    document.documentElement.style.setProperty('--navbar-h', h + 'px');
1914  };
1915  setNavH();
1916  window.addEventListener('resize', setNavH);
1917  window.addEventListener('load', setNavH);
1918  // Track --navbar-h ONLY at transitionend, not during scroll
1919  // (Guillaume 2026-06-22 bug report: catalog page transition not
1920  // smooth). The previous on-scroll rAF measure read offsetHeight
1921  // every animation frame — a forced sync layout each time — which
1922  // on the Vue-rendered /catalog (sticky sidebar reading
1923  // `top: calc(var(--navbar-h, 76px) + …)`) cascaded into a relayout
1924  // of thousands of product rows per scroll frame. That's the
1925  // staircase. The squeeze itself is pure CSS (220ms ease); only the
1926  // FINAL --navbar-h needs to land. Listen on the topbar's animating
1927  // children so we catch the actual end-of-transition.
1928  var _topbarEl = document.getElementById('topbar');
1929  if (_topbarEl) {
1930    _topbarEl.addEventListener('transitionend', setNavH);
1931    document.querySelectorAll('#topbar img, #topbar .upgrade-alert,'
1932      + ' #topbar .nav-row, #topbar .nav-links a')
1933      .forEach(function (el) { el.addEventListener('transitionend', setNavH); });
1934  }
1935
1936  /* Match current path against each center-nav <a href> instead of a
1937     hardcoded id map — the entries are now data-driven from
1938     packages.yaml.Lorvert-Poppies.nav_pages (architect consult
1939     2026-05-16-move-lorvert-poppies-center-nav).
1940
1941     Parse each href so links that target a pane via /features?focus=1#…
1942     (e.g. Marques → /features?focus=1#pane-feat-brands per ADR-0035)
1943     get their pathname compared to location.pathname AND their hash
1944     compared to location.hash — otherwise the literal full-href compare
1945     failed (location.pathname is just "/features") and the Marques tab
1946     never picked up `.active`. */
1947  function updateActiveNav() {
1948    var path = location.pathname || '/';
1949    var hash = location.hash || '';
1950    var links = document.querySelectorAll('.nav-links li a[href]');
1951    var bestLi = null, bestScore = -1;
1952    for (var i = 0; i < links.length; i++) {
1953      var href = links[i].getAttribute('href');
1954      if (!href || href === '/' || href.charAt(0) !== '/') continue;
1955      var hrefUrl;
1956      try { hrefUrl = new URL(href, location.origin); }
1957      catch (_e) { continue; }
1958      var lp = hrefUrl.pathname, lh = hrefUrl.hash;
1959      var pathOk = path === lp
1960                || path.indexOf(lp + '/') === 0
1961                || path.indexOf(lp + '?') === 0;
1962      if (!pathOk) continue;
1963      // Hash-bearing links (pane targets) require an exact hash match so
1964      // /features (no hash) doesn't randomly pick up the first hashed
1965      // sibling. Pathname-only links win on pathname alone.
1966      var score;
1967      if (lh) score = (hash === lh) ? 2 : -1;
1968      else    score = 1;
1969      if (score > bestScore) {
1970        bestScore = score;
1971        bestLi = links[i].closest('li');
1972      }
1973    }
1974    // Drop any prior `.active` first — pane navigation (hashchange)
1975    // re-runs this without a full reload, so old highlights would stick.
1976    document.querySelectorAll('.nav-links li.active')
1977      .forEach(function (el) { el.classList.remove('active'); });
1978    if (bestLi) bestLi.classList.add('active');
1979  }
1980  updateActiveNav();
1981  // Re-evaluate on pane navigation inside /features (hash-routed) so the
1982  // topbar highlight follows the visible pane without a full reload.
1983  window.addEventListener('hashchange', updateActiveNav);
1984
1985  /* "Archiver et démarrer une nouvelle commande" — archives every
1986     status=1 draft of the caller, then lands on /catalog where the
1987     handler mints a fresh draft via `_active_or_create_order`. Called
1988     from the catalog sidebar and the cart-page summary panel (UX
1989     consult 2026-05-16 moved this off the topbar into the order-
1990     summary surfaces where it semantically belongs).
1991
1992     summary = optional {orderid, articles, totalTtc, lang} so the
1993     confirm prompt reads concretely (e.g. "Archiver la commande
1994     N° 13332 (8 articles, 224,62 € TTC) ?"). Pass null/undefined to
1995     skip the confirm (used by the migration banner's CTA).
1996
1997     Idempotent on error: any 4xx/5xx still navigates so the operator
1998     isn't trapped on a stuck button. */
1999  /* Premium custom confirm — replaces the native window.confirm. Returns
2000     a Promise<bool>. opts: { title, message (may contain <strong>),
2001     confirmLabel, cancelLabel, icon }. Theme-aware, ESC=cancel, Enter=ok,
2002     backdrop-tap=cancel, focus lands on the confirm button. */
2003  window._gkConfirm = function (opts) {
2004    opts = opts || {};
2005    return new Promise(function (resolve) {
2006      var ov = document.createElement('div');
2007      ov.className = 'gk-confirm-ov';
2008      ov.innerHTML =
2009        '<div class="gk-confirm" role="dialog" aria-modal="true">'
2010        + '<div class="gk-confirm-icon"><i class="' + (opts.icon || 'fa-solid fa-circle-question') + '"></i></div>'
2011        + (opts.title ? '<h3 class="gk-confirm-title">' + opts.title + '</h3>' : '')
2012        + '<p class="gk-confirm-msg">' + (opts.message || '') + '</p>'
2013        + '<div class="gk-confirm-actions">'
2014        +   '<button type="button" class="gk-confirm-cancel">' + (opts.cancelLabel || 'Annuler') + '</button>'
2015        +   '<button type="button" class="gk-confirm-ok">' + (opts.confirmLabel || 'Confirmer') + '</button>'
2016        + '</div></div>';
2017      document.body.appendChild(ov);
2018      requestAnimationFrame(function () { ov.classList.add('is-open'); });
2019      var done = false;
2020      function close(val) {
2021        if (done) return; done = true;
2022        document.removeEventListener('keydown', onKey);
2023        ov.classList.remove('is-open');
2024        setTimeout(function () { if (ov.parentNode) ov.parentNode.removeChild(ov); }, 200);
2025        resolve(val);
2026      }
2027      function onKey(e) { if (e.key === 'Escape') close(false); else if (e.key === 'Enter') close(true); }
2028      ov.querySelector('.gk-confirm-ok').addEventListener('click', function () { close(true); });
2029      ov.querySelector('.gk-confirm-cancel').addEventListener('click', function () { close(false); });
2030      ov.addEventListener('click', function (e) { if (e.target === ov) close(false); });
2031      document.addEventListener('keydown', onKey);
2032      setTimeout(function () { var b = ov.querySelector('.gk-confirm-ok'); if (b) b.focus(); }, 60);
2033    });
2034  };
2035
2036  window.gkArchiveAndNewOrder = function (btn, summary) {
2037    var lang = (summary && summary.lang) || (document.documentElement.lang || 'fr');
2038    var scope = window.LORVERT_SCOPE
2039              || (location.host.indexOf('poppies') >= 0 ? 'poppies' : 'lorvert');
2040    var doArchive = function () {
2041      if (btn) { btn.disabled = true; btn.style.opacity = '0.6'; }
2042      var go = function () { location.href = '/catalog?newcmd=1'; };
2043      fetch('/api/lorvert/orders/archive', {
2044        method: 'POST',
2045        credentials: 'same-origin',
2046        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
2047        body: 'scope=' + encodeURIComponent(scope),
2048      }).then(go, go);
2049    };
2050    if (summary && summary.orderid) {
2051      var fmt = function (n) {
2052        if (n == null || isNaN(+n)) return '';
2053        return (+n).toFixed(2).replace('.', lang === 'fr' ? ',' : '.');
2054      };
2055      var bits = [];
2056      if (summary.articles != null) bits.push(summary.articles + ' ' + T_ITEMS);
2057      if (summary.totalTtc != null) bits.push(fmt(summary.totalTtc) + ' € TTC');
2058      var detail = bits.length ? ' · ' + bits.join(' · ') : '';
2059      window._gkConfirm({
2060        icon: 'fa-solid fa-cart-plus',
2061        title: T_ARCHIVE_TITLE,
2062        message: T_ARCHIVE_BODY
2063          .replace('{order}', '<strong>N° ' + summary.orderid + '</strong>' + detail),
2064        confirmLabel: T_ARCHIVE_OK,
2065        cancelLabel: T_CANCEL,
2066      }).then(function (ok) { if (ok) doArchive(); });
2067    } else {
2068      doArchive();
2069    }
2070  };
2071
2072  /* Connect-pill open/close (clone of header.php's toggleConnectPill) */
2073  window.toggleConnectPill = function () {
2074    var wrap = document.getElementById('connect_pill_wrap');
2075    var btn  = document.getElementById('connect_pill_btn');
2076    var panel = document.getElementById('connect_pill_menu');
2077    if (!wrap || !btn) return;
2078    var open = !wrap.classList.contains('is-open');
2079    wrap.classList.toggle('is-open', open);
2080    btn.setAttribute('aria-expanded', open ? 'true' : 'false');
2081    if (panel) panel.setAttribute('aria-hidden', open ? 'false' : 'true');
2082    if (open) {
2083      // Anchor the menu to the viewport (position:fixed), computed from the
2084      // button rect. This frees it from the mobile .nav-row { overflow-x:auto }
2085      // scroll-valve, which otherwise clips the dropdown (Guillaume 2026-05-27).
2086      if (panel) {
2087        var r = btn.getBoundingClientRect();
2088        panel.style.position = 'fixed';
2089        panel.style.top = (r.bottom + 6) + 'px';
2090        panel.style.right = Math.max(8, window.innerWidth - r.right) + 'px';
2091        panel.style.left = 'auto';
2092      }
2093      setTimeout(function () {
2094        document.addEventListener('click', function close(e) {
2095          if (!wrap.contains(e.target)) {
2096            wrap.classList.remove('is-open');
2097            btn.setAttribute('aria-expanded', 'false');
2098            if (panel) panel.setAttribute('aria-hidden', 'true');
2099            document.removeEventListener('click', close);
2100          }
2101        });
2102        document.addEventListener('keydown', function esc(e) {
2103          if (e.key === 'Escape') {
2104            wrap.classList.remove('is-open');
2105            btn.setAttribute('aria-expanded', 'false');
2106            if (panel) panel.setAttribute('aria-hidden', 'true');
2107            document.removeEventListener('keydown', esc);
2108          }
2109        });
2110      }, 0);
2111    }
2112  };
2113
2114  /* Le commutateur de langue n'a plus d'ecouteur ici — 2026-08-16.
2115     Il posait `document.cookie = 'lang=…'` puis rechargeait. C'etait un
2116     SECOND chemin d'ecriture du cookie de langue, a cote de
2117     `gateka.cookies.LANG` qu'ADR-0030 designe comme le seul — et les deux
2118     ne posaient pas la meme portee selon l'hote, donc ils pouvaient se
2119     masquer l'un l'autre.
2120
2121     La pilule partagee rend des liens `?lang=xx` ; c'est
2122     `GatekaMiddleware._persist_lang` qui valide et ecrit, cote serveur.
2123     Son comportement d'ouverture/fermeture (clic dehors, Echap, rouleau
2124     du code) vient de `gk-nav.js`, que `_lang_switch.html` emporte
2125     lui-meme avec sa propre garde de double-liaison. */
2126
2127  /* "Vue rôle" switcher handler removed 2026-05-21 with its buttons
2128     (operator request). The /api/auth/role-impersonate endpoint stays in
2129     gateka-core for the impersonate bar + any other product surface. */
2130
2131  /* Hortitec-style unified search modal. One backend round-trip
2132     (/api/lorvert/search?q=X) returns products + brands + categories;
2133     clicking any result hops to /catalog filtered to the chosen value
2134     (catalog.html reads searchField/searchTerm at mount). On the
2135     catalog page we use window.headerSearch (same Vue method as
2136     before) to avoid a full reload. */
2137  document.addEventListener('DOMContentLoaded', function () {
2138    var trigger = document.getElementById('gk_search_trigger');
2139    var modal   = document.getElementById('gk_search_modal');
2140    var input   = document.getElementById('gk_search_input');
2141    var body    = document.getElementById('gk_search_body');
2142    /* DOM-stable results zone — the quick-action shortcuts (Nouveautés /
2143       Promotions) live as a sibling above it, so render() can rebuild
2144       the results without clobbering the shortcuts on empty-state /
2145       clear-input. Falls back to body for legacy/test harness paths. */
2146    var results = document.getElementById('gk_search_results') || body;
2147    var closeBtn = document.getElementById('gk_search_close');
2148    var clearBtn = document.getElementById('gk_search_clear');
2149    if (!trigger || !modal || !input || !body) return;
2150
2151    /* Rotating typewriter placeholder — cycles through the messages
2152       declared on data-messages (JSON array) with per-char reveal +
2153       per-char erase. ~10s per message: 40ms/char type + 7s hold +
2154       25ms/char erase + 300ms gap. Cleared automatically when the
2155       modal opens (input.focus → no need for a competing animation
2156       on the trigger). */
2157    (function rotateTypewriter() {
2158      var el = document.getElementById('gk_search_trigger_text');
2159      if (!el) return;
2160      var messages = [];
2161      try { messages = JSON.parse(el.getAttribute('data-messages') || '[]'); }
2162      catch (e) { messages = []; }
2163      if (!messages.length) return;
2164      if (messages.length === 1) { el.textContent = messages[0]; return; }
2165
2166      var idx = 0, charIdx = messages[0].length;
2167      el.textContent = messages[0];     // server already painted msg 0
2168      var phase = 'hold';                // start by holding the rendered msg
2169      function tick() {
2170        // Pause the animation while the modal is open (we don't want
2171        // to fight the input focus / autocomplete UX).
2172        if (modal && modal.classList.contains('is-open')) {
2173          return setTimeout(tick, 800);
2174        }
2175        var msg = messages[idx];
2176        if (phase === 'type') {
2177          charIdx++;
2178          el.textContent = msg.slice(0, charIdx);
2179          if (charIdx >= msg.length) { phase = 'hold'; return setTimeout(tick, 7000); }
2180          return setTimeout(tick, 40);
2181        }
2182        if (phase === 'hold') {
2183          phase = 'erase';
2184          return setTimeout(tick, 25);
2185        }
2186        // erase
2187        charIdx--;
2188        el.textContent = msg.slice(0, Math.max(0, charIdx));
2189        if (charIdx <= 0) {
2190          idx = (idx + 1) % messages.length;
2191          charIdx = 0;
2192          phase = 'type';
2193          return setTimeout(tick, 300);
2194        }
2195        return setTimeout(tick, 25);
2196      }
2197      // Kick off after the initial 7s hold on the first message.
2198      setTimeout(tick, 7000);
2199    })();
2200
2201    var scope = window.LORVERT_SCOPE || (location.host.indexOf('poppies') >= 0 ? 'poppies' : 'lorvert');
2202    var onCatalog = /\/catalog\b/.test(location.pathname);
2203    var debounce = null;
2204    var lastQ = '';
2205    var RECENT_KEY = 'gk_search_recent_' + scope;
2206    var RECENT_MAX = 10;
2207    var recentNode = document.getElementById('gk_search_recent');
2208    var recentChips = document.getElementById('gk_search_recent_chips');
2209    var recentClear = document.getElementById('gk_search_recent_clear');
2210
2211    function recentRead() {
2212      try {
2213        var raw = localStorage.getItem(RECENT_KEY);
2214        var arr = raw ? JSON.parse(raw) : [];
2215        return Array.isArray(arr) ? arr.filter(function (s) { return typeof s === 'string' && s.length; }) : [];
2216      } catch (e) { return []; }
2217    }
2218    function recentWrite(arr) {
2219      try { localStorage.setItem(RECENT_KEY, JSON.stringify(arr.slice(0, RECENT_MAX))); } catch (e) {}
2220    }
2221    function recentPush(q) {
2222      q = String(q || '').trim();
2223      if (!q) return;
2224      var arr = recentRead();
2225      // Dedupe (case-insensitive) — move existing to front.
2226      var lc = q.toLowerCase();
2227      arr = arr.filter(function (s) { return s.toLowerCase() !== lc; });
2228      arr.unshift(q);
2229      recentWrite(arr);
2230    }
2231    function renderRecent() {
2232      if (!recentNode || !recentChips) return;
2233      var arr = recentRead();
2234      if (!arr.length) { recentNode.hidden = true; return; }
2235      var html = '';
2236      arr.forEach(function (s) {
2237        html += '<button class="gk-search-chip" data-kind="recent" data-val="'
2238              + escapeHtml(s) + '">' + escapeHtml(s) + '</button>';
2239      });
2240      recentChips.innerHTML = html;
2241      recentNode.hidden = false;
2242    }
2243    function recentHide() { if (recentNode) recentNode.hidden = true; }
2244
2245    /* Placeholder reveal — progressively types out the input's own
2246       placeholder ("Produit, marque, catégorie, réf…"), echoing the
2247       global search trigger's typewriter but ONE-SHOT: it runs once each
2248       time the modal OPENS (operator rule 2026-05-21), never rotates or
2249       erases. Only animates on an empty input (otherwise the placeholder
2250       is hidden behind the typed value anyway). _phFull is captured once
2251       from the server-rendered attribute so re-opens replay from full. */
2252    var _phFull = input ? (input.getAttribute('placeholder') || '') : '';
2253    var _phTimer = null;
2254    function revealPlaceholder() {
2255      if (!input || !_phFull) return;
2256      clearTimeout(_phTimer);
2257      if (input.value) { input.placeholder = _phFull; return; }
2258      var i = 0;
2259      input.placeholder = '';
2260      (function tick() {
2261        i++;
2262        input.placeholder = _phFull.slice(0, i);
2263        if (i < _phFull.length) _phTimer = setTimeout(tick, 45);
2264      })();
2265    }
2266    function open() {
2267      modal.classList.add('is-open');
2268      document.body.style.overflow = 'hidden';
2269      renderRecent();
2270      seedFooter();
2271      revealPlaceholder();
2272      setTimeout(function () { input.focus(); input.select(); }, 0);
2273    }
2274    function close() {
2275      modal.classList.remove('is-open');
2276      document.body.style.overflow = '';
2277    }
2278    function escapeHtml(s) {
2279      return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
2280        return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c];
2281      });
2282    }
2283    function highlight(text, q) {
2284      var safe = escapeHtml(text);
2285      if (!q) return safe;
2286      var qq = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2287      return safe.replace(new RegExp('(' + qq + ')', 'ig'),
2288                          '<mark class="gk-search-hl">$1</mark>');
2289    }
2290    // 2026-07-03 — prefer alias over raw imported name in the search dropdown.
2291    function displayName(p) {
2292      var a = p && p.alias;
2293      var n = (!a || String(a).toLowerCase() === 'null') ? ((p && p.name) || '') : a;
2294      try { return decodeURIComponent(escape(String(n || ''))); } catch (_) { return String(n || ''); }
2295    }
2296    function fmtPrice(p) {
2297      if (p == null || isNaN(+p)) return '';
2298      return (+p).toFixed(2).replace('.', ',') + ' €';
2299    }
2300    function imgSrc(p) {
2301      if (p.image_url_local) return p.image_url_local;
2302      if (p.image_url_source) return p.image_url_source;
2303      return '';
2304    }
2305    function commit(field, value, exact) {
2306      // exact defaults to true — a brand/category/reference chip click is a
2307      // precise navigation. Free-text Enter passes exact=false so it runs the
2308      // same partial "show all" query as the "Montrer tout" link (an exact
2309      // match on a partial term like "CAN" returns nothing — the reported bug).
2310      if (exact === undefined) exact = true;
2311      // Free-text keyword searches feed the Dernières recherches history.
2312      // Brand / category / reference clicks are navigation primitives.
2313      if ((field === 'name' || field === 'search') && value) recentPush(value);
2314      if (onCatalog && typeof window.headerSearch === 'function') {
2315        window.headerSearch(field, value, exact);
2316        close();
2317      } else {
2318        var qs = '?searchField=' + encodeURIComponent(field) +
2319                 '&searchTerm=' + encodeURIComponent(value) + (exact ? '&exact=1' : '');
2320        location.href = '/catalog' + qs;
2321      }
2322    }
2323
2324    var footer = document.getElementById('gk_search_footer');
2325
2326    function renderProducts(products, q) {
2327      if (!products.length) {
2328        results.innerHTML = '<div class="gk-search-empty">'
2329          + (q ? 'Aucun résultat pour « ' + escapeHtml(q) + ' ».'
2330               : 'Commencez à taper pour rechercher.')
2331          + '</div>';
2332        return;
2333      }
2334      // B2B price visibility — same skill as the catalog (view-b2b-prices).
2335      // Public / non-B2B viewers don't see prices in search either.
2336      var _grid = (window.LORVERT_SKILLS && window.LORVERT_SKILLS.catalog) || null;
2337      var showPrice = !(_grid && _grid['view-b2b-prices'] === false);
2338      // Seed-family colour code — same palette/logic as the catalog + cart + orders.
2339      function _seedColor(p) {
2340        var s = ((p.category || '') + ' ' + (p.subcategory || '')).toUpperCase();
2341        if (/CB[DG]/.test(s))  return '#1E88E5';
2342        if (/FAST/.test(s))    return '#E91E63';
2343        if (/AUTO/.test(s))    return '#F39C12';
2344        if (/FEMINIZ/.test(s)) return '#E6007E';
2345        if (/REGULAR/.test(s)) return '#3AAA35';
2346        return '';
2347      }
2348      function _seedFamily(p) {
2349        var s = ((p.category || '') + ' ' + (p.subcategory || '')).toUpperCase();
2350        if (/CB[DG]/.test(s)) { if (/AUTO/.test(s)) return 'CBD, Autoflowering'; if (/REGULAR/.test(s)) return 'CBD, Regular'; return 'CBD, Feminized'; }
2351        if (/FAST/.test(s))    return 'Fast Version, Feminized';
2352        if (/AUTO/.test(s))    return 'Autofloraison';
2353        if (/FEMINIZ/.test(s)) return 'Féminisée';
2354        if (/REGULAR/.test(s)) return 'Régulière';
2355        return '';
2356      }
2357      var html = '<div class="gk-search-section">'
2358             +    '<div class="gk-search-section-head">'
2359             +      '<span>Produits</span>'
2360             +      '<a href="/catalog?searchField=search&searchTerm=' + encodeURIComponent(q) + '">Montrer tout &rsaquo;</a>'
2361             +    '</div>';
2362      products.forEach(function (p) {
2363        var src = imgSrc(p);
2364        var fam = _seedFamily(p), famCol = _seedColor(p);
2365        var famHtml = fam ? '<div class="gk-sp-fam"><span class="gk-sp-dot" style="background:' + famCol + ';"></span>' + escapeHtml(fam) + '</div>' : '';
2366        html += '<a class="gk-search-product" data-ref="' + escapeHtml(p.reference) + '">'
2367             +    '<div class="gk-sp-img">'
2368             +      (src ? '<img src="' + escapeHtml(src) + '" alt="" loading="lazy">'
2369                          : '<i class="fa-solid fa-image"></i>')
2370             +    '</div>'
2371             +    '<div class="gk-sp-body">'
2372             +      '<div class="gk-sp-brand">' + escapeHtml(p.brand || '') + '</div>'
2373             +      '<div class="gk-sp-name">' + highlight(displayName(p), q) + '</div>'
2374             +      '<div class="gk-sp-ref">' + T_REF + ' ' + escapeHtml(p.reference || '') + '</div>'
2375             +      famHtml
2376             +    '</div>'
2377             +    (showPrice
2378                   ? (function () {
2379                       // 2026-06-30 — match the catalog's B2C-mode behavior:
2380                       // when body.lorvert-b2c-mode is on, render p.public_price
2381                       // (DB: p.price_b2c_ttc) instead of the B2B p.price.
2382                       // Falls through to B2B when public_price is null/0 so a
2383                       // product missing a B2C price still shows something.
2384                       var b2c = document.body && document.body.classList.contains('lorvert-b2c-mode');
2385                       var b2cVal  = (b2c && p.public_price) ? p.public_price : null;
2386                       var display = b2cVal || p.promo_price || p.price;
2387                       var lbl = b2cVal ? 'Prix B2C' : (p.promo_price ? 'Promo' : 'Prix');
2388                       return '<div class="gk-sp-price">'
2389                            +   '<div class="gk-sp-price-lbl">' + lbl + '</div>'
2390                            +   '<div class="gk-sp-price-val">' + fmtPrice(display) + '</div>'
2391                            + '</div>';
2392                     }())
2393                   : '')
2394             + '</a>';
2395      });
2396      html += '</div>';
2397      results.innerHTML = html;
2398    }
2399
2400    function renderFooter(brands, categories, q) {
2401      if (!footer) return;
2402      var html = '';
2403      if (brands.length) {
2404        html += '<div class="gk-search-section"><div class="gk-search-section-head"><span>Marques</span></div>'
2405             +  '<div class="gk-search-chips">';
2406        brands.forEach(function (b) {
2407          // API returns either a plain string (legacy) or { name, logo_url }.
2408          var name = (b && typeof b === 'object') ? (b.name || '') : b;
2409          var logo = (b && typeof b === 'object') ? b.logo_url : null;
2410          var logoHtml = logo
2411            ? '<img src="' + escapeHtml('/' + String(logo).replace(/^\/+/,'')) + '" alt="" loading="lazy" class="gk-search-chip-logo">'
2412            : '';
2413          html += '<button class="gk-search-chip gk-search-chip--brand" data-kind="brand" data-val="' + escapeHtml(name) + '">'
2414               + logoHtml + '<span>' + highlight(name, q) + '</span></button>';
2415        });
2416        html += '</div></div>';
2417      }
2418      if (categories.length) {
2419        html += '<div class="gk-search-section"><div class="gk-search-section-head"><span>' + T_CATEGORIES + '</span></div>'
2420             +  '<div class="gk-search-chips">';
2421        categories.forEach(function (c) {
2422          html += '<button class="gk-search-chip" data-kind="category" data-val="' + escapeHtml(c) + '">'
2423               + highlight(c, q) + '</button>';
2424        });
2425        html += '</div></div>';
2426      }
2427      footer.innerHTML = html;
2428      footer.hidden = !html;
2429    }
2430
2431    function render(data, q) {
2432      var products   = (data && data.products)   || [];
2433      var brands     = (data && data.brands)     || [];
2434      var categories = (data && data.categories) || [];
2435      renderProducts(products, q);
2436      renderFooter(brands, categories, q);
2437    }
2438
2439    // Full brands + categories cache. Loaded once per session on the
2440    // first modal open, then re-used by restoreFooter() whenever the
2441    // input is cleared so the customer always lands back on the full
2442    // list (not the last filtered subset).
2443    var allBrands = null;
2444    var allCategories = null;
2445
2446    function seedFooter() {
2447      if (seedFooter._done) return;
2448      seedFooter._done = true;
2449      var url = '/api/lorvert/search?scope=' + encodeURIComponent(scope) + '&q=&stream=0';
2450      fetch(url, { credentials: 'same-origin' })
2451        .then(function (r) { return r.json(); })
2452        .then(function (j) {
2453          var d = (j && j.res) || {};
2454          allBrands = d.brands || [];
2455          allCategories = d.categories || [];
2456          renderFooter(allBrands, allCategories, '');
2457        })
2458        .catch(function () { /* footer stays empty — non-fatal */ });
2459    }
2460    function restoreFooter() {
2461      if (allBrands != null && allCategories != null) {
2462        renderFooter(allBrands, allCategories, '');
2463      }
2464    }
2465
2466    function runSearch(q) {
2467      if (q === lastQ) return;
2468      lastQ = q;
2469      if (!q) {
2470        results.innerHTML = '<div class="gk-search-empty">Commencez à taper pour rechercher.</div>';
2471        return;
2472      }
2473      // 2026-06-30 — feed Top Searches: post the settled query as an
2474      // analytics event so admin_routes top_searches() picks it up.
2475      // Debounced (200ms by caller) so we don't flood per keystroke.
2476      // Uses the short-key payload shape /api/track expects (s/p/event_type
2477      // /target — see gk.js basePayload + public_routes.track).
2478      // Best-effort: any error swallowed, never blocks the actual search.
2479      try {
2480        var trackBody = JSON.stringify({
2481          s: scope, p: location.pathname,
2482          event_type: 'search', target: q.slice(0, 200),
2483        });
2484        if (navigator.sendBeacon) {
2485          navigator.sendBeacon('/api/track',
2486            new Blob([trackBody], { type: 'application/json' }));
2487        } else {
2488          fetch('/api/track', {
2489            method: 'POST', credentials: 'same-origin',
2490            headers: { 'content-type': 'application/json' },
2491            body: trackBody, keepalive: true,
2492          }).catch(function () {});
2493        }
2494      } catch (_) { /* never block search on tracker error */ }
2495      // ?stream=0 — request the legacy single-JSON envelope. The default
2496      // stream=1 returns NDJSON (one JSON line per section: products,
2497      // brands, categories) which `r.json()` cant parse → caught here as
2498      // "Erreur réseau". Switch to non-streaming until the modal teaches
2499      // itself to read the stream incrementally.
2500      var url = '/api/lorvert/search?scope=' + encodeURIComponent(scope)
2501              + '&q=' + encodeURIComponent(q) + '&stream=0';
2502      fetch(url, { credentials: 'same-origin' })
2503        .then(function (r) { return r.json(); })
2504        .then(function (j) { render((j && j.res) || {}, q); })
2505        .catch(function () {
2506          results.innerHTML = '<div class="gk-search-empty">Erreur réseau.</div>';
2507        });
2508    }
2509
2510    trigger.addEventListener('click', open);
2511    closeBtn && closeBtn.addEventListener('click', close);
2512    modal.addEventListener('click', function (e) {
2513      if (e.target === modal) close();
2514    });
2515    document.addEventListener('keydown', function (e) {
2516      if (e.key === 'Escape' && modal.classList.contains('is-open')) close();
2517    });
2518
2519    input.addEventListener('input', function () {
2520      var v = input.value.trim();
2521      clearBtn.hidden = !v;
2522      clearTimeout(debounce);
2523      if (v.length >= 2) {
2524        recentHide();
2525        debounce = setTimeout(function () { runSearch(v); }, 200);
2526      } else if (!v) {
2527        lastQ = '';
2528        results.innerHTML = '<div class="gk-search-empty">Commencez à taper pour rechercher.</div>';
2529        renderRecent();
2530        restoreFooter();
2531      }
2532    });
2533    input.addEventListener('keydown', function (e) {
2534      if (e.key === 'Enter') {
2535        e.preventDefault();
2536        var v = input.value.trim();
2537        // Partial multi-field search (field=search, exact=false) — same query
2538        // as the "Montrer tout" link, so Enter on a partial term returns the
2539        // full result list instead of an empty exact-match.
2540        if (v) commit('search', v, false);
2541      }
2542    });
2543    clearBtn && clearBtn.addEventListener('click', function () {
2544      input.value = ''; clearBtn.hidden = true; lastQ = '';
2545      results.innerHTML = '<div class="gk-search-empty">Commencez à taper pour rechercher.</div>';
2546      renderRecent();
2547      restoreFooter();
2548      input.focus();
2549    });
2550
2551    // "Effacer" link in the Dernières recherches header — wipe history.
2552    recentClear && recentClear.addEventListener('click', function (e) {
2553      e.preventDefault();
2554      try { localStorage.removeItem(RECENT_KEY); } catch (err) {}
2555      renderRecent();
2556      input.focus();
2557    });
2558
2559    body.addEventListener('click', function (e) {
2560      var prod = e.target.closest('.gk-search-product');
2561      if (prod) { e.preventDefault(); commit('reference', prod.dataset.ref || ''); return; }
2562      var chip = e.target.closest('.gk-search-chip');
2563      if (chip) {
2564        e.preventDefault();
2565        // Recent-history chips: re-populate the input and run the
2566        // search inline (no /catalog redirect) so the customer can
2567        // refine. Brand / category chips commit and navigate.
2568        if (chip.dataset.kind === 'recent') {
2569          var v = chip.dataset.val || '';
2570          input.value = v;
2571          clearBtn.hidden = !v;
2572          recentHide();
2573          lastQ = '';
2574          runSearch(v);
2575          input.focus();
2576          return;
2577        }
2578        commit(chip.dataset.kind, chip.dataset.val || '');
2579      }
2580    });
2581  });
2582})();
2583
2584// Shrink-on-scroll for .topbar (lorvert/poppies bespoke nav, Guillaume
2585// 2026-05-18). Mirrors the canonical .gk-nav scroll listener in
2586// gateka-core/templates/_gk_nav.html — same threshold (30px), same
2587// rAF throttle. Toggles .is-scrolled on the bespoke .topbar element.
2588// CSS for the shrunken state lives in this file's <style> block above.
2589(function () {
2590  var bar = document.querySelector('.topbar');
2591  if (!bar) return;
2592  var pending = false;
2593  function update() {
2594    pending = false;
2595    var y = window.scrollY || document.documentElement.scrollTop || 0;
2596    bar.classList.toggle('is-scrolled', y > 30);
2597  }
2598  window.addEventListener('scroll', function () {
2599    if (pending) return;
2600    pending = true;
2601    requestAnimationFrame(update);
2602  }, { passive: true });
2603  update();
2604})();
2605
2606// ── B2C-mode toggle — Lorvert Wholesale 2026-06-04 ───────────────
2607// Reveals the toggle for users with the catalog.view-b2b-prices skill
2608// (logged-in pros). Persists state in localStorage `gk-b2c-mode`,
2609// applies body class for the topbar tint, and dispatches `gk:b2cmode`
2610// so the catalog Vue app recomputes _unitPrice / _total from
2611// p.public_price (b2c_ttc in wholedb.products) without changing the
2612// cart contents. Idempotent (multiple bootstraps on Vue hot-reload
2613// won't double-fire).
2614(function () {
2615  // Visibility gated by the dedicated catalog skill `view-b2c-prices`
2616  // (declared in pane.skills; default-granted to the `desk` role). The
2617  // skill IS the permission — users without it can't flip the display,
2618  // not just by lacking the button, because the catalog Vue also reads
2619  // the same skill to decide whether to honour b2cMode. Set via the
2620  // Pages et accès matrix.
2621  function revealB2c() {
2622    var btn = document.getElementById('gk-b2c-btn');
2623    if (!btn) return;
2624    var grid = (window.LORVERT_SKILLS && window.LORVERT_SKILLS.catalog) || null;
2625    if (grid && grid['view-b2c-prices']) {
2626      btn.style.display = '';
2627    }
2628  }
2629  // `window.LORVERT_SKILLS` is assigned by the catalog page's own inline
2630  // script, which runs AFTER this shared-nav script. Running the reveal
2631  // now would always read an undefined grid (the bug that hid the button);
2632  // defer to DOMContentLoaded so the grid is populated. Run immediately
2633  // too in case the document is already parsed.
2634  if (document.readyState === 'loading') {
2635    document.addEventListener('DOMContentLoaded', revealB2c);
2636  } else {
2637    revealB2c();
2638  }
2639  var stored = '0';
2640  try { stored = localStorage.getItem('gk-b2c-mode') || '0'; } catch (e) {}
2641  if (stored === '1') document.body.classList.add('lorvert-b2c-mode');
2642})();
2643
2644window.toggleB2cMode = function () {
2645  var on = !document.body.classList.contains('lorvert-b2c-mode');
2646  document.body.classList.toggle('lorvert-b2c-mode', on);
2647  try { localStorage.setItem('gk-b2c-mode', on ? '1' : '0'); } catch (e) {}
2648  window.dispatchEvent(new CustomEvent('gk:b2cmode', { detail: { active: on } }));
2649};
2650</script>
2650
2651
2652  
2653
2654  
2655  
2655<script>
2656    function toggleLang() {
2657      var cur = (document.cookie.match(/(?:^|;\s*)lang=([^;]+)/) || [])[1] || 'fr';
2658      var nxt = cur === 'fr' ? 'en' : 'fr';
2659      document.cookie = 'lang=' + nxt + ';path=/;max-age=31536000;SameSite=Lax';
2660      try { localStorage.setItem('gk-lang', nxt); } catch (e) {}
2661      // Optimistic label flip + <html lang> update so the click feels
2662      // responsive in the ~150 ms before the reload completes.
2663      var l = document.getElementById('gk-lang-label');
2664      if (l) l.textContent = (nxt === 'fr' ? 'EN' : 'FR');
2665      document.documentElement.setAttribute('lang', nxt);
2666      location.reload();
2667    }
2668    // ── Sélecteur de langue ───────────────────────────────────────────
2669    // Le clic dehors, l'Échap et la moitié « sortie » de l'animation
2670    // vivaient ICI. Ils sont partis dans `gateka/static/gk-nav.js`, que
2671    // `_lang_switch.html` charge lui-même — comme il charge déjà sa
2672    // feuille, et pour la même raison.
2673    //
2674    // Mesuré le 2026-08-15 en navigateur, page authentifiée : les trois
2675    // étaient morts sur `beta.artsiderz.fr`, qui inclut le partiel sans
2676    // hériter de ce gabarit. Un comportement déclaré dans la base ne
2677    // couvre que les héritiers de la base ; le partiel, lui, couvre ses
2678    // inclusions. Consultation …-0e645b.
2679
2680    function toggleTheme() {
2681      var c = document.documentElement.getAttribute('data-theme') || 'light';
2682      var n = c === 'light' ? 'dark' : 'light';
2683      document.documentElement.setAttribute('data-theme', n);
2684      var i = document.getElementById('gk-theme-icon');
2685      if (i) i.className = (n === 'dark' ? 'fa-solid fa-sun' : 'fa-solid fa-moon');
2686      try { localStorage.setItem('gk-theme', n); } catch (e) {}
2687    }
2688    // Pre-paint sync — cookie is the source of truth for lang. Mirror
2689    // into localStorage + DOM attrs so the label paints correctly on
2690    // first frame (no flash) without the JS having to wait for a
2691    // postMessage round-trip.
2692    (function () {
2693      try {
2694        var t = localStorage.getItem('gk-theme');
2695        var i = document.getElementById('gk-theme-icon');
2696        if (i && t === 'dark') i.className = 'fa-solid fa-sun';
2697      } catch (e) {}
2698      try {
2699        var cookieLang = (document.cookie.match(/(?:^|;\s*)lang=([^;]+)/) || [])[1];
2700        var lang = cookieLang || localStorage.getItem('gk-lang') || 'fr';
2701        if (cookieLang) localStorage.setItem('gk-lang', cookieLang);
2702        document.documentElement.setAttribute('lang', lang);
2703        var lbl = document.getElementById('gk-lang-label');
2704        if (lbl) lbl.textContent = (lang === 'fr' ? 'EN' : 'FR');
2705      } catch (e) {}
2706    })();
2707  </script>
2707
2708
2709  
2710  
2711  
2712  <div style="padding-top: var(--gk-nav-spacer-h, var(--nav-h, 68px));" aria-hidden="true"></div>
2713  
2714  
2715
2716  
2717<article class="gk-legal">
2718  
2719  
2720  <nav class="gk-legal-nav">
2721    <a href="/mentions-legales" class="">Mentions légales</a>
2722    <a href="/cgu" class="">CGU</a>
2723    <a href="/confidentialite" class="is-active">Confidentialité</a>
2724  </nav>
2725
2726  
2727
2728  
2729  
2730
2731  
2732  
2733
2734  
2735  
2736  <h1>Politique de confidentialité</h1>
2737  <p class="gk-legal-intro">
2738    La présente politique explique les traitements de données à caractère
2739    personnel effectués par L&#39;Or Vert sur le
2740    site <strong>https://lorvert-paris.com</strong>, dans le respect du Règlement
2741    Général sur la Protection des Données (RGPD, UE 2016/679) et de la loi
2742    Informatique et Libertés.
2743  </p>
2744
2745  <h2>Responsable du traitement</h2>
2746  <p>
2747    L&#39;Or Vert, 17 Rue Berthollet 75005 Paris, France.
2748    Contact&nbsp;: <a href="mailto:[email protected]">[email protected]</a>.
2749  </p>
2750
2751  <h2>Données collectées</h2>
2752  <ul>
2753    <li><b>Compte utilisateur&nbsp;:</b> nom, prénom, adresse courriel, mot de passe (haché), langue, avatar.</li>
2754    <li><b>Navigation&nbsp;:</b> pages consultées, horodatage, type d'appareil,
2755      pays estimé (via l'adresse IP tronquée), référent. Les adresses IP ne
2756      sont jamais conservées en clair.</li>
2757    <li><b>Formulaires&nbsp;:</b> toute information volontairement communiquée
2758      via un formulaire de contact ou de devis.</li>
2759    <li><b>Fichiers téléversés&nbsp;:</b> lorsqu'ils sont associés à une tâche
2760      ou une fonctionnalité spécifique, ils restent accessibles aux seuls
2761      membres du produit concerné.</li>
2762  </ul>
2763
2764  <h2>Finalités et bases légales</h2>
2765  <table class="gk-legal-table">
2766    <thead><tr><th>Finalité</th><th>Base légale</th><th>Durée de conservation</th></tr></thead>
2767    <tbody>
2768      <tr><td>Gestion du compte et authentification</td><td>Exécution du contrat</td><td>Durée du compte + 1 an</td></tr>
2769      <tr><td>Communication avec les utilisateurs</td><td>Intérêt légitime / consentement</td><td>3 ans après dernier échange</td></tr>
2770      <tr><td>Mesure d'audience anonymisée</td><td>Intérêt légitime (sans cookie traceur)</td><td>13 mois</td></tr>
2771      <tr><td>Sécurité du site, détection de fraude</td><td>Intérêt légitime</td><td>1 an</td></tr>
2772      <tr><td>Obligations comptables et légales</td><td>Obligation légale</td><td>10 ans</td></tr>
2773    </tbody>
2774  </table>
2775
2776  <h2>Destinataires</h2>
2777  <p>
2778    Vos données sont destinées aux équipes habilitées de
2779    L&#39;Or Vert. Elles ne sont ni vendues ni
2780    cédées à des tiers à des fins commerciales. Elles peuvent être traitées
2781    par les sous-traitants techniques suivants, encadrés par contrat et
2782    soumis au RGPD&nbsp;:
2783  </p>
2784  <ul>
2785    <li><b>Hébergement&nbsp;:</b> OVHcloud (France).</li>
2786    <li><b>Base de données managée&nbsp;:</b> OVHcloud (Union européenne).</li>
2787    <li><b>Sauvegardes chiffrées&nbsp;:</b> OVHcloud Object Storage (région GRA, France) — données chiffrées avant envoi (age).</li>
2788    
2789  </ul>
2790
2791  <h2>Transferts hors Union européenne</h2>
2792  <p>
2793    Lorsque des données transitent par un prestataire situé hors UE (par
2794    exemple pour les API d'IA génératives), le transfert s'appuie sur les
2795    Clauses Contractuelles Types de la Commission européenne et sur les
2796    engagements de conformité RGPD du sous-traitant. Les données
2797    d'identification restent en UE.
2798  </p>
2799
2800  <h2>Vos droits</h2>
2801  <p>Conformément au RGPD et à la loi Informatique et Libertés, vous disposez des droits suivants&nbsp;:</p>
2802  <ul>
2803    <li>droit d'accès, de rectification et d'effacement&nbsp;;</li>
2804    <li>droit à la limitation et à la portabilité des données&nbsp;;</li>
2805    <li>droit d'opposition au traitement fondé sur l'intérêt légitime&nbsp;;</li>
2806    <li>droit de définir des directives post-mortem&nbsp;;</li>
2807    <li>droit d'introduire une réclamation auprès de la CNIL
2808      (<a href="https://www.cnil.fr/fr/plaintes" rel="noopener">cnil.fr/plaintes</a>).</li>
2809  </ul>
2810  <p>
2811    Pour exercer vos droits, contactez
2812    <a href="mailto:[email protected]">[email protected]</a>.
2813    Une réponse vous sera apportée dans un délai d'un mois.
2814  </p>
2815
2816  <h2>Cookies</h2>
2817  <p>
2818    Le site utilise exclusivement des cookies strictement nécessaires à son
2819    fonctionnement (session d'authentification, préférence de thème, préférence
2820    de langue). La mesure d'audience est effectuée sans cookie traceur, à partir
2821    d'un identifiant de session anonyme recalculé à chaque visite — aucun
2822    consentement préalable n'est donc requis. Les préférences éventuelles
2823    utilisateur peuvent être révoquées à tout moment en vidant le stockage du
2824    navigateur.
2825  </p>
2826
2827  <h2>Sécurité</h2>
2828  <p>
2829    Les mots de passe sont hachés (bcrypt). Les communications sont chiffrées
2830    en TLS. Les sauvegardes quotidiennes sont chiffrées avec une clé publique
2831    (age) dont la clé privée est conservée hors du serveur de
2832    production. Les accès aux données sont restreints par rôle et par produit.
2833  </p>
2834
2835  <h2>Modifications</h2>
2836  <p>
2837    La présente politique peut être mise à jour pour refléter les évolutions
2838    légales ou techniques. La date de dernière mise à jour est indiquée en pied
2839    de page. Toute modification substantielle fera l'objet d'une notification
2840    aux utilisateurs connectés.
2841  </p>
2842  
2843
2844  <p class="gk-legal-updated">
2845    Dernière mise à jour&nbsp;: 2026-02-27.
2846  </p>
2847</article>
2848
2849<style>
2850.gk-legal{max-width:820px;margin:40px auto 60px;padding:0 24px;line-height:1.65;color:var(--text,#1f2937)}
2851.gk-legal h1{font-size:clamp(26px,4vw,34px);margin:0 0 20px;font-weight:800;letter-spacing:-.5px}
2852.gk-legal h2{font-size:18px;margin:30px 0 10px;font-weight:700;color:var(--text,#1f2937)}
2853.gk-legal p{margin:10px 0}
2854.gk-legal-intro{color:var(--text2,#374151);font-size:15px}
2855.gk-legal-list{list-style:none;padding:0;margin:10px 0}
2856.gk-legal-list li{padding:4px 0;border-bottom:1px solid var(--border,rgba(128,128,128,.12))}
2857.gk-legal-list li b{color:var(--text3,#6b7280);font-weight:600;margin-right:6px}
2858.gk-legal-table{width:100%;border-collapse:collapse;font-size:13px;margin:10px 0}
2859.gk-legal-table th,.gk-legal-table td{padding:8px 10px;border-bottom:1px solid var(--border,rgba(128,128,128,.12));text-align:left;vertical-align:top}
2860.gk-legal-table th{font-size:11px;text-transform:uppercase;letter-spacing:.6px;color:var(--text3,#6b7280)}
2861.gk-legal-nav{display:flex;flex-wrap:wrap;gap:14px;margin-bottom:24px;padding-bottom:14px;border-bottom:1px solid var(--border,rgba(128,128,128,.18))}
2862.gk-legal-nav a{color:var(--text3,#6b7280);font-size:13px;text-decoration:none;font-weight:600}
2863.gk-legal-nav a:hover{color:var(--accent,#3b82f6)}
2864.gk-legal-nav a.is-active{color:var(--accent,#3b82f6)}
2865.gk-legal-warn{padding:10px 14px;margin-bottom:18px;background:rgba(180,83,9,.1);color:#b45309;border-left:3px solid #b45309;font-size:13px;border-radius:4px}
2866.gk-legal-updated{margin-top:30px;font-size:12px;color:var(--text3,#6b7280);font-style:italic}
2867.gk-legal a{color:var(--accent,#3b82f6)}
2868.gk-legal a:hover{text-decoration:underline}
2869/* Dark theme: --text isn't defined dark-aware on these shared pages, so the
2870   var() fallbacks (#1f2937…) render dark-on-dark. Force readable light text. */
2871html[data-theme="dark"] .gk-legal{color:#e5e7eb}
2872html[data-theme="dark"] .gk-legal h1,html[data-theme="dark"] .gk-legal h2{color:#f3f4f6}
2873html[data-theme="dark"] .gk-legal-intro{color:#cbd5e1}
2874html[data-theme="dark"] .gk-legal-list li b,
2875html[data-theme="dark"] .gk-legal-table th,
2876html[data-theme="dark"] .gk-legal-nav a,
2877html[data-theme="dark"] .gk-legal-updated{color:#9ca3af}
2878</style>
2879
2880
2881  
2882  
2883<footer class="gk-legal-footer">
2884  <div class="gk-legal-footer-inner">
2885    
2886    <span class="gk-legal-footer-copy">
2887      &copy; 2026 Lorvert-Poppies
2888      · <a href="https://gateka.fr" class="gk-legal-footer-sig" target="_blank" rel="noopener">gateka.fr</a>
2889    </span>
2890    
2891    <nav class="gk-legal-footer-links" aria-label="Pages légales">
2892      <a href="/mentions-legales">Mentions légales</a>
2893      <a href="/cgu">CGU</a>
2894      <a href="/confidentialite">Confidentialité</a>
2895      
2896      <a class="gk-legal-footer-mail" href="mailto:[email protected]">[email protected]</a>
2897    </nav>
2898  </div>
2899</footer>
2900<style>
2901.gk-legal-footer{background:var(--bg);border-top:1px solid var(--border,rgba(128,128,128,.15));padding:18px 24px;margin-top:40px;font-size:12px;color:var(--text3,#6b7280)}
2902.gk-legal-footer-inner{max-width:var(--max-w,1200px);margin:0 auto;display:flex;flex-wrap:wrap;gap:14px;align-items:center;justify-content:space-between}
2903.gk-legal-footer-links{display:flex;gap:14px;flex-wrap:wrap}
2904.gk-legal-footer-links a{color:var(--text3,#6b7280);text-decoration:none}
2905.gk-legal-footer-links a:hover{color:var(--accent,#3b82f6);text-decoration:underline}
2906/* Pas de règle de couleur pour `.gk-legal-footer-mail` : l'adresse est un
2907   lien de pied de page comme les trois autres et se peint avec eux. Une
2908   première version en déclarait une — morte, `.gk-legal-footer-links a`
2909   la bat d'un point de spécificité, et une règle qui annonce une intention
2910   qu'elle n'obtient pas est pire que pas de règle. La classe reste comme
2911   prise pour un produit qui voudra la distinguer. */
2912.gk-legal-footer-sig{color:var(--accent,#3b82f6);text-decoration:none;font-weight:600}
2913.gk-legal-footer-sig:hover{text-decoration:underline}
2914.gk-legal-footer-version{opacity:.65;font-variant-numeric:tabular-nums;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px}
2915</style>
2916
2917  
2918  
2919
2920
2921
2922
2923
2924
2925
2926
2927<div id="signin" class="gk-sc-modal" role="dialog" aria-modal="true" aria-labelledby="gk-sc-title" data-entrance="fade">
2928  <a href="#" class="gk-sc-backdrop" aria-label="Fermer" tabindex="-1"></a>
2929  <div class="gk-sc-panel">
2930    <a href="#" class="gk-sc-close" aria-label="Fermer">&times;</a>
2931    
2932    <h3 id="gk-sc-title" class="gk-sc-title"><i class="fa-solid fa-right-to-bracket"></i> Se connecter</h3>
2933
2934    <form id="gk-sc-form" method="post" action="#" autocomplete="on" onsubmit="return gkScSubmit(event);">
2935      
2936
2937      
2938      <div id="gk-sc-google-wrap" class="gk-sc-google-wrap" hidden>
2939        <div id="gk-sc-google-btn-light" class="gk-sc-google-btn gk-sc-google-btn--light gk-sc-google-btn--iconclip"></div>
2940        <div id="gk-sc-google-btn-dark"  class="gk-sc-google-btn gk-sc-google-btn--dark gk-sc-google-btn--iconclip"></div>
2941        <button type="button" id="gk-sc-microsoft-btn" class="gk-sc-ms-btn" hidden
2942                aria-label="Microsoft" title="Microsoft">
2943          <svg width="18" height="18" viewBox="0 0 23 23" aria-hidden="true">
2944            <path fill="#f35325" d="M1 1h10v10H1z"/><path fill="#81bc06" d="M12 1h10v10H12z"/>
2945            <path fill="#05a6f0" d="M1 12h10v10H1z"/><path fill="#ffba08" d="M12 12h10v10H12z"/>
2946          </svg>
2947        </button>
2948        <div class="gk-sc-divider"><span>ou</span></div>
2949      </div>
2950
2951      
2952      <label for="gk-sc-email" class="gk-sc-label">Email</label>
2953      <input id="gk-sc-email" name="email" type="email" required autocomplete="username"
2954             value="" placeholder="Votre email" class="gk-sc-input">
2955
2956      
2957      <button type="button" id="gk-sc-magic-btn" class="gk-sc-submit gk-sc-submit-magic">
2958        <i class="fa-solid fa-wand-magic-sparkles"></i>
2959        <span>Recevoir mon lien de connexion</span>
2960      </button>
2961      <div id="gk-sc-magic-sent" class="gk-sc-notice gk-sc-ok" hidden>
2962        <i class="fa-solid fa-circle-check"></i> Lien envoyé — vérifiez votre boîte.
2963      </div>
2964      <div id="gk-sc-err" class="gk-sc-notice gk-sc-err" hidden></div>
2965
2966      
2967      <label for="gk-sc-pwd" class="gk-sc-label">Mot de passe</label>
2968      <div class="gk-sc-pwd-inner">
2969        <input id="gk-sc-pwd" name="password" type="password" autocomplete="current-password"
2970               placeholder="Mot de passe" class="gk-sc-input">
2971        <button type="button" class="gk-sc-eye" aria-label="Afficher le mot de passe"
2972                onclick="(function(b){var i=document.getElementById('gk-sc-pwd');if(!i)return;var on=i.type==='password';i.type=on?'text':'password';b.setAttribute('aria-label',on?'Masquer le mot de passe':'Afficher le mot de passe');b.firstElementChild.className='fa-solid '+(on?'fa-eye-slash':'fa-eye');})(this)">
2973          <i class="fa-solid fa-eye"></i>
2974        </button>
2975      </div>
2976
2977      
2978      <div class="gk-sc-login-row">
2979        <button type="submit" class="gk-sc-submit gk-sc-submit-pwd">
2980          <i class="fa-solid fa-arrow-right-to-bracket"></i> Se connecter
2981        </button>
2982        
2983      </div>
2984    </form>
2985
2986    
2987  </div>
2988</div>
2989
2990<style>
2991  /* Shape C — self-contained, CSS-variable-driven per-site theming.
2992     Composer can pass EITHER single palette values (used in both light
2993     + dark, backward-compat) OR dual variants (surface_light/surface_dark,
2994     ink_light/ink_dark, …). Dual variants + html[data-theme=…] +
2995     prefers-color-scheme let the modal adapt at runtime when the site
2996     toggles theme. */
2997  #signin {
2998    /* Universal (theme-agnostic) — brand colours + status. */
2999    --gk-sc-accent:     #f3c84b;
3000    --gk-sc-accent-dim: #e8bc3a;
3001    --gk-sc-error:      #dc2626;
3002    --gk-sc-ok:         #059669;
3003    /* Light-mode palette (default). */
3004    --gk-sc-surface:    #ffffff;
3005    --gk-sc-ink:        #1A2C42;
3006    --gk-sc-muted:      #6b7280;
3007    --gk-sc-backdrop:   rgba(15,20,28,0.7);
3008    --gk-sc-border:     rgba(0,0,0,0.08);
3009  }
3010  /* Dark-mode overrides — fired when the site opts into data-theme="dark"
3011     OR when the visitor's OS prefers dark AND the site hasn't pinned light.
3012     Composers that don't declare dark variants stay on the single value
3013     (rendered above) — byte-invariant fallback. */
3014  /* Single source of truth: the modal's OWN data-theme attribute
3015     (synced from <html>
3015 at load by JS below). Reading the modal's
3016     attr — not <html>'s — survives every site-specific theme scheme:
3017     data-theme on html/body, class-based "dark-mode", prefers-color-
3018     scheme, whatever. If a site flips theme after load, the JS
3019     mutation observer re-syncs and every rule below picks it up. */
3020  #signin[data-theme="dark"] {
3021    --gk-sc-surface:    #ffffff;
3022    --gk-sc-ink:        #1A2C42;
3023    --gk-sc-muted:      #6b7280;
3024    --gk-sc-backdrop:   rgba(15,20,28,0.7);
3025    --gk-sc-border:     rgba(0,0,0,0.08);
3026  }
3027  .gk-sc-modal {
3028    display: none;
3029    position: fixed; inset: 0;
3030    z-index: 1500;
3031    background: var(--gk-sc-backdrop);
3032    backdrop-filter: blur(2px);
3033    align-items: safe center;
3034    justify-content: center;
3035    padding: 24px;
3036    overflow-y: auto;
3037    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
3038  }
3039  .gk-sc-modal:target { display: flex; }
3040  .gk-sc-backdrop {
3041    position: absolute; inset: 0; z-index: 0; cursor: default;
3042  }
3043  .gk-sc-panel {
3044    position: relative; z-index: 1;
3045    width: min(440px, 100%);
3046    background: var(--gk-sc-surface);
3047    color: var(--gk-sc-ink);
3048    border-radius: 14px;
3049    box-shadow: 0 20px 60px rgba(0,0,0,0.4);
3050    padding: 30px 32px 26px;
3051    transform-origin: 50% 30%;
3052    /* §D5: was 0.24s. Measured in the browser on /features, not read
3053       off this file — the sign-in panel renders inside the admin
3054       shell, so its entrance is an administration gesture and §C's
3055       200ms ceiling applies to it. */
3056    animation: gkScFadeIn var(--gk-duration, 180ms) var(--gk-ease-out, ease-out);
3057  }
3058  #signin[data-entrance="none"] .gk-sc-panel { animation: none; }
3059  @keyframes gkScFadeIn {
3060    from { opacity: 0; transform: scale(0.94) translateY(-6px); }
3061    to   { opacity: 1; transform: scale(1)    translateY(0); }
3062  }
3063  .gk-sc-close {
3064    position: absolute; top: 10px; right: 14px;
3065    font-size: 26px; line-height: 1;
3066    color: var(--gk-sc-muted); text-decoration: none; font-weight: 300;
3067  }
3068  .gk-sc-close:hover { color: var(--gk-sc-ink); }
3069  /* Masthead */
3070  .gk-sc-mast {
3071    display: flex; flex-direction: column; align-items: center;
3072    margin: 2px 0 14px 0;
3073  }
3074  .gk-sc-logo {
3075    /* Height imposée, largeur au ratio (Guillaume 2026-07-12 « the
3076       Elen logo is tiny ») : un wordmark 4:1 dans une boîte 64×64
3077       se dessinait en 64×16. Icône carrée : inchangée (64×64).
3078       2026-06-30 (Bogue: "Tu peux en profiter pour grossir le logo") —
3079       bumped 64 → 96 (1.5×) so the site brand carries more visual
3080       weight in the signin surface. max-width proportionally raised. */
3081    height: 96px; width: auto; max-width: 300px; object-fit: contain;
3082    filter: drop-shadow(0 2px 8px rgba(0,0,0,0.08));
3083  }
3084  /* Theme-aware logo toggling — composer can pass logo_light + logo_dark
3085     for surfaces where the mark is a dark shape on a light card vs a
3086     light shape on a dark card. Default (no data-theme, no prefers-
3087     color-scheme match) shows the light-mode variant; dark-mode surfaces
3088     switch to the dark variant. Sites that pass only logo_url render
3089     one <img class="gk-sc-logo"> with neither modifier — the rules
3090     below don't apply. */
3091  .gk-sc-logo--dark  { display: none; }
3092  .gk-sc-logo--light { display: block; }
3093
3094  /* Same single-source-of-truth pattern as palette + Google button. */
3095  #signin[data-theme="dark"] .gk-sc-logo--light  { display: none; }
3096  #signin[data-theme="dark"] .gk-sc-logo--dark   { display: block; }
3097  .gk-sc-wordmark {
3098    font-size: 10px; font-weight: 700;
3099    letter-spacing: 0.42em; text-transform: uppercase;
3100    color: var(--gk-sc-muted);
3101    margin-top: 8px; padding-left: 0.42em;
3102  }
3103  .gk-sc-rule {
3104    position: relative; height: 4px; width: 60%;
3105    margin-top: 10px; overflow: hidden;
3106  }
3107  .gk-sc-rule::before,
3108  .gk-sc-rule::after {
3109    content: ""; position: absolute; left: 0; right: 0; height: 1px;
3110    background: var(--gk-sc-accent); opacity: 0.55;
3111  }
3112  .gk-sc-rule::before { top: 0; }
3113  .gk-sc-rule::after  { bottom: 0; }
3114  /* Title */
3115  .gk-sc-title {
3116    font-size: 18px; font-weight: 700;
3117    color: var(--gk-sc-ink);
3118    margin: 0 0 18px 0;
3119    letter-spacing: 0.04em;
3120  }
3121  /* Form controls */
3122  .gk-sc-label {
3123    display: block; font-size: 12px; font-weight: 600;
3124    color: var(--gk-sc-muted);
3125    margin: 6px 0 4px 0;
3126  }
3127  .gk-sc-input {
3128    width: 100%; padding: 10px 12px;
3129    font-size: 14px;
3130    border: 1px solid var(--gk-sc-border);
3131    border-radius: 8px;
3132    background: var(--gk-sc-surface);
3133    color: var(--gk-sc-ink);
3134    outline: none; box-sizing: border-box;
3135  }
3136  .gk-sc-input:focus {
3137    border-color: var(--gk-sc-accent);
3138    box-shadow: 0 0 0 2px color-mix(in srgb, var(--gk-sc-accent) 20%, transparent);
3139  }
3140  /* Primary submit (password login = the top CTA). */
3141  .gk-sc-submit {
3142    width: 100%; padding: 12px;
3143    font: inherit; font-size: 15px; font-weight: 700;
3144    color: var(--gk-sc-ink);
3145    background: var(--gk-sc-accent);
3146    border: none; border-radius: 8px;
3147    cursor: pointer;
3148    display: flex; align-items: center; justify-content: center; gap: 8px;
3149    margin-top: 12px;
3150    transition: background 0.2s;
3151  }
3152  .gk-sc-submit:hover:not(:disabled) { background: var(--gk-sc-accent-dim); }
3153  .gk-sc-submit:disabled { opacity: 0.6;
3153 cursor: not-allowed; }
3154  /* 2026-06-30 — Login row: primary submit on the left, forgot link on
3155     the right so a user who can't remember their password reaches
3156     recovery without leaving the visible surface. */
3157  .gk-sc-login-row {
3158    display: flex; align-items: center; gap: 12px;
3159    margin-top: 12px;
3160  }
3161  .gk-sc-login-row .gk-sc-submit { margin-top: 0; flex: 1; }
3162  .gk-sc-link-forgot {
3163    font-size: 12px; white-space: nowrap;
3164    color: var(--gk-sc-muted); text-decoration: underline;
3165  }
3166  /* 2026-06-30 (Bogue: "i still want to promote the recevoir mon lien
3167     de connexion rather than se connecter") — primary/secondary
3168     treatments swapped. The base .gk-sc-submit (accent-filled) now
3169     applies to the MAGIC button which sits above the password fields;
3170     the .gk-sc-submit-pwd modifier below downgrades password login to
3171     outlined-secondary alongside the forgot link. */
3172  .gk-sc-submit-pwd {
3173    background: transparent;
3174    color: var(--gk-sc-muted);
3175    border: 1px solid var(--gk-sc-border);
3176    font-weight: 600;
3177  }
3178  .gk-sc-submit-pwd:hover:not(:disabled) {
3179    background: color-mix(in srgb, var(--gk-sc-accent) 8%, transparent);
3180    color: var(--gk-sc-ink);
3181    border-color: var(--gk-sc-accent);
3182  }
3183  /* Notices */
3184  .gk-sc-notice {
3185    font-size: 13px; font-weight: 600;
3186    margin-top: 8px; text-align: center;
3187  }
3188  .gk-sc-ok  { color: var(--gk-sc-ok); }
3189  .gk-sc-err { color: var(--gk-sc-error); }
3190  /* OU divider + Google */
3191  /* Centre by text-align, NOT flex: the wrap also carries the "OU"
3192     separator, and a flex wrap would pull it inline with the buttons
3193     (see the iconclip note below). font-size:0 kills the whitespace gap
3194     between inline-flex children; each child re-declares its own size. */
3195  /* 2026-06-30 (Bogue: "copy the fucking connexion button shape of
3196     lorvert") — Google + MS render as small circle icons SIDE BY
3197     SIDE, matching Lorvert's inline signin. Wrap is centered with
3198     text-align (NOT flex, so the OU divider stays a wrap child
3199     without getting inline-flexed into the row). font-size:0 kills
3200     the whitespace gap between inline-flex children; each child
3201     re-declares its own size. */
3202  .gk-sc-google-wrap { margin-top: 14px; text-align: center; font-size: 0; }
3203  /* Microsoft icon button — neutral white face in both themes,
3204     matching Google's own icon button, which never inverts either.
3205     40px circle to sit level with what GSI emits for
3206     {type:icon,shape:circle,size:large}. */
3207  .gk-sc-ms-btn {
3208    width: 40px; height: 40px; padding: 0; border-radius: 50%;
3209    border: 1px solid #dadce0; background: #fff; cursor: pointer;
3210    display: inline-flex; align-items: center; justify-content: center;
3211    line-height: 0; transition: box-shadow .15s ease;
3212    margin: 0 7px; vertical-align: middle;
3213  }
3214  .gk-sc-ms-btn:hover { box-shadow: 0 1px 6px rgba(0,0,0,.18); }
3215  .gk-sc-ms-btn:disabled { opacity: .55; cursor: default; }
3216  .gk-sc-ms-btn[hidden] { display: none; }
3217  .gk-sc-divider {
3218    text-align: center; color: var(--gk-sc-muted);
3219    font-size: 12px; letter-spacing: 0.05em; text-transform: uppercase;
3220    margin: 4px 0 12px 0; position: relative;
3221  }
3222  .gk-sc-divider span {
3223    background: var(--gk-sc-surface);
3224    padding: 0 12px; position: relative; z-index: 1;
3225  }
3226  .gk-sc-divider::before {
3227    content: ""; position: absolute; left: 0; top: 50%;
3228    height: 1px; width: 100%;
3229    background: color-mix(in srgb, var(--gk-sc-muted) 30%, transparent);
3230    z-index: 0;
3231  }
3232  .gk-sc-google-btn {
3233    display: flex; align-items: center; justify-content: center;
3234    margin-bottom: 14px;
3235  }
3236  /* Show one Google button per active theme — same technique as the
3237     logo variants (never miss a theme flip; zero JS detection). GSI
3238     renders into both hosts; CSS shows the correct one. */
3239  /* color-scheme: light — SANS quoi Chromium peint un canvas BLANC
3240     opaque derrière l'iframe GSI (restée light) dès que la page
3241     utilise le scheme sombre : le « carré blanc » de la capture
3242     Elen 2026-07-12. Workaround documenté côté embarqueur. */
3243  .gk-sc-google-btn { color-scheme: light; }
3244  /* type:icon → clip circulaire du slot : le bouton PERSONNALISÉ de
3245     Google (session Google active) est un chip blanc rectangulaire
3246     non thémable — on le découpe en pastille nette plutôt que de
3247     subir le carré (Guillaume 2026-07-12). */
3248  .gk-sc-google-btn--iconclip {
3249    /* centrée par marges — le wrap contient aussi le séparateur OU,
3250       un flex sur le wrap les alignerait côte à côte. */
3251    margin: 0 7px; vertical-align: middle;
3252    width: 44px; height: 44px; border-radius: 999px; overflow: hidden;
3253    align-items: center; justify-content: center;
3254    border: 1px solid rgba(128,128,128,0.3);
3255    box-sizing: border-box;
3256  }
3257  .gk-sc-google-btn--dark  { display: none; }
3258  .gk-sc-google-btn--light { display: inline-flex; }
3259  /* Read the modal's own data-entrance stamp (set at render) AND the
3260     modal's data-theme (synced from <html> at load by JS below). Using
3261     the modal's own attribute — not <html>'s — lets us survive any
3262     site-specific theme mechanism (data-theme on html/body, class-based,
3263     class="dark-mode", prefers-color-scheme, whatever). Single source
3264     of truth. */
3265  #signin[data-theme="dark"] .gk-sc-google-btn--light { display: none; }
3266  #signin[data-theme="dark"] .gk-sc-google-btn--dark  { display: inline-flex; }
3267  /* .gk-sc-toggle-row retired 2026-06-30 (password toggle removed). */
3268  .gk-sc-link {
3269    color: var(--gk-sc-muted);
3270    text-decoration: underline;
3271    font-size: 13px;
3272    background: none; border: none; padding: 0;
3273    cursor: pointer; font: inherit;
3274  }
3275  .gk-sc-link:hover { color: var(--gk-sc-ink); }
3276  .gk-sc-link-small { font-size: 12px; margin-top: 8px; display: inline-block; }
3277  .gk-sc-link-strong {
3278    color: var(--gk-sc-ink); font-weight: 600;
3279  }
3280  .gk-sc-link-strong:hover { color: var(--gk-sc-accent-dim); }
3281  /* .gk-sc-pwd-wrap retired 2026-06-30 (password field always visible;
3282     no wrapping div needed to reveal it). */
3283  .gk-sc-pwd-inner { position: relative; margin-top: 4px; }
3284  .gk-sc-eye {
3285    position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
3286    background: none; border: none; padding: 4px 8px;
3287    color: var(--gk-sc-muted); cursor: pointer;
3288    display: inline-flex; align-items: center; justify-content: center;
3289  }
3290  .gk-sc-eye:hover { color: var(--gk-sc-ink); background: rgba(0,0,0,0.05); border-radius: 4px; }
3291  /* Signup footer */
3292  .gk-sc-signup {
3293    text-align: center; margin-top: 14px; padding-top: 12px;
3294    border-top: 1px solid var(--gk-sc-border);
3295    font-size: 12px; color: var(--gk-sc-muted);
3296  }
3297  /* Mobile */
3298  @media (max-width: 480px) {
3299    .gk-sc-modal { padding: 12px; }
3300    .gk-sc-panel { padding: 22px 20px 18px; }
3301  }
3302</style>
3303
3304<script src="https://accounts.google.com/gsi/client" async defer></script>
3304
3305
3306<script src="https://cdn.jsdelivr.net/npm/@azure/[email protected]/lib/msal-browser.min.js" async defer></script>
vendor: 1 bytes, line 3306
3306
3307<script>
3308  window.GK_SC_CONFIG = {
3309    lang:            "fr",
3310    magic_endpoint:  "/connect/magic/send",
3311    password_endpoint: "/connect/email",
3312    google_endpoint: "/connect/google",
3313    google_client_id: "728864339187-n1934ts2pd4bq81v8s50vngki4l11jlu.apps.googleusercontent.com",
3314    google_button:   {"shape": "circle", "size": "large", "theme": "outline", "type": "icon"},
3315    google_theme_light: "outline",
3316    google_theme_dark:  "outline",
3317    microsoft_endpoint:  "/connect/microsoft",
3318    microsoft_client_id: "8db11b3f-f470-4f40-b081-9964253d24c1",
3319    microsoft_redirect_uri: "/msal-blank.html",
3320    redirect:        "/confidentialite",
3321    strings:         {"back_to_magic": "Revenir au lien de connexion", "both_required": "Saisissez email et mot de passe.", "close": "Fermer", "email": "Email", "email_ph": "Votre email", "forgot": "Mot de passe oubli\u00e9 ?", "google_fail": "Connexion Google \u00e9chou\u00e9e \u2014 essayez par email.", "hide_pwd": "Masquer le mot de passe", "magic_cta": "Recevoir mon lien de connexion", "magic_invalid": "Veuillez saisir une adresse email valide.", "magic_sent": "Lien envoy\u00e9 \u2014 v\u00e9rifiez votre bo\u00eete.", "network_err": "Erreur r\u00e9seau \u2014 veuillez r\u00e9essayer.", "no_account": "Pas encore de compte ?", "or": "ou", "password": "Mot de passe", "password_ph": "Mot de passe", "show_pwd": "Afficher le mot de passe", "signin_fail": "Connexion \u00e9chou\u00e9e. Merci de r\u00e9essayer.", "signin_password": "Se connecter", "signup": "S\u0027inscrire", "title": "Se connecter", "use_password": "Utiliser mon mot de passe"}
3322  };
3323  // ── Legacy-anchor compat shim (2026-08-16) ──────────────────────
3324  // This node's id was `gk-sc-signin` until the owner made /#signin
3325  // the one way to connect. Inside gateka-core the rename was a
3326  // single-file edit — but the old spelling is ALSO written in three
3327  // OTHER repos that link here and are not deployed in lockstep:
3328  //   oxygen-core  src/shop/templates/gate.html, src/shop/server.py,
3329  //                src/shop/templates/storyteller_foodtruck.html
3330  //   artsiderz.fr src/artsiderz/templates/base.html
3331  //   elen         src/elen/templates/_knowledge_topbar_trigger.html
3332  // Without this shim every one of those links silently stops opening
3333  // the modal — :target simply matches nothing, so there is no error
3334  // to notice. Rewrite the legacy hash to the canonical one on load
3335  // and on hashchange. DELETE this block only once those five call
3336  // sites are migrated and deployed (grep gk-sc-signin across repos).
3337  (function () {
3338    var LEGACY = '#gk-sc-signin', CANON = '#signin';
3339    function alias() {
3340      if (location.hash === LEGACY) {
3341        // replaceState first so the legacy spelling does not linger in
3342        // history — otherwise Back lands on a hash that matches nothing.
3343        try { history.replaceState(null, '', location.pathname + location.search); } catch (e) {}
3344        location.hash = CANON;
3345      }
3346    }
3347    alias();
3348    window.addEventListener('hashchange', alias);
3349  })();
3350  // Single-source-of-truth theme sync: stamp #signin[data-theme=…]
3351  // from the active theme so every CSS toggle (palette, logo variant,
3352  // Google button host) reads the modal's own attr — not <html>'s.
3353  // Reads html[data-theme] → body[data-theme] → prefers-color-scheme.
3354  // Re-syncs on data-theme attribute changes ONLY (not class changes —
3355  // those can incidentally toggle for unrelated reasons, flipping the
3356  // theme). Only writes when the resolved value actually changes so
3357  // we don't keep firing our own observer.
3358  (function () {
3359    function _resolvedTheme() {
3360      var t = document.documentElement.getAttribute('data-theme');
3361      if (t === 'dark' || t === 'light') return t;
3362      if (document.body) {
3363        var b = document.body.getAttribute('data-theme');
3364        if (b === 'dark' || b === 'light') return b;
3365      }
3366      return (window.matchMedia && matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
3367    }
3368    function _sync() {
3369      var m = document.getElementById('signin');
3370      if (!m) return;
3371      var next = _resolvedTheme();
3372      if (m.getAttribute('data-theme') !== next) m.setAttribute('data-theme', next);
3373    }
3374    _sync();
3375    try {
3376      new MutationObserver(_sync).observe(document.documentElement,
3377        {attributes: true, attributeFilter: ['data-theme']});
3378      if (document.body) {
3379        new MutationObserver(_sync).observe(document.body,
3380          {attributes: true, attributeFilter: ['data-theme']});
3381      }
3382      if (window.matchMedia) {
3383        matchMedia('(prefers-color-scheme: dark)').addEventListener('change', _sync);
3384      }
3385    } catch (_) {}
3386    document.addEventListener('DOMContentLoaded', _sync);
3387  })();
3388  window.gkScSubmit = function (ev) {
3389    if (ev && ev.preventDefault) ev.preventDefault();
3390    var C = window.GK_SC_CONFIG || {};
3391    var input = document.getElementById('gk-sc-email');
3392    var pwInput = document.getElementById('gk-sc-pwd');
3393    var errEl = document.getElementById('gk-sc-err');
3394    var email = (input && input.value || '').trim();
3395    var password = (pwInput && pwInput.value || '');
3396    if (!email || !password) {
3397      if (errEl) { errEl.textContent = C.strings.both_required; errEl.hidden = false; }
3398      return false;
3399    }
3400    if (errEl) { errEl.textContent = ''; errEl.hidden = true; }
3401    fetch(C.password_endpoint, {
3402      method: 'POST', credentials: 'same-origin',
3403      headers: {'content-type': 'application/json'},
3404      body: JSON.stringify({email: email, password: password})
3405    })
3406      .then(function (r) { return r.json().then(function (d) { return {ok: r.ok, body: d}; }); })
3407      .then(function (res) {
3408        if (res.ok && res.body && res.body.ok) {
3409          window.location.href = C.redirect;
3410          return;
3411        }
3412        if (errEl) {
3413          var body = res.body || {};
3414          errEl.textContent = body.error || C.strings.signin_fail;
3415          errEl.hidden = false;
3416          if (body.cta_label && body.cta_url) {
3417            var br = document.createElement('br');
3418            var a = document.createElement('a');
3419            a.href = body.cta_url;
3420            a.textContent = body.cta_label;
3421            a.style.cssText = 'display:inline-block;margin-top:6px;color:inherit;text-decoration:underline;font-weight:600';
3422            errEl.appendChild(br);
3423            errEl.appendChild(a);
3424          }
3425        }
3426      })
3427      .catch(function () {
3428        if (errEl) { errEl.textContent = C.strings.network_err; errEl.hidden = false; }
3429      });
3430    return false;
3431  };
3432  (function () {
3433    var C = window.GK_SC_CONFIG || {};
3434    var input   = document.getElementById('gk-sc-email');
3435    var magicBtn = document.getElementById('gk-sc-magic-btn');
3436    var magicSent = document.getElementById('gk-sc-magic-sent');
3437    var errEl   = document.getElementById('gk-sc-err');
3438    // 2026-06-30 — password field is now always visible (no pwWrap /
3439    // pwToggle); keep pwInput for the eye-toggle button + submit handler.
3440    var pwInput = document.getElementById('gk-sc-pwd');
3441    if (!input) return;
3442    // Hijack the shared widget's anon signin triggers so they open THIS
3443    // Shape C modal instead of the (non-existent-on-this-page) shared
3444    // #gk-connect-overlay. gateka-core's _header_auth_menu.html emits
3445    // <button data-gk-connect-open> for anon — that button clicked in
3446    // connect.js would call openModal() which looks up an overlay that
3447    // isn't mounted here. Redirect the click at capture phase so we
3448    // beat connect.js's document-level listener. Only fires when the
3449    // Shape C modal is actually present in the DOM (this script only
3450    // runs when the partial renders).
3451    document.addEventListener('click', function (ev) {
3452      var t = ev.target && ev.target.closest && ev.target.closest('[data-gk-connect-open]');
3453      if (!t) return;
3454      // Skip if a widget overlay is ALSO present — some future page may
3455      // render both; let connect.js handle the widget in that case.
3456      if (document.getElementById('gk-connect-overlay')) return;
3457      ev.preventDefault();
3458      ev.stopPropagation();
3459      if (location.hash === '#signin') {
3460        // Force a re-fire so :target CSS reveals the modal even if the
3461        // hash was already set (e.g. from a prior open+backdrop-close).
3462        location.hash = '';
3463        setTimeout(function () { location.hash = '#signin'; }, 0);
3464      } else {
3465        location.hash = '#signin';
3466      }
3467    }, true);
3468    function looksLikeEmail(v) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v); }
3469    // Magic-link primary submit
3470    if (magicBtn) {
3471      magicBtn.addEventListener('click', function () {
3472        var email = (input.value || '').trim().toLowerCase();
3473        if (!looksLikeEmail(email)) {
3474          if (errEl) { errEl.textContent = C.strings.magic_invalid; errEl.hidden = false; }
3475          input.focus();
3476          return;
3477        }
3478        magicBtn.disabled = true; magicBtn.style.opacity = '0.6';
3479        if (errEl) errEl.hidden = true;
3480        fetch(C.magic_endpoint, {
3481          method: 'POST', credentials: 'same-origin',
3482          headers: {'content-type': 'application/json'},
3483          body: JSON.stringify({email: email})
3484        }).finally(function () {
3485          magicBtn.style.display = 'none';
3486          if (magicSent) magicSent.hidden = false;
3487        });
3488      });
3489    }
3490    // 2026-06-30 (Bogue: "GLOBAL, magic link should still be there") —
3491    // password toggle logic retired. Password field is now visible from
3492    // first paint; magic-link becomes a secondary CTA below Login (still
3493    // wired to gk-sc-magic-btn, unchanged). No JS needed to reveal
3494    // anything — the form is what you see.
3495    // Google Identity Services init — same pattern as v1 Shape C
3496    function _initGis() {
3497      var CID = C.google_client_id;
3498      if (!CID || !window.google || !google.accounts || !google.accounts.id) {
3499        var wrap0 = document.getElementById('gk-sc-google-wrap');
3500        if (wrap0) wrap0.hidden = true;
3501        return;
3502      }
3503      var hostLight = document.getElementById('gk-sc-google-btn-l
3503ight');
3504      var hostDark  = document.getElementById('gk-sc-google-btn-dark');
3505      if (!hostLight || !hostDark) {
3506        var wrap0 = document.getElementById('gk-sc-google-wrap');
3507        if (wrap0) wrap0.hidden = true;
3508        return;
3509      }
3510      try {
3511        google.accounts.id.initialize({
3512          client_id: CID,
3513          callback: function (credential) {
3514            if (!credential || !credential.credential) return;
3515            fetch(C.google_endpoint, {
3516              method: 'POST', credentials: 'same-origin',
3517              headers: {'content-type': 'application/json'},
3518              body: JSON.stringify({id_token: credential.credential})
3519            })
3520              .then(function (r) { return r.json().then(function(d){return {ok:r.ok, body:d};}); })
3521              .then(function (res) {
3522                if (res.ok && res.body && (res.body.ok || res.body.status === 'ok')) {
3523                  window.location.href = C.redirect;
3524                } else if (errEl) {
3525                  var body = res.body || {};
3526                  errEl.textContent = body.error || C.strings.google_fail;
3527                  errEl.hidden = false;
3528                  if (body.cta_label && body.cta_url) {
3529                    var br = document.createElement('br');
3530                    var a = document.createElement('a');
3531                    a.href = body.cta_url; a.textContent = body.cta_label;
3532                    a.style.cssText = 'display:inline-block;margin-top:6px;color:inherit;text-decoration:underline;font-weight:600';
3533                    errEl.appendChild(br); errEl.appendChild(a);
3534                  }
3535                }
3536              })
3537              .catch(function () {
3538                if (errEl) { errEl.textContent = C.strings.network_err; errEl.hidden = false; }
3539              });
3540          }
3541        });
3542        // Zero-detection theme handling: render TWO buttons (light +
3543        // dark themes) into their own hosts; CSS toggles which one is
3544        // visible based on html[data-theme=…] + prefers-color-scheme.
3545        // The invisible host is display:none — takes no layout, no
3546        // click surface. Composer's google_button.theme (if pinned)
3547        // wins on both hosts.
3548        var baseOpts = Object.assign(
3549          {size: 'large', type: 'standard'},
3550          C.google_button || {}
3551        );
3552        var lightOpts = Object.assign({}, baseOpts,
3553          {theme: baseOpts.theme || (C.google_theme_light || 'outline')});
3554        var darkOpts  = Object.assign({}, baseOpts,
3555          {theme: baseOpts.theme || (C.google_theme_dark  || 'filled_black')});
3556        google.accounts.id.renderButton(hostLight, lightOpts);
3557        google.accounts.id.renderButton(hostDark,  darkOpts);
3558        var wrapx = document.getElementById('gk-sc-google-wrap');
3559        if (wrapx) wrapx.hidden = false;
3560      } catch (e) {
3561        var wrap2 = document.getElementById('gk-sc-google-wrap');
3562        if (wrap2) wrap2.hidden = true;
3563      }
3564    }
3565    (function _pollGis() {
3566      var tries = 0;
3567      var iv = setInterval(function () {
3568        tries++;
3569        if (window.google && google.accounts && google.accounts.id) {
3570          clearInterval(iv); _initGis();
3571        } else if (tries > 40) {
3572          clearInterval(iv);
3573          var wrap = document.getElementById('gk-sc-google-wrap');
3574          if (wrap) wrap.hidden = true;
3575        }
3576      }, 100);
3577    })();
3578
3579    // ── Microsoft (Entra ID) ──────────────────────────────────────────
3580    // Popup → id_token → POST /connect/microsoft. redirectUri is a BLANK
3581    // page, never the site root: Entra lands the popup there carrying
3582    // `#code=…`, and a real page competes for that fragment (Lorvert's
3583    // landing drives its modals off the hash) besides being slow to load
3584    // inside a popup — which made sign-in fail silently.
3585    function _initMsal() {
3586      var btn = document.getElementById('gk-sc-microsoft-btn');
3587      if (!btn) return;
3588      var MSID = C.microsoft_client_id;
3589      if (!MSID || !window.msal || !msal.PublicClientApplication) return;
3590      var app;
3591      try {
3592        app = new msal.PublicClientApplication({
3593          auth: {
3594            clientId: MSID,
3595            // /common/ admits work, school AND personal. Which of those
3596            // the app really accepts is the registration's signInAudience,
3597            // decided server-side — not here.
3598            authority: 'https://login.microsoftonline.com/common',
3599            redirectUri: window.location.origin + C.microsoft_redirect_uri,
3600          },
3601          cache: {cacheLocation: 'sessionStorage'}
3602        });
3603      } catch (e) { return; }
3604      btn.hidden = false;
3605      // The wrap is shared with Google and starts hidden. Microsoft alone
3606      // must still reveal it, or a product with only Microsoft configured
3607      // renders an invisible button.
3608      var wrapm = document.getElementById('gk-sc-google-wrap');
3609      if (wrapm) wrapm.hidden = false;
3610      btn.addEventListener('click', function () {
3611        btn.disabled = true;
3612        Promise.resolve(app.initialize ? app.initialize() : null)
3613          .then(function () {
3614            // `email` must be REQUESTED here, not merely granted in the
3615            // portal — the claim is omitted from the token otherwise.
3616            return app.loginPopup({scopes: ['openid', 'profile', 'email'],
3617                                   prompt: 'select_account'});
3618          })
3619          .then(function (res) {
3620            if (!res || !res.idToken) throw new Error('no id_token');
3621            return fetch(C.microsoft_endpoint, {
3622              method: 'POST', credentials: 'same-origin',
3623              headers: {'content-type': 'application/json'},
3624              body: JSON.stringify({id_token: res.idToken})
3625            });
3626          })
3627          .then(function (r) { return r.json().then(function (d) { return {ok: r.ok, body: d}; }); })
3628          .then(function (res) {
3629            if (res.ok && res.body && (res.body.ok || res.body.status === 'ok')) {
3630              window.location.href = C.redirect; return;
3631            }
3632            if (errEl) {
3633              var body = res.body || {};
3634              errEl.textContent = body.error || C.strings.google_fail;
3635              errEl.hidden = false;
3636              // Skipped when the refusal carries an oid — that message
3637              // already tells the visitor what to send an admin, and a
3638              // "create an account" link would send them round a loop
3639              // they cannot finish.
3640              if (body.cta_label && body.cta_url && !body.oid) {
3641                var br = document.createElement('br');
3642                var a = document.createElement('a');
3643                a.href = body.cta_url; a.textContent = body.cta_label;
3644                a.style.cssText = 'display:inline-block;margin-top:6px;color:inherit;text-decoration:underline;font-weight:600';
3645                errEl.appendChild(br); errEl.appendChild(a);
3646              }
3647            }
3648          })
3649          .catch(function (e) {
3650            var code = String((e && (e.errorCode || e.message)) || '');
3651            // Only a deliberate close is silent. Swallowing popup_window*
3652            // too was the bug that hid a blocked popup AND a redirect-URI
3653            // mismatch behind an empty screen on Lorvert.
3654            if (code.indexOf('user_cancelled') !== -1) return;
3655            console.error('[msal]', code, e);
3656            if (errEl) { errEl.textContent = C.strings.network_err; errEl.hidden = false; }
3657          })
3658          .then(function () { btn.disabled = false; });
3659      });
3660    }
3661    (function _pollMsal() {
3662      var tries = 0;
3663      var iv = setInterval(function () {
3664        tries++;
3665        if (window.msal && msal.PublicClientApplication) { clearInterval(iv); _initMsal(); }
3666        else if (tries > 40) { clearInterval(iv); }
3667      }, 100);
3668    })();
3669  })();
3670</script>
3670
3671
3672  
3673  
3674  
3675
3676
3677
3678
3679
3680
3681
3682
3683
3684
3685
3686
3687
3688
3689
3690
3691  
3692  
3693
3694  
3695
3696  
3697  
3697<script defer src="/gateka/static/edit-in-place.js?v=prod-20260926-a"></script>
3697
3698
3699  
3700  
3701
3702<script src="/api/editorial/zone-edit-stub.js?product=Lorvert-paris&amp;v=prod-20260926-a" defer></script>
3702
3703</body>
3704</html>

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.