@zevra/ui 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/react/index.ts CHANGED
@@ -38,5 +38,6 @@ export * from './icon.js'
38
38
  export * from './layout.js'
39
39
  export * from './navigation.js'
40
40
  export * from './overlays.js'
41
+ export * from './select-menu.js'
41
42
  export * from './surfaces.js'
42
43
  export * from './typography.js'
@@ -0,0 +1,250 @@
1
+ 'use client'
2
+
3
+ // Sélection DESSINÉE — le déclencheur a l'apparence d'un champ, et la liste
4
+ // est peinte par le paquet.
5
+ //
6
+ // ⚠️ POURQUOI CE COMPOSANT EXISTE, ALORS QUE `Select` (natif) EST LÀ.
7
+ // Un <select> natif ne peut pas être habillé : sa liste déroulée est peinte
8
+ // par le SYSTÈME, pas par la page. Sur un macOS en mode sombre elle sort en
9
+ // menu noir au milieu d'une interface claire — aucun CSS ne corrige cela.
10
+ //
11
+ // ⚠️ CE QUE ÇA COÛTE, ET QUI EST PAYÉ ICI. En remplaçant le contrôle natif,
12
+ // on perd le clavier, l'annonce au lecteur d'écran et la liste système. Tout
13
+ // est réimplémenté dans ce fichier : rôles ARIA de combobox/listbox,
14
+ // navigation aux flèches, Origine/Fin, Échap, recherche au clavier, fermeture
15
+ // au clic extérieur et restitution du focus. C'est la raison pour laquelle il
16
+ // n'existe PAS de version « CSS seule » de ce composant : les classes posées
17
+ // à la main sans ce travail livrent un select inutilisable au clavier.
18
+ //
19
+ // Quand préférer `Select` (natif) : un formulaire long sur mobile, où la
20
+ // liste système est plus rapide à manipuler que n'importe quelle liste
21
+ // maison, et où le clavier logiciel s'adapte tout seul.
22
+
23
+ import { useCallback, useEffect, useId, useRef, useState } from 'react'
24
+ import type { ReactNode } from 'react'
25
+ import { cx } from './cx.js'
26
+
27
+ export interface OptionSelect<T extends string = string> {
28
+ valeur: T
29
+ label: ReactNode
30
+ /** Texte employé pour la recherche au clavier et l'affichage du
31
+ * déclencheur quand `label` n'est pas une chaîne. */
32
+ texte?: string
33
+ desactive?: boolean
34
+ }
35
+
36
+ export interface SelectMenuProps<T extends string = string> {
37
+ options: readonly OptionSelect<T>[]
38
+ valeur: T | null
39
+ onChange: (valeur: T) => void
40
+ /** Affiché quand rien n'est choisi. */
41
+ placeholder?: string
42
+ /** Identifiant du déclencheur — à faire correspondre au `htmlFor` du champ. */
43
+ id?: string
44
+ /** Nom du contrôle, quand aucun intitulé visible ne le désigne. */
45
+ label?: string
46
+ desactive?: boolean
47
+ erreur?: boolean
48
+ className?: string
49
+ }
50
+
51
+ function texteDe(o: OptionSelect | undefined): string {
52
+ if (!o) return ''
53
+ return o.texte ?? (typeof o.label === 'string' ? o.label : String(o.valeur))
54
+ }
55
+
56
+ export function SelectMenu<T extends string = string>({
57
+ options,
58
+ valeur,
59
+ onChange,
60
+ placeholder = 'Choisir…',
61
+ id,
62
+ label,
63
+ desactive,
64
+ erreur,
65
+ className,
66
+ }: SelectMenuProps<T>) {
67
+ const auto = useId()
68
+ const idListe = `${id ?? auto}-liste`
69
+ const [ouvert, setOuvert] = useState(false)
70
+ // L'option SURVOLÉE au clavier, distincte de l'option CHOISIE : c'est elle
71
+ // que `aria-activedescendant` désigne, et elle ne devient un choix qu'à
72
+ // Entrée. Les confondre ferait valider au premier coup de flèche.
73
+ const [actif, setActif] = useState(0)
74
+ const ancre = useRef<HTMLDivElement>(null)
75
+ const declencheur = useRef<HTMLButtonElement>(null)
76
+ const liste = useRef<HTMLDivElement>(null)
77
+ // La saisie au clavier (« ré » → Rédacteur) s'accumule et retombe après
78
+ // une seconde : c'est le comportement d'un <select> natif, et le perdre
79
+ // se remarque tout de suite sur une liste longue.
80
+ const frappe = useRef<{ texte: string; quand: number }>({ texte: '', quand: 0 })
81
+
82
+ const indexChoisi = options.findIndex((o) => o.valeur === valeur)
83
+ const choisi = indexChoisi >= 0 ? options[indexChoisi] : undefined
84
+
85
+ const ouvrir = useCallback(() => {
86
+ if (desactive) return
87
+ // On s'ouvre SUR l'option courante, pas sur la première : dans une liste
88
+ // de vingt entrées, repartir du haut oblige à retrouver sa place.
89
+ setActif(indexChoisi >= 0 ? indexChoisi : 0)
90
+ setOuvert(true)
91
+ }, [desactive, indexChoisi])
92
+
93
+ const fermer = useCallback((rendreLeFocus = true) => {
94
+ setOuvert(false)
95
+ if (rendreLeFocus) declencheur.current?.focus()
96
+ }, [])
97
+
98
+ const choisir = useCallback(
99
+ (i: number) => {
100
+ const o = options[i]
101
+ if (!o || o.desactive) return
102
+ onChange(o.valeur)
103
+ fermer()
104
+ },
105
+ [options, onChange, fermer],
106
+ )
107
+
108
+ // Clic extérieur. `pointerdown` et non `click` : un clic qui commence
109
+ // dehors et finit dedans ne doit pas rouvrir ce qu'il vient de fermer.
110
+ useEffect(() => {
111
+ if (!ouvert) return
112
+ const doc = ancre.current?.ownerDocument ?? document
113
+ function dehors(e: PointerEvent) {
114
+ if (!ancre.current?.contains(e.target as Node)) setOuvert(false)
115
+ }
116
+ doc.addEventListener('pointerdown', dehors)
117
+ return () => doc.removeEventListener('pointerdown', dehors)
118
+ }, [ouvert])
119
+
120
+ // L'option active reste visible quand on parcourt une liste plus haute que
121
+ // son cadre.
122
+ useEffect(() => {
123
+ if (!ouvert) return
124
+ const el = liste.current?.children[actif] as HTMLElement | undefined
125
+ el?.scrollIntoView({ block: 'nearest' })
126
+ }, [ouvert, actif])
127
+
128
+ function suivant(depuis: number, pas: number): number {
129
+ const n = options.length
130
+ for (let k = 1; k <= n; k++) {
131
+ const i = (depuis + pas * k + n * n) % n
132
+ if (!options[i]?.desactive) return i
133
+ }
134
+ return depuis
135
+ }
136
+
137
+ function onKeyDown(e: React.KeyboardEvent) {
138
+ if (desactive) return
139
+ if (!ouvert) {
140
+ if (['Enter', ' ', 'ArrowDown', 'ArrowUp'].includes(e.key)) {
141
+ e.preventDefault()
142
+ ouvrir()
143
+ }
144
+ return
145
+ }
146
+ switch (e.key) {
147
+ case 'Escape':
148
+ e.preventDefault()
149
+ e.stopPropagation()
150
+ fermer()
151
+ return
152
+ case 'Enter':
153
+ case ' ':
154
+ e.preventDefault()
155
+ choisir(actif)
156
+ return
157
+ case 'ArrowDown':
158
+ e.preventDefault()
159
+ setActif((i) => suivant(i, 1))
160
+ return
161
+ case 'ArrowUp':
162
+ e.preventDefault()
163
+ setActif((i) => suivant(i, -1))
164
+ return
165
+ case 'Home':
166
+ e.preventDefault()
167
+ setActif(suivant(-1, 1))
168
+ return
169
+ case 'End':
170
+ e.preventDefault()
171
+ setActif(suivant(options.length, -1))
172
+ return
173
+ case 'Tab':
174
+ // Tab valide et sort : c'est ce que fait un select natif, et sortir
175
+ // sans rien retenir surprend.
176
+ choisir(actif)
177
+ return
178
+ default:
179
+ break
180
+ }
181
+ if (e.key.length === 1) {
182
+ const maintenant = Date.now()
183
+ const texte =
184
+ (maintenant - frappe.current.quand < 1000 ? frappe.current.texte : '') +
185
+ e.key.toLowerCase()
186
+ frappe.current = { texte, quand: maintenant }
187
+ const i = options.findIndex(
188
+ (o) => !o.desactive && texteDe(o).toLowerCase().startsWith(texte),
189
+ )
190
+ if (i >= 0) setActif(i)
191
+ }
192
+ }
193
+
194
+ return (
195
+ <div
196
+ ref={ancre}
197
+ className={cx('zv-select-anchor', 'zv-select-anchor--block', className)}
198
+ >
199
+ <button
200
+ ref={declencheur}
201
+ id={id}
202
+ type="button"
203
+ role="combobox"
204
+ aria-expanded={ouvert}
205
+ aria-controls={idListe}
206
+ aria-haspopup="listbox"
207
+ aria-label={label}
208
+ aria-invalid={erreur || undefined}
209
+ aria-activedescendant={ouvert ? `${idListe}-${actif}` : undefined}
210
+ disabled={desactive}
211
+ data-vide={choisi ? undefined : 'oui'}
212
+ className="zv-input zv-input--trigger"
213
+ onClick={() => (ouvert ? fermer() : ouvrir())}
214
+ onKeyDown={onKeyDown}
215
+ >
216
+ <span className="zv-trunc">{choisi ? choisi.label : placeholder}</span>
217
+ <span className="zv-select__chevron" aria-hidden="true">
218
+ ▾
219
+ </span>
220
+ </button>
221
+
222
+ {ouvert && (
223
+ <div ref={liste} id={idListe} role="listbox" className="zv-menu">
224
+ {options.map((o, i) => (
225
+ <button
226
+ key={o.valeur}
227
+ id={`${idListe}-${i}`}
228
+ type="button"
229
+ role="option"
230
+ aria-selected={o.valeur === valeur}
231
+ aria-disabled={o.desactive || undefined}
232
+ // ⚠️ tabIndex -1 : le focus ne quitte JAMAIS le déclencheur.
233
+ // C'est ce qui permet à aria-activedescendant de porter
234
+ // l'annonce, et à la frappe de continuer d'arriver au clavier.
235
+ tabIndex={-1}
236
+ className={cx(
237
+ 'zv-menu__item',
238
+ i === actif && !o.desactive && 'zv-menu__item--survol',
239
+ )}
240
+ onPointerEnter={() => !o.desactive && setActif(i)}
241
+ onClick={() => choisir(i)}
242
+ >
243
+ {o.label}
244
+ </button>
245
+ ))}
246
+ </div>
247
+ )}
248
+ </div>
249
+ )
250
+ }
package/src/base.css CHANGED
@@ -273,7 +273,9 @@ a:not([class]):hover {
273
273
  width: var(--spec-filet);
274
274
  height: 2px;
275
275
  background: var(--spectre);
276
- border-radius: 1px;
276
+ /* Angle vif comme tout le reste depuis le 25/08 — sur 2px de haut, le
277
+ * rayon d'1px du relevé ne se voyait de toute façon pas. */
278
+ border-radius: 0;
277
279
  }
