@zevra/ui 0.50.0 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -66,8 +66,14 @@ export function useOverlay({ ouvert, onClose, panneau, verrouille = true }: UseO
66
66
 
67
67
  // ⚠️ La valeur PRÉCÉDENTE est restaurée, pas remise à '' : une page qui
68
68
  // gérait déjà son propre défilement serait cassée par un reset aveugle.
69
- const overflowPrecedent = verrouille ? docu.body.style.overflow : null
70
- if (verrouille) docu.body.style.overflow = 'hidden'
69
+ // ⚠️ Sur `html`, JAMAIS sur `body` (DECISIONS, 03/11/2026) : overlays.css
70
+ // verrouille déjà `html` ; un `overflow` de plus sur `body` ne remonte
71
+ // alors plus au viewport, `body` devient un conteneur de défilement à
72
+ // zéro, et toute colonne `sticky` de la page saute en haut de `body`,
73
+ // hors de l'écran.
74
+ const racine = docu.documentElement
75
+ const overflowPrecedent = verrouille ? racine.style.overflow : null
76
+ if (verrouille) racine.style.overflow = 'hidden'
71
77
 
72
78
  const premier = cible?.querySelector<HTMLElement>(FOCUSABLES)
73
79
  premier?.focus()
@@ -99,7 +105,7 @@ export function useOverlay({ ouvert, onClose, panneau, verrouille = true }: UseO
99
105
  docu.addEventListener('keydown', onKey)
100
106
  return () => {
101
107
  docu.removeEventListener('keydown', onKey)
102
- if (overflowPrecedent !== null) docu.body.style.overflow = overflowPrecedent
108
+ if (overflowPrecedent !== null) racine.style.overflow = overflowPrecedent
103
109
  origine.current?.focus?.()
104
110
  }
105
111
  }, [ouvert, panneau, verrouille])