278
280
 
279
281
  /* Bas de page : filet raccourci. */
@@ -94,7 +94,23 @@
94
94
  * à 16px dans DesignSystem.html. Tracés : M5 12h14 et M13 6l6 6-6 6, sur
95
95
  * un viewBox 0 0 24 24, stroke currentColor, épaisseur 2, bouts arrondis. */
96
96
 
97
+ /* ⚠️ REPRIS le 25/08/2026 — la DA le jugeait « pas ouf ». Le diagnostic :
98
+ * c'était un lien bleu avec un chevron collé, sans rien qui le distingue
99
+ * d'un lien de texte, et sans aucune récompense au survol. Le mouvement du
100
+ * paquet est réservé à la vitrine et piloté au défilement ; il n'y avait
101
+ * donc PAS de vocabulaire pour un lien d'action.
102
+ *
103
+ * Trois ajouts, tous dessinés dans les tokens existants :
104
+ * · un FILET qui se déploie sous le libellé au survol, de gauche à droite
105
+ * (scaleX sur une origine gauche) — pas un `text-decoration`, qui ne
106
+ * s'anime pas et coupe les jambages ;
107
+ * · la flèche avance de 4px, ce qui donne au lien sa direction ;
108
+ * · le libellé passe en --accent-deep.
109
+ * Le tout est repeint en une seule transition, donc coupé net par
110
+ * prefers-reduced-motion (base.css) — l'état final reste lisible sans. */
111
+
97
112
  .zv-link-arrow {
113
+ position: relative;
98
114
  display: inline-flex;
99
115
  align-items: center;
100
116
  gap: 8px;
@@ -102,22 +118,56 @@
102
118
  font-weight: 600;
103
119
  color: var(--accent);
104
120
  text-decoration: none;
121
+ transition: color 160ms cubic-bezier(0.2, 0.7, 0.2, 1);
122
+ }
123
+
124
+ /* Le filet est un pseudo-élément et non une bordure : une bordure
125
+ * occuperait de la place et ferait sauter la ligne de base au survol. */
126
+ .zv-link-arrow::after {
127
+ content: '';
128
+ position: absolute;
129
+ inset-inline: 0;
130
+ bottom: -3px;
131
+ height: 1px;
132
+ background: currentColor;
133
+ transform: scaleX(0);
134
+ transform-origin: left center;
135
+ transition: transform 220ms cubic-bezier(0.2, 0.7, 0.2, 1);
105
136
  }