@@ -0,0 +1,18 @@
1
+ // GÉNÉRÉ par scripts/build-partenaires-data.mjs : ne pas éditer à la main.
2
+ // Source : logos/partenaires/ (provenance de chaque fichier dans index.json).
3
+
4
+ export type PartenaireId =
5
+ | 'docusign'
6
+ | 'google'
7
+ | 'henrri'
8
+ | 'maileva'
9
+ | 'microsoft'
10
+
11
+ /** Les logos des services partenaires, inlinés, et le nom de la marque. */
12
+ export const LOGOS_PARTENAIRES: Record<PartenaireId, { src: string; nom: string; plein: boolean }> = {
13
+ 'docusign': { src: 'data:image/webp;base64,UklGRkgFAABXRUJQVlA4WAoAAAAQAAAAXwAAXwAAQUxQSA8CAAABoETbtmnbmXvv+227btu2bf9ftq3U0lKybZRSYylF27ad3Hv3WjPO+efPOGktIiYAxUfMetGcZbRPXnnw6n2TmgNIEeUNaPMajSWuvXb9qrZACqWJGFJzL4m75UySb1/QC0jlGfAty/Kz7jmTn13YGTGUZeB35fqpZfLNTUCSRXomb+uIpIu0zOeHIgkj6/xgBirKmFlbjqSM5t+OQVJG4/t9EJUx8+GmMShj5qVI0pg5H0ma+ZPNY1DGzH1I0szfax+CMmYeQZJm/kKTEJQx+5JQ0ca7EaQ5P+kGacxcJ67Oy4AQC67EwQKcT1Uiiu8p4cue6DhkYLGDB6ysCXBfOuf1774t+JsqBWYefJfSM6+jefEKjPc6pTufproP5X0p7q/Q78Q535P3gjjjQ+IybxVX5/nijFu1OasT1b3bSpvxQWir85g2d07XZnylubbMSxClmU1CUmZ8pEEIyjK3IUGY+TvtQlCWuQsJwsyfbBKDMM+chwRhmTcgBmHGl9rHAF1uX49Bgi4zLkOCLjfuRIKuTG5Hgiyv87NlqECVZ/LhQUgQZZn84kQTJMhxN8vZyeoN/YEIPb/4+Hm9gRQgqPb5u0/fc2h8QyBG/FYFzuqqnj0iAKSI367h254AYkoBRYoYHCsBRYsYiIj/bXIJ3ww4G3QBXh1yNh6nnXPG19ogFDfyUfdzzO3FWYgoHgBWUDggEgMAAPAVAJ0BKmAAYAA+MRaJQqIhIRgLHHAgAwSxAGk3AL9J/ADaEepfhb+0n+k5JvurythyOoP8z+QH9m+iHor8Rf/DfZL3APMB+nP7K9iD0AP1262b0AP2j9Kz2Mf29/4f/A+Af9c//Q0gNJ3MG8aGoH+qPoAdSB+laEZgR3iv2AwhLHYdOsaluNDFnQs241z2Fa2xKZeHggqLHWCUIMBqnZhLegQGZzHLVNkmuG7OTFK3HtKDTGh1hIAA/v7usEQurJYDnzZuaEty0/pLuG8V+gCnix0ijD36wHNsySlro1acCpUC+MSWl0HIEUAQwJJBGeStHf/b/90fKi/dlvxq0s/Oh3p/zG8b4f6gf2yWdCpOX67m20mGgL/Qh/Dd72O8/jIvkRrOosvlsgyLJ5Hutr/A/n1RiWIUACnzAmzS2JCQOxy3JC8j+qT4ct2BGmy0j/vSpG6/1UBazxDlbu839JqhBeVpIoEoOlMz9DAXp/C70ApMAUY3PE64Ya7XYcb523P7yKke+cYCpaKC3uYzK0HIncy5Pszu+rH1zNhFOtcPdBAj1tZfRLImjY4cKx4WKythwc7NPXiZ3apAytZ1/T7Wuq1SwE7h5ZDhNwiKT/UHCUeo6jWVwLT3QfXbtRVBr5zGOhiHi2KsvHafx/JAMRXUPDAjaKTc2mYuTOVIMH7pym1qbzgIIC1uAsMdkWoLsIVRJa47UrYL1+gX149HTyTVSgV0xhCqJn2pz5Pg7Br85XWPW8IQK78lKaYfqL0UCq9g+bB2l1lCq3rJWcDRwA+EFVV/jVZ7xdPGdpyWA0UL5EO3wmlQt3Qbi5ONgtnXobZwFWsHY3lWlHPYgBWOBWXx4DoT31P2So9qTu8Ab2hOq9UZdinjJg1b4g/f/gfWpNMxnpYyhKB5ITQmj8aPYokyqInXP/ydHbOrJebrwcuZ9JD/SIs+dfv+mY5X8sJW/ApFjv/u0EQoDA3HsxHwx7pJbo8+IsMz8Ij3ut/ZQkTTiQsNDkvLTQnCSyiU/2rHYQz4GFWLMTG5qFZVrNFRq2AAAA==', nom: "DocuSign", plein: false },
14
+ 'google': { src: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0Ij48cGF0aCBkPSJNMjIuNTYgMTIuMjVjMC0uNzgtLjA3LTEuNTMtLjItMi4yNUgxMnY0LjI2aDUuOTJjLS4yNiAxLjM3LTEuMDQgMi41My0yLjIxIDMuMzF2Mi43N2gzLjU3YzIuMDgtMS45MiAzLjI4LTQuNzQgMy4yOC04LjA5eiIgZmlsbD0iIzQyODVGNCIvPjxwYXRoIGQ9Ik0xMiAyM2MyLjk3IDAgNS40Ni0uOTggNy4yOC0yLjY2bC0zLjU3LTIuNzdjLS45OC42Ni0yLjIzIDEuMDYtMy43MSAxLjA2LTIuODYgMC01LjI5LTEuOTMtNi4xNi00LjUzSDIuMTh2Mi44NEMzLjk5IDIwLjUzIDcuNyAyMyAxMiAyM3oiIGZpbGw9IiMzNEE4NTMiLz48cGF0aCBkPSJNNS44NCAxNC4wOWMtLjIyLS42Ni0uMzUtMS4zNi0uMzUtMi4wOXMuMTMtMS40My4zNS0yLjA5VjcuMDdIMi4xOEMxLjQzIDguNTUgMSAxMC4yMiAxIDEycy40MyAzLjQ1IDEuMTggNC45M2wyLjg1LTIuMjIuODEtLjYyeiIgZmlsbD0iI0ZCQkMwNSIvPjxwYXRoIGQ9Ik0xMiA1LjM4YzEuNjIgMCAzLjA2LjU2IDQuMjEgMS42NGwzLjE1LTMuMTVDMTcuNDUgMi4wOSAxNC45NyAxIDEyIDEgNy43IDEgMy45OSAzLjQ3IDIuMTggNy4wN2wzLjY2IDIuODRjLjg3LTIuNiAzLjMtNC41MyA2LjE2LTQuNTN6IiBmaWxsPSIjRUE0MzM1Ii8+PHBhdGggZD0iTTEgMWgyMnYyMkgxeiIgZmlsbD0ibm9uZSIvPjwvc3ZnPg==', nom: "Google Workspace", plein: false },
15
+ 'henrri': { src: 'data:image/webp;base64,UklGRrgFAABXRUJQVlA4IKwFAACQHgCdASpgAGAAPjEWiUMiISEXiPTYIAMEsgBqYri/APxu7lyn/Wvxp/LH5i63/PvvZyasu+Z14h+e/5/7dPrN6DPsr9wD9O/8H1APMB+sv7Te0f+rnuA/Tv2AP6Z/lPWA9SP+sf7f2AP1h9NX9mfg3/b39xvadvGT8HysiQ37H/oeH/YR3orIXET2zHIBfSP8Z52vP00bPQvsI/yX+vb6ahQciI39yrPWkj5LEo8aImSVYgK7JqT+zpAddnCZPSLMXeOmYMszqDj62U8IhaM3INGjflWeF2bD83ozTB6i0tRgp2/ZbCnsvFFJaBUW4VW3ptUkW2uAaQ9s8bgAAP7xTYVq77cxtvoUT8iX8iX8oEWUOLh+Tf4FFYPh9CJ/rcey/1W+9K3RmjH0Q2ToquREs5KySoANutbkzjl4dZrpCpHdkcoXQuKfZe10q0MVoS9xFefQ1VogpVds4908KQ4G1AZHX5PjjP+4mMp08dO3f21xrpfsHeC5GA1X+xZgUeulXVaH6PnTTxMbOf+08tvEuViAib0GCa3gSideogTUymOtQXKjDSRKjeIt0DoVOpgMnBn9BeFPjrF1hnBkaV32SaiI2gBc3HUM2gll+USLRnJbZ97R7xnaKD8nkZ2+XGIGeQQzhuD4RNVNpfD/rEKuzDwqAmc5KCu7GGL/Vi22thly5L2NSBem5b1qs1HEz68Oa++gqBG0zTO4JN1hkfJtP/aKxQCurZSx+R/+/9L3on//427rufZ/pqqwDjYprGeexcST5Me+Re/T+9so12oPxTDlUXaiHqGid1o1dbwaJflAE6VaWVuXBZjycDLnjaQkBz/APqfkHfJQnb1IycGnrR4V7MCZl3/z09VjDu3a1yRTZsP3Ut73QYiV4KFvNqpj7ty70rk3u2IYc68Mn8PKOp1P842CSGgRvMDuWDFiA6w88G1SdVOTCHqsB0Q7L0T9/y09//TvNfv3wgvMGTw1DxUOMekHK+wj3s3XMSUJjV9gI3uL9ToNbMIQHF/ENmTj9Ftf88prMgB1//9nBo0eFFe++l0j54Q84D2+ZyYkY2I2ylLOryhVwCs/cCGYlszPE2fHJpJAGcxWigRKlw+UEQpuu3GEsGAphOD3q/sR9xs0x5IbvwNblE8LeFUx2i/0KDtQtXXxdBa+BXnW09zk1WXAOXRmeDD9ukzB1KlXKOujMD5+ZclfXirEcfnQ2OzH+Qdyg6QzolQTdMnmlEbsDmP/EVpoAaFWnHldj26VvH3FbXR1bwdl5rmdKXfDbLxTDEi0dvvJl9LCyW5wA9aNBt1r+K9/bPCUw1Fqmo/HKZPSrid0kCYDG08EZnW74/Fxxb8DTNzjo0FMAT0GLruQg30oMCfQtRfQnqAGxzvc+eLqTylsC96DHmcl0bvON02eR1yNlHdE05/Svnk3oq+3XF6rED/8ddsVQuherFVxnbQGtCdb7clCJBadVNZo6R2G91R/8S4k68URD/pS7lyS6Plxaij69rWRnwnVE3LgZywwsLH264BZsyz05nfl7fyQdgnq3dRGwfNCE0xELt/VvvAKwhlantrqrh5yBIINoRZFlKeXgkT0To5Z5/N5LSOuw0dO0KDv2MK/FVcFQC9sq0oEl/MPT7EXzqKcN3SvaYGHVM6ed6Wh9I3YFh3MZu6YEa30GmI5fY7AwKRsKEPjEgwGX3k++Wk5rwi5mIMWwNS49XG1zwC8mbUG5R+WMnn4Yc/cIWsDjcwqLOTHb42zuk6+OSzBVVKYvnSh5KL2gmN+RdDCqmF6mbdNPEFB9KXh39TE1NYKWJ1QRF2PLEYALWOvrHuFbgZ72YihavKilRZ1dynXEegZITqWnR+J1ZMQbpv2km6OPbJG5dChRj48a8WITagCZgoRaYMi3mrkrvYHB4QXYmxrkSGYhhnTyOn+zvAK7kMPiwAAAAA=', nom: "Henrri", plein: true },
16
+ 'maileva': { src: 'data:image/webp;base64,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', nom: "Maileva", plein: false },
17
+ 'microsoft': { src: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyMyAyMyI+PHBhdGggZmlsbD0iI2YzZjNmMyIgZD0iTTAgMGgyM3YyM0gweiIvPjxwYXRoIGZpbGw9IiNmMzUzMjUiIGQ9Ik0xIDFoMTB2MTBIMXoiLz48cGF0aCBmaWxsPSIjODFiYzA2IiBkPSJNMTIgMWgxMHYxMEgxMnoiLz48cGF0aCBmaWxsPSIjMDVhNmYwIiBkPSJNMSAxMmgxMHYxMEgxeiIvPjxwYXRoIGZpbGw9IiNmZmJhMDgiIGQ9Ik0xMiAxMmgxMHYxMEgxMnoiLz48L3N2Zz4=', nom: "Microsoft 365", plein: false },
18
+ }
@@ -0,0 +1,55 @@
1
+ // Les logos des services qu'une app branche : Microsoft 365, Google
2
+ // Workspace, DocuSign, Henrri, Maileva. Premier usage : la Marketplace de
3
+ // Tésope (03/11/2026), où chaque extension se reconnaît à sa marque.
4
+ //
5
+ // ⚠️ ENTRÉE SÉPARÉE (`@zevra/ui/react/partenaires`) : les logos y sont
6
+ // inlinés, le barrel React n'en porte pas un octet. Voir
7
+ // scripts/build-partenaires-data.mjs.
8
+ //
9
+ // Ce sont les marques des ÉDITEURS, en couleur, telles qu'ils les publient :
10
+ // on ne les recolore pas, on ne les redessine pas. Un service sans logo ici
11
+ // garde son `IconTile` : on n'invente pas de marque.
12
+
13
+ import { cx } from './cx.js'
14
+ import { LOGOS_PARTENAIRES, type PartenaireId } from './partenaires-data.js'
15
+
16
+ export type { PartenaireId }
17
+
18
+ /** Les services dont le paquet porte le logo. */
19
+ export const PARTENAIRES = Object.keys(LOGOS_PARTENAIRES) as PartenaireId[]
20
+
21
+ /** Le paquet a-t-il le logo de ce service ? Sinon, l'app garde son `IconTile`. */
22
+ export const aUnLogo = (id: string): id is PartenaireId => id in LOGOS_PARTENAIRES
23
+
24
+ export interface LogoPartenaireProps {
25
+ /** Un service dont le paquet porte le logo. */
26
+ id?: PartenaireId
27
+ /**
28
+ * Une image du paquet À LA PLACE d'un service partenaire : le glyphe d'un
29
+ * produit Zevra (`GLYPHES.memoire` de `@zevra/ui/glyphes`) quand le
30
+ * service branché est l'un des nôtres. Jamais une image de l'app : un logo
31
+ * se range dans le paquet, pas dans un public/.
32
+ */
33
+ src?: string
34
+ /** Avec `src` : l'image couvre toute la tuile (icône d'application à fond plein). */
35
+ plein?: boolean
36
+ /** Nom lu par un lecteur d'écran. Par défaut, celui de la marque. Vide : décoratif (le nom est écrit à côté). */
37
+ titre?: string
38
+ className?: string
39
+ }
40
+
41
+ /**
42
+ * La tuile d'un service : même gabarit que `IconTile` (40×40), fond blanc
43
+ * au filet, le logo au centre. Une icône d'application à fond plein
44
+ * (Henrri) occupe toute la tuile.
45
+ */
46
+ export function LogoPartenaire({ id, src, plein, titre, className }: LogoPartenaireProps) {
47
+ const l = id ? LOGOS_PARTENAIRES[id] : { src: src ?? '', nom: '', plein: Boolean(plein) }
48
+ const alt = titre ?? l.nom
49
+ return (
50
+ <span className={cx('zv-icon-tile', 'zv-icon-tile--logo', l.plein && 'zv-icon-tile--logo-plein', className)}
51
+ role={alt ? 'img' : undefined} aria-label={alt || undefined} aria-hidden={alt ? undefined : true}>
52
+ <img src={l.src} alt="" />
53
+ </span>
54
+ )
55
+ }
@@ -20,7 +20,8 @@
20
20
  // liste système est plus rapide à manipuler que n'importe quelle liste
21
21
  // maison, et où le clavier logiciel s'adapte tout seul.
22
22
 
23
- import { useCallback, useEffect, useId, useRef, useState } from 'react'
23
+ import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
24
+ import type { CSSProperties } from 'react'
24
25
  import type { ReactNode } from 'react'
25
26
  import { cx } from './cx.js'
26
27
  import { Icon } from './icon.js'
@@ -118,13 +119,70 @@ export function SelectMenu<T extends string = string>({
118
119
  return () => doc.removeEventListener('pointerdown', dehors)
119
120
  }, [ouvert])
120
121
 
122
+ // La place de la liste à l'écran (DECISIONS, 03/11/2026) : en `fixed`, elle
123
+ // échappe à tout conteneur qui rogne (un tableau qui défile, une carte en
124
+ // `overflow: hidden`). Calculée d'après la boîte du déclencheur, et
125
+ // recalculée quand la page ou un conteneur défile, ou que la fenêtre change.
126
+ const [place, setPlace] = useState<CSSProperties | null>(null)
127
+ useLayoutEffect(() => {
128
+ if (!ouvert) {
129
+ setPlace(null)
130
+ return
131
+ }
132
+ const vue = declencheur.current?.ownerDocument.defaultView ?? window
133
+ const ECART = 5
134
+ const MARGE = 8
135
+ function placer(e?: Event) {
136
+ // Le défilement de la liste ELLE-MÊME ne la déplace pas : sans ce
137
+ // filtre, chaque tour de molette dans une longue liste la replaçait.
138
+ if (e && e.target === liste.current) return
139
+ const d = declencheur.current
140
+ if (!d) return
141
+ const r = d.getBoundingClientRect()
142
+ // La largeur AVANT la mesure : sans elle, la liste vaut encore 100 % de
143
+ // la fenêtre au premier passage, et le garde-fou de droite la plaque à
144
+ // gauche de l'écran.
145
+ liste.current?.style.setProperty('--zv-menu-largeur', `${r.width}px`)
146
+ const hauteur = liste.current?.scrollHeight ?? 0
147
+ const largeur = Math.max(liste.current?.offsetWidth ?? 0, r.width)
148
+ const dessous = vue.innerHeight - r.bottom - ECART - MARGE
149
+ const dessus = r.top - ECART - MARGE
150
+ // Au-dessus seulement si la liste ne tient pas dessous ET qu'il y a
151
+ // davantage de place au-dessus : sinon on garde le sens attendu.
152
+ const enHaut = hauteur > dessous && dessus > dessous
153
+ const dispo = Math.max(120, enHaut ? dessus : dessous)
154
+ const visible = Math.min(hauteur, dispo)
155
+ setPlace({
156
+ position: 'fixed',
157
+ top: enHaut ? r.top - ECART - visible : r.bottom + ECART,
158
+ // Jamais hors de la fenêtre à droite.
159
+ left: Math.max(MARGE, Math.min(r.left, vue.innerWidth - largeur - MARGE)),
160
+ maxHeight: dispo,
161
+ '--zv-menu-largeur': `${r.width}px`,
162
+ } as CSSProperties)
163
+ }
164
+ placer()
165
+ vue.addEventListener('scroll', placer, true)
166
+ vue.addEventListener('resize', placer)
167
+ return () => {
168
+ vue.removeEventListener('scroll', placer, true)
169
+ vue.removeEventListener('resize', placer)
170
+ }
171
+ }, [ouvert, options.length])
172
+
121
173
  // L'option active reste visible quand on parcourt une liste plus haute que
122
- // son cadre.
174
+ // son cadre. `place` : la liste n'est bornée (hauteur maximale) qu'une fois
175
+ // placée ; avant, il n'y a rien à faire défiler, et une liste longue
176
+ // s'ouvrait en haut (« Algérie ») au lieu de l'option choisie (« France »).
177
+ // Sur le PASSAGE à « placée », pas sur chaque nouvelle place : la place
178
+ // change à chaque défilement de la page, et ramener l'option active à
179
+ // chaque fois contrarierait qui parcourt la liste.
180
+ const placee = place !== null
123
181
  useEffect(() => {
124
- if (!ouvert) return
182
+ if (!ouvert || !placee) return
125
183
  const el = liste.current?.children[actif] as HTMLElement | undefined
126
184
  el?.scrollIntoView({ block: 'nearest' })
127
- }, [ouvert, actif])
185
+ }, [ouvert, actif, placee])
128
186
 