106
137
 
107
- .zv-link-arrow:hover {
138
+ .zv-link-arrow:hover,
139
+ .zv-link-arrow:focus-visible {
108
140
  color: var(--accent-deep);
109
141
  }
110
142
 
143
+ .zv-link-arrow:hover::after,
144
+ .zv-link-arrow:focus-visible::after {
145
+ transform: scaleX(1);
146
+ }
147
+
111
148
  .zv-link-arrow svg {
112
149
  width: 15px;
113
150
  height: 15px;
114
151
  flex: none;
152
+ transition: transform 220ms cubic-bezier(0.2, 0.7, 0.2, 1);
153
+ }
154
+
155
+ .zv-link-arrow:hover svg,
156
+ .zv-link-arrow:focus-visible svg {
157
+ transform: translateX(4px);
115
158
  }
116
159
 
160
+ /* Sur la bande manifeste : le filet suit `currentColor`, il n'y a donc rien
161
+ * à redéclarer — c'est tout l'intérêt de l'avoir posé en currentColor. */
117
162
  .zv-link-arrow--on-dark {
118
163
  color: var(--white);
119
164
  }
120
165
 
166
+ .zv-link-arrow--on-dark:hover,
167
+ .zv-link-arrow--on-dark:focus-visible {
168
+ color: var(--accent-on-dark);
169
+ }
170
+
121
171
  /* ─── Badges et puces ───────────────────────────────────────────────────*/
122
172
 
123
173
  /* Pastille pleine : numéro de pièce, catégorie de section. */
@@ -148,7 +198,8 @@
148
198
  color: var(--accent-deep);
149
199
  background: var(--white);
150
200
  border: 1px solid var(--accent-line);
151
- border-radius: 4px;
201
+ /* La puce de source est une pastille : elle suit le token, désormais à 0. */
202
+ border-radius: var(--r-pill);
152
203
  padding: 6px 10px;
153
204
  }
154
205
 
@@ -515,7 +566,7 @@
515
566
  .zv-progress {
516
567
  height: 3px;
517
568
  background: var(--hairline);
518
- border-radius: 2px;
569
+ border-radius: 0;
519
570
  overflow: hidden;
520
571
  }
521
572
 
package/src/forms.css CHANGED
@@ -183,6 +183,15 @@
183
183
  border: 1px solid var(--line);
184
184
  background: var(--white);
185
185
  padding-inline: 13px;
186
+ /* La prise de focus s'ANIME (25/08) : sans transition, le cadre sautait
187
+ * d'un état à l'autre et le retour se lisait comme un défaut de rendu. */
188
+ transition:
189
+ border-color 140ms cubic-bezier(0.2, 0.7, 0.2, 1),
190
+ box-shadow 140ms cubic-bezier(0.2, 0.7, 0.2, 1);
191
+ }
192
+
193
+ .zv-search:hover {
194
+ border-color: var(--accent-line);
186
195
  }
187
196
 
188
197
  /* DÉRIVÉ : la bordure de focus relevée sur .zv-input, portée par le cadre.
@@ -196,16 +205,42 @@
196
205
  * · supprimer ce filet priverait la souris de tout retour, alors que le
197
206
  * relevé lui en donne un sur les champs bordés.
198
207
  * La redondance coûte moins cher que l'une ou l'autre perte. */