129
187
  function suivant(depuis: number, pas: number): number {
130
188
  const n = options.length
@@ -221,7 +279,15 @@ export function SelectMenu<T extends string = string>({
221
279
  </button>
222
280
 
223
281
  {ouvert && (
224
- <div ref={liste} id={idListe} role="listbox" className="zv-menu">
282
+ <div
283
+ ref={liste}
284
+ id={idListe}
285
+ role="listbox"
286
+ className="zv-menu zv-menu--place"
287
+ // Tant que la place n'est pas calculée (premier rendu), la liste est
288
+ // posée mais invisible : elle ne clignote pas à sa place d'origine.
289
+ style={place ?? { position: 'fixed', visibility: 'hidden' }}
290
+ >
225
291
  {options.map((o, i) => (
226
292
  <button
227
293
  key={o.valeur}
package/src/forms.css CHANGED
@@ -383,8 +383,21 @@
383
383
  }
384
384
 
385
385
  .zv-select-anchor--block .zv-menu {
386
- width: 100%;
387
- min-width: 0;
386
+ /* En `fixed` (DECISIONS, 03/11/2026), 100 % vaudrait la fenêtre : la
387
+ * largeur du déclencheur arrive par `--zv-menu-largeur`. La liste en est
388
+ * le MINIMUM, pas la mesure : sous un déclencheur calé sur son contenu
389
+ * (« Membre »), l'option longue (« Administrateur ») débordait. Elle prend
390
+ * la largeur de ses options, jamais plus que la fenêtre. */
391
+ width: max-content;
392
+ min-width: var(--zv-menu-largeur, 100%);
393
+ max-width: calc(100vw - 16px);
394
+ }
395
+
396
+ /* La liste de SelectMenu, placée par le composant (`fixed`, haut, gauche,
397
+ * hauteur maximale) : elle défile au-delà de la place disponible. */
398
+ .zv-menu--place {
399
+ overflow-y: auto;
400
+ overscroll-behavior: contain;
388
401
  }
389
402
 
390
403
  /* ─── Déclencheur de sélection DESSINÉE ─────────────────────────────────
@@ -585,6 +598,8 @@
585
598
  height: 20px;
586
599
  flex: none;
587
600
  border: 1px solid var(--line);
601
+ /* Carrée par défaut, en gélule sous `zv-arrondi` (tokens.css). */
602
+ border-radius: var(--r-switch);
588
603
  background: var(--white);
589
604
  /* La courbe est celle du paquet (motion.css, `--zv-easing`), recopiée en
590
605
  * clair : forms.css ne dépend pas de motion.css et n'a pas à en dépendre
@@ -619,7 +634,7 @@
619
634
  inset-inline-start: 2px;
620
635
  width: 14px;
621
636
  height: 14px;
622
- border-radius: 0;
637
+ border-radius: var(--r-switch-knob);
623
638
  background: var(--label);
624
639
  transition:
625
640
  inset-inline-start 150ms cubic-bezier(0.2, 0.7, 0.2, 1),
package/src/icons.css CHANGED
@@ -132,6 +132,39 @@
132
132
  height: 40px;
133
133
  flex: none;
134
134
  border: 1px solid var(--accent-line);
135
+ /* Suit l'option arrondie (`zv-arrondi`, 03/11/2026) : nul par défaut. */
136
+ border-radius: var(--r-pill);
135
137
  background: var(--accent-soft);
136
138
  color: var(--accent);
137
139
  }
140
+
141
+ /* La tuile d'un SERVICE (DECISIONS, « Les logos des partenaires »,
142
+ * 03/11/2026). HORS RELEVÉ. Même gabarit que la tuile d'icône (40×40, même
143
+ * filet), mais sur papier blanc : la marque de l'éditeur garde ses couleurs,
144
+ * le fond indigo pâle les aurait salies. Le logo occupe 60 % de la tuile,
145
+ * l'air autour est celui des icônes tracées. Une icône d'application à fond
146
+ * plein (Henrri) prend toute la tuile : un carré coloré dans un carré blanc
147
+ * se lirait comme un cadre de trop. */
148
+ .zv-icon-tile--logo {
149
+ overflow: hidden;
150
+ border-color: var(--line);
151
+ background: var(--white);
152
+ }
153
+
154
+ .zv-icon-tile--logo img {
155
+ display: block;
156
+ width: 60%;
157
+ height: 60%;
158
+ object-fit: contain;
159
+ }
160
+
161
+ /* À fond plein, le filet ferait un cadre autour du cadre de la marque. */
162
+ .zv-icon-tile--logo-plein {
163
+ border-color: transparent;
164
+ }
165
+
166
+ .zv-icon-tile--logo-plein img {
167
+ width: 100%;
168
+ height: 100%;
169
+ object-fit: cover;
170
+ }
package/src/tokens.css CHANGED
@@ -107,6 +107,9 @@
107
107
  --r-pill: 0;
108
108
  --r-panel: 0;
109
109
  --r-card: 0;
110
+ /* L'interrupteur : carré ici, en gélule sous `zv-arrondi` (03/11/2026). */
111
+ --r-switch: 0;
112
+ --r-switch-knob: 0;
110
113
 
111
114
  /* Ombres */
112
115
  --shadow-card: 0 2px 10px rgba(11, 16, 32, 0.05);
@@ -183,18 +186,27 @@
183
186
  * lui seul. `zv-vif` remet un élément au carré dans une zone arrondie.
184
187
  *
185
188
  * Légèrement, et c'est voulu : à peu près la moitié du relevé d'avant le
186
- * 25/08. Le rond de forme (pastille, radio, interrupteur) n'en dépend pas. */
189
+ * 25/08. Le rond de forme (pastille, radio) n'en dépend pas.
190
+ *
191
+ * L'interrupteur, lui, en dépend depuis le 03/11/2026 (DECISIONS,
192
+ * « L'interrupteur suit l'option arrondie ») : carré en angle vif, piste en
193
+ * gélule et bouton rond sous `zv-arrondi`. Deux tokens à part, parce qu'un
194
+ * rayon de 3 px sur une piste de 20 px ne se lit ni carré ni rond. */
187
195
  .zv-arrondi {
188
196
  --r-btn: 4px;
189
197
  --r-pill: 3px;
190
198
  --r-panel: 6px;
191
199
  --r-card: 8px;
200
+ --r-switch: 999px;
201
+ --r-switch-knob: 50%;
192
202
  }
193
203
  .zv-vif {
194
204
  --r-btn: 0;
195
205
  --r-pill: 0;
196
206
  --r-panel: 0;
197
207
  --r-card: 0;
208
+ --r-switch: 0;
209
+ --r-switch-knob: 0;
198
210
  }
199
211
  /* L'enveloppe `Angles` : elle ne porte que les tokens, sans ajouter de
200
212
  * boîte à la mise en page (l'héritage passe par l'arbre, pas par la boîte). */
package/tokens/index.js CHANGED
@@ -122,6 +122,9 @@ export const rayons = {
122
122
  pill: '0',
123
123
  panel: '0',
124
124
  card: '0',
125
+ /** L'interrupteur, piste et bouton : carrés ici, en gélule sous `zv-arrondi` (03/11/2026). */
126
+ switch: '0',
127
+ switchKnob: '0',
125
128
  }
126
129
 
127
130
  export const ombres = {