208
+ /* ⚠️ REPRIS le 25/08/2026 — la DA jugeait la prise de focus « boff ». Elle
209
+ * l'était : le cadre passait à --accent-line, un filet si pâle qu'à côté de
210
+ * l'anneau du champ intérieur il ne se voyait pas. Trois corrections :
211
+ * · le filet passe à --accent PLEIN, pas à sa version diluée ;
212
+ * · un halo --accent-soft de 3px double le trait — c'est lui qui rend le
213
+ * retour visible du coin de l'œil, sans déplacer un pixel de la mise en
214
+ * page (box-shadow ne prend pas de place) ;
215
+ * · la loupe s'allume avec le cadre : elle cesse d'être une invite grise
216
+ * pour devenir la marque du champ actif.
217
+ *
218
+ * L'anneau :focus-visible du champ INTÉRIEUR est neutralisé ici, et
219
+ * seulement ici : le cadre le remplace, et il le remplace pour la souris
220
+ * comme pour le clavier. C'est ce qui lève la double marque assumée jusque-là
221
+ * — un seul objet à l'écran, un seul retour. Le champ nu reste parfaitement
222
+ * repérable au clavier, puisque c'est le cadre entier qui s'allume. */
199
223
  .zv-search:focus-within {
200
- border-color: var(--accent-line);
224
+ border-color: var(--accent);
225
+ box-shadow: 0 0 0 3px var(--accent-soft);
226
+ }
227
+
228
+ .zv-search:focus-within .zv-search__icon {
229
+ stroke: var(--accent);
230
+ }
231
+
232
+ .zv-search__input:focus-visible {
233
+ outline: none;
201
234
  }
202
235
 
203
236
  .zv-search__icon {
204
237
  flex: none;
205
238
  width: 13px;
206
239
  height: 13px;
207
- /* Même gris que le placeholder : la loupe est une invite, pas une donnée. */
240
+ /* Même gris que le placeholder : la loupe est une invite, pas une donnée.
241
+ * Elle s'allume avec le cadre — voir :focus-within plus bas. */
208
242
  stroke: var(--label);
243
+ transition: stroke 140ms cubic-bezier(0.2, 0.7, 0.2, 1);
209
244
  }
210
245
 
211
246
  .zv-search__input {
@@ -279,6 +314,63 @@
279
314
  max-width: 100%;
280
315
  }
281
316
 
317
+ /* Ancre pleine largeur : le cas d'un CHAMP de formulaire, où la liste doit
318
+ * tomber exactement sous le contrôle et faire la même largeur que lui. La
319
+ * variante en ligne ci-dessus reste celle des filtres, où le déclencheur se
320
+ * dimensionne sur son libellé. */
321
+ .zv-select-anchor--block {
322
+ display: block;
323
+ width: 100%;
324
+ }
325
+
326
+ .zv-select-anchor--block .zv-menu {
327
+ width: 100%;
328
+ min-width: 0;
329
+ }
330
+
331
+ /* ─── Déclencheur de sélection DESSINÉE ─────────────────────────────────
332
+ * ⚠️ AJOUTÉ le 25/08/2026 : « le select, faut le designer jusque dans
333
+ * l'affichage de la liste » (DA, sur pièces).
334
+ *
335
+ * Le <select> natif (.zv-input--select) reste dans le paquet et garde son
336
+ * usage — mais il ne peut PAS être habillé : la liste déroulée est peinte
337
+ * par le système, pas par la page. Sur un macOS en mode sombre, elle sort
338
+ * en menu noir au milieu d'une interface claire. Aucun CSS ne corrige cela,
339
+ * c'est une limite du contrôle.
340
+ *
341
+ * D'où ce déclencheur, qui a l'apparence exacte d'un champ et ouvre le
342
+ * .zv-menu du paquet. Ce qu'il COÛTE, et qu'il faut assumer en le
343
+ * choisissant : le clavier, l'annonce et la liste native sont à réimplémenter
344
+ * — c'est fait dans le composant React (SelectMenu), pas dans ce fichier.
345
+ * Un gabarit qui pose ces classes à la main sans ce travail livre un select
346
+ * inutilisable au clavier. Dans ce cas, prendre le natif. */
347
+ .zv-input--trigger {
348
+ display: flex;
349
+ align-items: center;
350
+ justify-content: space-between;
351
+ gap: 10px;
352
+ text-align: left;
353
+ line-height: 1.4;
354
+ cursor: pointer;
355
+ transition: border-color 140ms cubic-bezier(0.2, 0.7, 0.2, 1);
356
+ }
357
+
358
+ .zv-input--trigger:hover {
359
+ border-color: var(--accent-line);
360
+ }
361
+
362
+ /* Ouvert, le déclencheur reste marqué : sans cela, la liste flotte au-dessus
363
+ * d'un champ qui a l'air au repos, et on ne sait plus lequel elle sert. */
364
+ .zv-input--trigger[aria-expanded='true'] {
365
+ border-color: var(--accent);
366
+ }
367
+
368
+ /* Le placeholder d'un déclencheur est du texte, pas un ::placeholder : il
369
+ * lui faut donc sa propre teinte, celle du champ vide. */
370
+ .zv-input--trigger[data-vide='oui'] {
371
+ color: var(--label);
372
+ }
373
+
282
374
  .zv-select {
283
375
  display: inline-flex;
284
376
  align-items: center;
@@ -384,37 +476,70 @@
384
476
  * elle est portée par l'aplat plein et par le libellé accolé, pas par cette
385
477
  * nuance-là. */
386
478
 
479
+ /* ⚠️ REDESSINÉ le 25/08/2026 — la DA a tranché « switch horrible » sur
480
+ * pièces, et elle avait raison sur trois points :
481
+ * · 28×16 avec un bouton rond de 12, c'est un jouet posé au milieu de
482
+ * contrôles carrés de 16px de haut ; la ligne de réglage n'avait aucune
483
+ * unité ;
484
+ * · la piste éteinte était --accent-line, un indigo très pâle : éteint et
485
+ * allumé se distinguaient par la SATURATION d'une même teinte, ce qui
486
+ * tombe au premier écran mal calibré et disparaît en daltonisme ;
487
+ * · le bouton rond contredisait l'arbitrage « carré » du même jour.
488
+ * La piste passe donc à 36×20, à angle vif, avec un filet qui la pose comme
489
+ * les champs voisins ; éteint elle est BLANCHE au filet gris, allumée elle
490
+ * est un aplat indigo plein. L'écart n'est plus une nuance, c'est un
491
+ * contraste. */
387
492
  .zv-switch {
388
493
  position: relative;
389
494
  display: inline-block;
390
- width: 28px;
391
- height: 16px;
495
+ width: 36px;
496
+ height: 20px;
392
497
  flex: none;
393
- background: var(--accent-line);
498
+ border: 1px solid var(--line);
499
+ background: var(--white);
394
500
  /* La courbe est celle du paquet (motion.css, `--zv-easing`), recopiée en
395
501
  * clair : forms.css ne dépend pas de motion.css et n'a pas à en dépendre
396
502
  * pour une transition de couleur. Ce n'est PAS `ease` — le défaut d'un
397
503
  * framework tiers n'a rien à faire dans le système. */
398
- transition: background-color 150ms cubic-bezier(0.2, 0.7, 0.2, 1);
504
+ transition:
505
+ background-color 150ms cubic-bezier(0.2, 0.7, 0.2, 1),
506
+ border-color 150ms cubic-bezier(0.2, 0.7, 0.2, 1);
507
+ }
508
+
509
+ .zv-switch-hit:hover .zv-switch {
510
+ border-color: var(--accent-line);
399
511
  }
400
512
 
401
513
  .zv-switch--on {
514
+ border-color: var(--accent);
402
515
  background: var(--accent);
403
516
  }
404
517
 
518
+ .zv-switch-hit:hover .zv-switch--on {
519
+ border-color: var(--accent-deep);
520
+ background: var(--accent-deep);
521
+ }
522
+
523
+ /* Bouton CARRÉ : le rond ici n'est pas une forme porteuse de sens (ce que
524
+ * sont la pastille d'état ou le radio), c'était un style — il tombe avec les
525
+ * autres rayons. Éteint il est gris comme un contrôle inactif, allumé il
526
+ * passe en blanc sur l'aplat indigo. */
405
527
  .zv-switch__knob {
406
528
  position: absolute;
407
529
  inset-block-start: 2px;
408
530
  inset-inline-start: 2px;
409
- width: 12px;
410
- height: 12px;
411
- border-radius: 9999px;
412
- background: var(--white);
413
- transition: inset-inline-start 150ms cubic-bezier(0.2, 0.7, 0.2, 1);
531
+ width: 14px;
532
+ height: 14px;
533
+ border-radius: 0;
534
+ background: var(--label);
535
+ transition:
536
+ inset-inline-start 150ms cubic-bezier(0.2, 0.7, 0.2, 1),
537
+ background-color 150ms cubic-bezier(0.2, 0.7, 0.2, 1);
414
538
  }
415
539
 
416
540
  .zv-switch--on .zv-switch__knob {
417
- inset-inline-start: 14px;
541
+ inset-inline-start: 18px;
542
+ background: var(--white);
418
543
  }
419
544
 
420
545
  /* Cible tactile portée par une enveloppe : agrandir la piste elle-même
@@ -429,6 +429,15 @@
429
429
  background: var(--hairline);
430
430
  }
431
431
 
432
+ /* ⚠️ Option SURVOLÉE AU CLAVIER — ajoutée le 25/08/2026 avec la sélection
433
+ * dessinée. Ce n'est ni :hover (la souris n'est pas dessus) ni :focus (le
434
+ * focus reste sur le déclencheur, c'est aria-activedescendant qui porte
435
+ * l'annonce). Sans cette classe, parcourir la liste aux flèches ne se voit
436
+ * pas : le lecteur d'écran suit, l'œil non. */
437
+ .zv-menu__item--survol {
438
+ background: var(--hairline);
439
+ }
440
+
432
441
  .zv-menu__item:focus-visible {
433
442
  outline-offset: -2px;
434
443
  }
package/src/tokens.css CHANGED
@@ -74,11 +74,27 @@
74
74
  --lead: 18px;
75
75
  --body: 15px;
76
76
 
77
- /* Rayons */
78
- --r-btn: 5px;
79
- --r-pill: 3px;
80
- --r-panel: 10px;
81
- --r-card: 16px;
77
+ /* Rayons — ARBITRÉS À ZÉRO le 25/08/2026.
78
+ *
79
+ * La DA a tranché sur pièces : « très carré, ça fait pro et inspire
80
+ * confiance » ; le rond disait « flexible, jovial », qui n'est pas ce que
81
+ * vend Zevra. Le relevé donnait 5/3/10/16px, hérités d'un état antérieur
82
+ * de la charte — et déjà contredits sur place, puisque les champs de
83
+ * saisie, les étiquettes et les pastilles d'initiales étaient à angle vif
84
+ * depuis le premier jour. Les quatre tokens s'alignent sur eux.
85
+ *
86
+ * ⚠️ Les tokens RESTENT, à zéro : ils nomment quatre intentions
87
+ * distinctes (action, pastille, panneau flottant, carte). Le jour où la DA
88
+ * veut rendre de la rondeur à l'une d'elles, c'est une ligne ici — pas une
89
+ * chasse aux `border-radius` dans douze fichiers.
90
+ *
91
+ * ⚠️ Ce qui n'est PAS concerné : le rond de forme, qui porte du sens et
92
+ * non du style — pastille d'état, bouton d'interrupteur, radio, halo. Ils
93
+ * écrivent leur `border-radius` en clair, sans passer par un token. */
94
+ --r-btn: 0;
95
+ --r-pill: 0;
96
+ --r-panel: 0;
97
+ --r-card: 0;
82
98
 
83
99
  /* Ombres */
84
100
  --shadow-card: 0 2px 10px rgba(11, 16, 32, 0.05);
package/tokens/index.js CHANGED
@@ -99,11 +99,17 @@ export const mesures = {
99
99
  specFilet: '56px',
100
100
  }
101
101
 
102
+ /** Rayons — À ZÉRO depuis l'arbitrage DA du 25/08/2026 (« très carré, ça
103
+ * fait pro et inspire confiance »). Les quatre clés restent : elles nomment
104
+ * quatre intentions distinctes, et rendre de la rondeur à l'une d'elles
105
+ * doit rester une ligne à changer, pas une chasse dans douze fichiers.
106
+ * Le rond de FORME — pastille d'état, bouton d'interrupteur, radio, halo —
107
+ * ne passe pas par ici : il porte du sens, pas du style. */
102
108
  export const rayons = {
103
- btn: '5px',
104
- pill: '3px',
105
- panel: '10px',
106
- card: '16px',
109
+ btn: '0',
110
+ pill: '0',
111
+ panel: '0',
112
+ card: '0',
107
113
  }
108
114
 
109
115
  export const ombres = {