@zevra/ui 0.1.0 → 0.2.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/src/forms.css ADDED
@@ -0,0 +1,815 @@
1
+ /* Zevra UI — formulaires : champ, recherche, zone de texte, sélection,
2
+ * case/radio, interrupteur, puces de filtre, zone de dépôt.
3
+ *
4
+ * DÉPEND de tokens.css (toutes les valeurs) et de base.css (reset
5
+ * box-sizing, anneau :focus-visible). RIEN D'AUTRE : ce module ne lit ni
6
+ * layout.css, ni components.css, ni motion.css. C'est volontaire — une app
7
+ * purement applicative n'a aucune raison de charger la mise en page de la
8
+ * vitrine.
9
+ *
10
+ * Ce qu'il NE porte PAS :
11
+ * · le menu déroulé par .zv-select — il appartient à navigation.css
12
+ * (.zv-menu) ; ce module pose l'ancre (.zv-select-anchor) et le
13
+ * déclencheur, rien de plus ;
14
+ * · le bouton d'envoi d'un formulaire — c'est .zv-btn (components.css) ;
15
+ * · l'alerte de bloc (role="alert") — module de retours.
16
+ *
17
+ * PROVENANCE. Relevé dans l'application Mémoire (production, mêmes tokens de
18
+ * charte) sur six écrans : /connexion, /chat, /repertoire, /bibliotheque,
19
+ * /logs, /import. Deux écrans de plus ont été rouverts APRÈS le premier
20
+ * relevé, parce qu'ils contiennent des motifs que les six premiers n'ont
21
+ * pas : /admin (add-member-form.tsx, billing-card.tsx) pour les deux seuls
22
+ * <select> natifs et le seul message d'erreur au niveau du champ. Restent
23
+ * NON relevés : /tableau-de-bord et /documents/[slug]. Chaque section dit ce
24
+ * qui est relevé et ce qui est dérivé ; rien n'est comblé en silence.
25
+ *
26
+ * VOCABULAIRE. Le relevé emploie huit paliers de texte dans 5,5 px et une
27
+ * demi-douzaine de gris intermédiaires ; tokens.css les regroupe. Les
28
+ * rabattements appliqués ici, une fois pour toutes :
29
+ * #4A5468 et #727C93 → var(--ink-2) / var(--muted)
30
+ * #9AA3B5 (placeholder) → var(--label)
31
+ * #C9CEDD (filet ferme) → var(--accent-line)
32
+ * #FDFDFF (survol papier) → var(--panel)
33
+ * 13/14/12,5/11,5 px → var(--app) / var(--app-lg) / var(--app-sm)
34
+ * 11/10,5/9,5/8,5 px mono → var(--mono-lg) / var(--mono) / var(--mono-sm)
35
+ * Deux d'entre eux font disparaître une nuance du relevé : c'est dit sur
36
+ * place, à l'endroit exact où la nuance tombe.
37
+ *
38
+ * POINT DE RUPTURE : 768px, comme les autres modules. Les sources basculent
39
+ * à 1024px (`lg` de Tailwind) : les valeurs sont reprises telles quelles,
40
+ * seul le seuil change.
41
+ */
42
+
43
+ /* ─── Groupe de champ ───────────────────────────────────────────────────
44
+ * Intitulé au-dessus, contrôle, puis aide ou erreur. Relevé sur les
45
+ * formulaires de connexion (login-card.tsx:16-18, 163-200 ;
46
+ * reset-password-card.tsx:102-140) et confirmé sur /admin
47
+ * (add-member-form.tsx). L'écart intitulé → contrôle vaut 6px et non 8 :
48
+ * c'est le `mt-[6px]` du champ, pas une estimation. */
49
+
50
+ .zv-field {
51
+ display: flex;
52
+ flex-direction: column;
53
+ gap: 6px;
54
+ /* Sans min-width:0, un champ placé dans une ligne flex refuse de rétrécir
55
+ * sous la largeur de son placeholder et fait déborder la page. */
56
+ min-width: 0;
57
+ }
58
+
59
+ /* Ligne d'intitulé quand un lien l'accompagne (« Mot de passe oublié ? ») :
60
+ * les deux se lisent sur la même ligne de base plutôt que de s'empiler.
61
+ * Gouttière NULLE ici : le relevé ferme l'écart au-dessus du seuil
62
+ * (login-card.tsx:180, `gap-3 … lg:gap-0`) et laisse space-between faire
63
+ * tout le travail. Les 12px sont une valeur MOBILE, ils sont en fin de
64
+ * fichier. */
65
+ .zv-field__head {
66
+ display: flex;
67
+ align-items: baseline;
68
+ justify-content: space-between;
69
+ gap: 0;
70
+ }
71
+
72
+ /* Les capitales sont LITTÉRALES dans le JSX de Mémoire. Elles passent en CSS
73
+ * ici : recopiées dans le contenu, elles seraient épelées lettre par lettre
74
+ * par un lecteur d'écran. */
75
+ .zv-field__label {
76
+ display: block;
77
+ font-family: var(--font-mono);
78
+ font-size: var(--mono);
79
+ font-weight: 600;
80
+ letter-spacing: 0.12em;
81
+ text-transform: uppercase;
82
+ color: var(--label);
83
+ }
84
+
85
+ .zv-field__help {
86
+ font-size: var(--app-sm);
87
+ color: var(--muted);
88
+ }
89
+
90
+ /* Le message d'erreur AU NIVEAU DU CHAMP existe — une seule fois dans toute
91
+ * l'application, hors des six écrans du relevé initial
92
+ * (add-member-form.tsx:94). Ses valeurs propres (taille, graisse, écart)
93
+ * n'ont PAS été relevées : celles ci-dessous sont dérivées du reste du
94
+ * groupe de champ et de la teinte de l'alerte de bloc. Aucune couleur
95
+ * nouvelle n'est introduite. */
96
+ .zv-field__err {
97
+ font-size: var(--app-sm);
98
+ font-weight: 500;
99
+ color: var(--err);
100
+ }
101
+
102
+ /* ─── Champ de saisie ───────────────────────────────────────────────────
103
+ * Référence : le champ des formulaires de connexion, seul <input> bordé de
104
+ * l'application (login-card.tsx:18), au motif strictement identique dans
105
+ * /admin. Les 10px/13px de l'inventaire venaient du CADRE de recherche, pas
106
+ * d'un champ : ils restent sur .zv-search. */
107
+
108
+ .zv-input {
109
+ width: 100%;
110
+ min-width: 0;
111
+ border: 1px solid var(--line);
112
+ /* Angle vif : aucun rounded-* sur un seul champ des huit écrans. Posé
113
+ * explicitement, sinon Safari arrondit les champs de lui-même. */
114
+ border-radius: 0;
115
+ background: var(--white);
116
+ padding: 9px 12px;
117
+ /* Un <input> n'hérite pas de la police du document : sans cette ligne, le
118
+ * champ repasse en police système et jure avec le reste de la page. */
119
+ font-family: var(--font-text);
120
+ font-size: var(--app);
121
+ color: var(--ink);
122
+ }
123
+
124
+ .zv-input::placeholder {
125
+ color: var(--label);
126
+ }
127
+
128
+ /* Relevé (`focus:border-accent-line`) : c'est :focus et non :focus-visible,
129
+ * la bordure réagit donc aussi à la souris. L'anneau clavier de base.css
130
+ * s'y AJOUTE — on ne reprend PAS le `input { outline: none }` de la prod,
131
+ * qui laissait l'application inutilisable au clavier. */
132
+ .zv-input:focus {
133
+ border-color: var(--accent-line);
134
+ }
135
+
136
+ /* Identifiant d'organisation : même champ, chiffres et tirets alignés
137
+ * (login-card.tsx:145). */
138
+ .zv-input--mono {
139
+ font-family: var(--font-mono);
140
+ }
141
+
142
+ /* <select> NATIF, habillé comme le champ. C'est le motif de sélection
143
+ * accessible de l'application, relevé deux fois (add-member-form.tsx:82,
144
+ * billing-card.tsx:135) avec exactement les valeurs de .zv-input —
145
+ * `border-line bg-white px-3 py-[9px] text-[13px] text-ink`. D'où un simple
146
+ * modificateur : tout est déjà là.
147
+ *
148
+ * L'`appearance` n'est PAS neutralisée : la flèche, l'ouverture au clavier,
149
+ * la liste native et son annonce viennent du navigateur. La remplacer
150
+ * imposerait de redessiner tout cela, et le relevé ne le fait pas non plus.
151
+ * À ne pas confondre avec .zv-select, qui est un déclencheur de menu maison
152
+ * — voir plus bas. */
153
+ .zv-input--select {
154
+ cursor: pointer;
155
+ }
156
+
157
+ /* Champ nu : ni fond ni filet propres, c'est le CADRE qui l'entoure qui fait
158
+ * le champ (composeur du chat). Lui redonner une bordure dessinerait deux
159
+ * cadres imbriqués. */
160
+ .zv-input--bare {
161
+ flex: 1 1 0%;
162
+ border: none;
163
+ background: transparent;
164
+ padding: 2px 0;
165
+ font-size: var(--app-lg);
166
+ }
167
+
168
+ /* ─── Recherche ─────────────────────────────────────────────────────────
169
+ * Cadre en flex, loupe au filet, saisie sans bordure propre. Deux relevés
170
+ * identiques (repertoire-client.tsx:59-78, bibliotheque-client.tsx:185-204).
171
+ *
172
+ * La loupe est un SVG dans le balisage, pas un fond CSS : viewBox 0 0 13 13,
173
+ * fill="none", stroke-width="1.5",
174
+ * <circle cx="5.5" cy="5.5" r="4"/><line x1="8.6" y1="8.6" x2="12" y2="12"/> */
175
+
176
+ .zv-search {
177
+ display: flex;
178
+ width: auto;
179
+ min-width: 240px;
180
+ flex: 1 1 0%;
181
+ align-items: center;
182
+ gap: 9px;
183
+ border: 1px solid var(--line);
184
+ background: var(--white);
185
+ padding-inline: 13px;
186
+ }
187
+
188
+ /* DÉRIVÉ : la bordure de focus relevée sur .zv-input, portée par le cadre.
189
+ *
190
+ * DEUX MARQUAGES, ASSUMÉS. L'anneau :focus-visible de base.css est un
191
+ * sélecteur universel : au clavier il se dessine AUSSI autour du champ nu,
192
+ * à l'intérieur du cadre, en plus de ce filet. On le garde tel quel plutôt
193
+ * que de trancher l'un des deux :
194
+ * · supprimer l'anneau (outline:none) rendrait le champ nu invisible au
195
+ * clavier — c'est exactement la faute de la production ;
196
+ * · supprimer ce filet priverait la souris de tout retour, alors que le
197
+ * relevé lui en donne un sur les champs bordés.
198
+ * La redondance coûte moins cher que l'une ou l'autre perte. */
199
+ .zv-search:focus-within {
200
+ border-color: var(--accent-line);
201
+ }
202
+
203
+ .zv-search__icon {
204
+ flex: none;
205
+ width: 13px;
206
+ height: 13px;
207
+ /* Même gris que le placeholder : la loupe est une invite, pas une donnée. */
208
+ stroke: var(--label);
209
+ }
210
+
211
+ .zv-search__input {
212
+ min-width: 0;
213
+ flex: 1 1 0%;
214
+ border: none;
215
+ background: transparent;
216
+ padding-block: 10px;
217
+ font-family: var(--font-text);
218
+ font-size: var(--app);
219
+ color: var(--ink);
220
+ }
221
+
222
+ .zv-search__input::placeholder {
223
+ color: var(--label);
224
+ }
225
+
226
+ /* ─── Zone de texte ─────────────────────────────────────────────────────
227
+ * DÉCISION, PAS UN RELEVÉ : il n'existe AUCUN <textarea> dans l'application,
228
+ * sur aucun des huit écrans ouverts. Le composeur du chat est un <input> où
229
+ * Entrée envoie (chat-client.tsx:365). Le bloc ci-dessous est dérivé mot à
230
+ * mot de .zv-input ; seules min-height et resize s'y ajoutent — sans elles
231
+ * le champ se rend sur une ligne et le composant n'existe pas. min-height
232
+ * est la SEULE valeur de ce module sans aucune source, dans aucun écran.
233
+ *
234
+ * Ne PAS remplacer l'input du composeur par ce bloc : ce serait un
235
+ * changement de comportement (Entrée n'enverrait plus). */
236
+
237
+ .zv-textarea {
238
+ width: 100%;
239
+ min-width: 0;
240
+ min-height: 96px; /* sans source — voir ci-dessus */
241
+ border: 1px solid var(--line);
242
+ border-radius: 0;
243
+ background: var(--white);
244
+ padding: 9px 12px;
245
+ font-family: var(--font-text);
246
+ font-size: var(--app);
247
+ /* 1.6 : l'interligne du corps de texte du paquet (base.css, .zv-body). */
248
+ line-height: 1.6;
249
+ color: var(--ink);
250
+ /* Vertical seul : en libre, l'utilisateur élargit le champ hors de sa
251
+ * colonne et casse la mise en page. */
252
+ resize: vertical;
253
+ }
254
+
255
+ .zv-textarea::placeholder {
256
+ color: var(--label);
257
+ }
258
+
259
+ .zv-textarea:focus {
260
+ border-color: var(--accent-line);
261
+ }
262
+
263
+ /* ─── Déclencheur de sélection (menu maison) ────────────────────────────
264
+ * « PÉRIMÈTRE : Toute la base ▾ » (chat-client.tsx:168-175). Ce n'est pas un
265
+ * <select> : c'est un bouton qui ouvre .zv-menu (navigation.css). Pour un
266
+ * vrai choix dans une liste, préférer .zv-input--select, qui est natif.
267
+ *
268
+ * ANCRAGE — arbitrage à consigner dans DECISIONS.md, les deux modules ne
269
+ * disent pas la même chose. Le relevé donne raison à celui-ci : c'est
270
+ * l'ENVELOPPE qui porte position:relative (`relative inline-block
271
+ * max-w-full`), pas le déclencheur. Ancrer le menu sur .zv-select
272
+ * fonctionnerait par accident, mais casserait son `max-width` calculé sur
273
+ * la fenêtre. D'où .zv-select-anchor, posée ici, à côté de son
274
+ * déclencheur. */
275
+
276
+ .zv-select-anchor {
277
+ position: relative;
278
+ display: inline-block;
279
+ max-width: 100%;
280
+ }
281
+
282
+ .zv-select {
283
+ display: inline-flex;
284
+ align-items: center;
285
+ gap: 7px;
286
+ border: 1px solid var(--line);
287
+ background: var(--white);
288
+ padding: 6px 11px;
289
+ font-family: var(--font-mono);
290
+ font-size: var(--mono);
291
+ font-weight: 600;
292
+ letter-spacing: 0.1em;
293
+ text-transform: uppercase;
294
+ /* La valeur courante peut être longue : elle sort du cadre plutôt que de
295
+ * casser la pastille en deux lignes. */
296
+ white-space: nowrap;
297
+ color: var(--ink-2);
298
+ cursor: pointer;
299
+ }
300
+
301
+ .zv-select:hover {
302
+ border-color: var(--accent-line);
303
+ }
304
+
305
+ /* La valeur choisie, seule partie mise en avant du déclencheur. */
306
+ .zv-select strong {
307
+ font-weight: 700;
308
+ color: var(--accent);
309
+ }
310
+
311
+ /* ▾ (U+25BE) posé dans le balisage. */
312
+ .zv-select__chevron {
313
+ font-size: var(--mono-sm);
314
+ color: var(--label);
315
+ }
316
+
317
+ /* ─── Case à cocher et bouton radio ─────────────────────────────────────
318
+ * DÉRIVÉS INTÉGRALEMENT, ET ILS DOIVENT ÊTRE LUS COMME TELS : il n'existe
319
+ * zéro <input type="checkbox"> et zéro <input type="radio"> dans toute
320
+ * l'application — la sélection s'y fait toujours par .zv-seg ou .zv-switch.
321
+ * Le bloc ci-dessous réemploie la grammaire déjà en production (carré au
322
+ * filet --line sur --white, coché en aplat --accent, rond des pastilles)
323
+ * sans introduire de valeur de charte nouvelle ; ses DIMENSIONS, elles
324
+ * (16px, taille du glyphe, épaisseur de l'anneau intérieur), n'ont aucune
325
+ * source. Le jour où un vrai écran en pose, c'est lui qui tranche.
326
+ *
327
+ * ⚠️ .zv-check est DÉJÀ PRIS par la pastille de validation
328
+ * (components.css:482) — d'où .zv-check-box.
329
+ *
330
+ * Contrôles natifs volontairement conservés (appearance:none seulement) : le
331
+ * clavier, :focus-visible, [disabled] et l'annonce au lecteur d'écran
332
+ * viennent alors du navigateur, pas d'un <div> à réoutiller. */
333
+
334
+ .zv-check-box,
335
+ .zv-radio {
336
+ appearance: none;
337
+ flex: none;
338
+ width: 16px;
339
+ height: 16px;
340
+ margin: 0;
341
+ border: 1px solid var(--line);
342
+ border-radius: 0;
343
+ background: var(--white);
344
+ cursor: pointer;
345
+ }
346
+
347
+ .zv-radio {
348
+ border-radius: 9999px;
349
+ }
350
+
351
+ .zv-check-box:hover,
352
+ .zv-radio:hover {
353
+ border-color: var(--accent-line);
354
+ }
355
+
356
+ /* Le blanc du glyphe est écrit en clair (%23fff) : une data-URI ne lit pas
357
+ * les variables CSS. Valeur strictement égale à --white. */
358
+ .zv-check-box:checked {
359
+ border-color: var(--accent);
360
+ background-color: var(--accent);
361
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none' stroke='%23fff' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='2.5,6.5 5,9 9.5,3.5'/%3E%3C/svg%3E");
362
+ background-position: center;
363
+ background-repeat: no-repeat;
364
+ background-size: 12px 12px;
365
+ }
366
+
367
+ /* Le point du radio est creusé dans l'aplat par un anneau blanc interne :
368
+ * un pseudo-élément sur un <input> n'est pas fiable d'un moteur à l'autre.
369
+ * Ce box-shadow est une géométrie, pas une ombre de charte — d'où l'absence
370
+ * de token. */
371
+ .zv-radio:checked {
372
+ border-color: var(--accent);
373
+ background-color: var(--accent);
374
+ box-shadow: inset 0 0 0 4px var(--white);
375
+ }
376
+
377
+ /* ─── Interrupteur ──────────────────────────────────────────────────────
378
+ * Ligne de réglage des dossiers (logs-client.tsx:267-293). Piste
379
+ * RECTANGULAIRE, bouton rond : relevé tel quel, ne pas arrondir la piste.
380
+ *
381
+ * ⚠️ RABATTEMENT VISIBLE : la piste éteinte était un gris ferme (#C9CEDD),
382
+ * rabattu sur --accent-line, qui est bleuté. L'état éteint prend donc une
383
+ * teinte indigo très pâle. La distinction éteint/allumé tient quand même —
384
+ * elle est portée par l'aplat plein et par le libellé accolé, pas par cette
385
+ * nuance-là. */
386
+
387
+ .zv-switch {
388
+ position: relative;
389
+ display: inline-block;
390
+ width: 28px;
391
+ height: 16px;
392
+ flex: none;
393
+ background: var(--accent-line);
394
+ /* La courbe est celle du paquet (motion.css, `--zv-easing`), recopiée en
395
+ * clair : forms.css ne dépend pas de motion.css et n'a pas à en dépendre
396
+ * pour une transition de couleur. Ce n'est PAS `ease` — le défaut d'un
397
+ * framework tiers n'a rien à faire dans le système. */
398
+ transition: background-color 150ms cubic-bezier(0.2, 0.7, 0.2, 1);
399
+ }
400
+
401
+ .zv-switch--on {
402
+ background: var(--accent);
403
+ }
404
+
405
+ .zv-switch__knob {
406
+ position: absolute;
407
+ inset-block-start: 2px;
408
+ 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);
414
+ }
415
+
416
+ .zv-switch--on .zv-switch__knob {
417
+ inset-inline-start: 14px;
418
+ }
419
+
420
+ /* Cible tactile portée par une enveloppe : agrandir la piste elle-même
421
+ * décalerait toute la ligne de réglage (cf. mobile en fin de fichier). */
422
+ .zv-switch-hit {
423
+ display: flex;
424
+ /* La source place la gouttière de 7px sur une enveloppe PARENTE, et
425
+ * n'entoure que la piste de sa zone tactile (logs-client.tsx:267-273).
426
+ * Elle est ramenée ici : cette classe enveloppe le contrôle ET son
427
+ * libellé, ce qui rend le texte cliquable — un <label> qui n'entoure que
428
+ * l'interrupteur laisse une cible de 28px là où il en faut 40. Sans la
429
+ * gouttière, le libellé vient coller à la piste. */
430
+ align-items: center;
431
+ gap: 7px;
432
+ cursor: pointer;
433
+ }
434
+
435
+ /* Le libellé accolé change avec l'état : c'est lui qui porte le sens, la
436
+ * piste seule ne dit pas ce qui est activé. */
437
+ .zv-switch__text {
438
+ font-family: var(--font-mono);
439
+ font-size: var(--mono);
440
+ font-weight: 600;
441
+ color: var(--label);
442
+ }
443
+
444
+ .zv-switch__text--on {
445
+ color: var(--accent);
446
+ }
447
+
448
+ /* ─── Puces de filtre ───────────────────────────────────────────────────
449
+ * Le seul mécanisme de filtrage de l'application (bibliotheque-client.tsx:
450
+ * 143-180, repertoire-client.tsx:79-94, logs-client.tsx:193-207).
451
+ *
452
+ * ⚠️ LA SOURCE DIVERGE, ET C'EST TRANCHÉ ICI :
453
+ * · gouttière du groupe — bibliothèque 5px, répertoire et logs 6px. On
454
+ * retient 6px : deux écrans sur trois, et c'est la valeur de .zv-tabs
455
+ * (navigation.css), qui héberge les mêmes puces. Deux conteneurs des
456
+ * mêmes puces avec deux écarts différents, c'est un défaut, pas une
457
+ * nuance.
458
+ * · taille et densité — bibliothèque descend à 11px/4-9px au-dessus du
459
+ * seuil, le répertoire reste à 11px/8-12px partout, en graisse 600. On
460
+ * retient la puce DENSE de la bibliothèque au-dessus du seuil, et la
461
+ * graisse 500 (la 600 est réservée à l'état sélectionné, sinon
462
+ * sélectionné et non sélectionné ne se distinguent plus que par la
463
+ * couleur).
464
+ * · corps — 11 et 12px se rabattent tous les deux sur --app-sm (12px) :
465
+ * l'écart d'un pixel entre les deux écrans disparaît, et la variante
466
+ * mobile de la bibliothèque avec lui. C'est le prix de l'échelle
467
+ * rationalisée, assumé.
468
+ *
469
+ * ⚠️ L'état sélectionné n'est porté par AUCUN aria-pressed en production :
470
+ * il est invisible pour un lecteur d'écran. Le paquet impose <button
471
+ * aria-pressed="true|false"> — et le style suit l'attribut autant que la
472
+ * classe, pour que le balisage correct ne coûte rien. */
473
+
474
+ .zv-seg-group {
475
+ display: flex;
476
+ flex-wrap: wrap;
477
+ gap: 6px;
478
+ }
479
+
480
+ /* ⚠️ HORS RELEVÉ. Les quatre rangées de puces du dépôt passent TOUTES à la
481
+ * ligne ; le motif « nowrap + défilement » n'apparaît que sur des CADRES
482
+ * (tableau, bande de trente jours), jamais sur une rangée de puces.
483
+ * Cette variante n'existe que parce que la règle du projet interdit tout
484
+ * défilement horizontal de PAGE : une rangée qu'on refuse d'enrouler doit
485
+ * alors déborder dans son propre cadre. À poser explicitement, jamais par
486
+ * défaut. */
487
+ .zv-seg-group--rail {
488
+ flex-wrap: nowrap;
489
+ overflow-x: auto;
490
+ }
491
+
492
+ .zv-seg {
493
+ border: 1px solid var(--line);
494
+ border-radius: 0;
495
+ background: var(--white);
496
+ padding: 4px 9px;
497
+ font-family: var(--font-text);
498
+ font-size: var(--app-sm);
499
+ font-weight: 500;
500
+ white-space: nowrap;
501
+ color: var(--ink-2);
502
+ cursor: pointer;
503
+ }
504
+
505
+ /* La rangée de puces vit souvent dans un rail qui défile horizontalement
506
+ * (.zv-tabs--rail, navigation.css). L'anneau de base.css y serait rogné aux
507
+ * deux bords. On ne le REDÉFINIT pas — on déplace seulement son tracé à
508
+ * l'intérieur de la puce, où rien ne le coupe. */
509
+ .zv-seg:focus-visible {
510
+ outline-offset: -2px;
511
+ }
512
+
513
+ /* Sélection DOUCE (bibliothèque), la seule livrée par défaut : elle
514
+ * n'ajoute pas un second aplat indigo à l'écran, là où la charte n'autorise
515
+ * qu'un seul plein par page. */
516
+ .zv-seg--on,
517
+ .zv-seg[aria-pressed="true"] {
518
+ border-color: var(--accent-line);
519
+ background: var(--accent-soft);
520
+ font-weight: 600;
521
+ color: var(--accent);
522
+ }
523
+
524
+ /* Sélection PLEINE (répertoire, logs) : OPT-IN, à poser explicitement, et
525
+ * BORNÉE — un seul groupe plein par page, jamais sur une page qui porte
526
+ * déjà un bouton plein. Une page de filtres à plusieurs groupes (la
527
+ * bibliothèque en a deux) afficherait sinon autant d'aplats indigo que de
528
+ * groupes, simultanément : c'est le seul mécanisme du paquet capable de
529
+ * multiplier le plein sans le dire. */
530
+ .zv-seg--on-solid,
531
+ .zv-seg--on-solid[aria-pressed="true"] {
532
+ border-color: var(--accent);
533
+ background: var(--accent);
534
+ color: var(--white);
535
+ }
536
+
537
+ .zv-seg__count {
538
+ margin-inline-start: 6px;
539
+ font-family: var(--font-mono);
540
+ font-size: var(--mono);
541
+ color: var(--label);
542
+ }
543
+
544
+ /* Niveaux de log : libellés de longueur inégale (INFO, WARN, ERROR) centrés
545
+ * pour que la rangée ne danse pas d'une puce à l'autre. */
546
+ .zv-seg--mono {
547
+ justify-content: center;
548
+ padding: 4px 8px;
549
+ font-family: var(--font-mono);
550
+ font-size: var(--mono-sm);
551
+ font-weight: 600;
552
+ letter-spacing: 0.06em;
553
+ }
554
+
555
+ /* ─── Zone de dépôt ─────────────────────────────────────────────────────
556
+ * Elle tient lieu d'action principale de l'écran d'import : il n'y a aucun
557
+ * bouton plein sur cette page (import-client.tsx:144-202).
558
+ *
559
+ * ⚠️ En production c'est un <div onClick> sans role ni tabIndex : la page
560
+ * d'import est inatteignable au clavier. Le paquet impose role="button" +
561
+ * tabindex="0" (l'anneau de base.css suit alors tout seul).
562
+ *
563
+ * L'icône est un SVG du balisage (import-client.tsx:159-166) : viewBox
564
+ * 0 0 30 30, fill="none", stroke="var(--accent)", stroke-width="1.5",
565
+ * stroke-linecap="round" — et PAS de stroke-linejoin, le tracé n'a qu'un
566
+ * angle franc et la source ne le pose pas :
567
+ * <line x1="15" y1="4" x2="15" y2="18"/>
568
+ * <polyline points="9.5,12.5 15,18 20.5,12.5"/>
569
+ * <path d="M5 21 v3 a2 2 0 0 0 2 2 h16 a2 2 0 0 0 2-2 v-3"/> */
570
+
571
+ .zv-drop {
572
+ display: flex;
573
+ flex-direction: column;
574
+ align-items: center;
575
+ gap: 10px;
576
+ /* 1,5px : l'épaisseur du pointillé est celle du tracé de l'icône, les deux
577
+ * se répondent. */
578
+ border: 1.5px dashed var(--accent-line);
579
+ background: var(--white);
580
+ padding: 38px 24px;
581
+ cursor: pointer;
582
+ }
583
+
584
+ /* Survol et survol de dépôt rendus à l'identique : la zone répond pareil que
585
+ * le fichier soit traîné ou non, sinon le retour visuel se contredit.
586
+ *
587
+ * ⚠️ RABATTEMENT VISIBLE : le fond de survol du relevé (#FDFDFF) tombe sur
588
+ * --panel, à peine distinct du --white de repos. Le fond ne SIGNALE donc
589
+ * plus rien — le retour visuel repose entièrement sur le filet qui passe à
590
+ * --accent. Ne pas le supprimer en croyant que le fond compense. */
591
+ .zv-drop:hover,
592
+ .zv-drop.is-dragging {
593
+ border-color: var(--accent);
594
+ background: var(--panel);
595
+ }
596
+
597
+ /* Stockage indisponible : la zone reste visible mais inerte — la masquer
598
+ * ferait croire à une page cassée. */
599
+ .zv-drop--disabled {
600
+ cursor: not-allowed;
601
+ border-color: var(--line);
602
+ background: var(--panel);
603
+ opacity: 0.6;
604
+ }
605
+
606
+ .zv-drop--disabled:hover {
607
+ border-color: var(--line);
608
+ background: var(--panel);
609
+ }
610
+
611
+ /* 15px : c'est une phrase, pas un libellé d'interface — d'où le palier de
612
+ * corps de texte (--body) plutôt que l'échelle applicative, qui s'arrête à
613
+ * 14px. */
614
+ .zv-drop__title {
615
+ font-size: var(--body);
616
+ font-weight: 500;
617
+ color: var(--ink);
618
+ text-align: center;
619
+ }
620
+
621
+ .zv-drop__hint {
622
+ font-size: var(--app-sm);
623
+ color: var(--muted);
624
+ }
625
+
626
+ .zv-drop__hint a,
627
+ .zv-drop__hint strong {
628
+ font-weight: 600;
629
+ color: var(--accent);
630
+ text-decoration: underline;
631
+ }
632
+
633
+ /* Sous le seuil il n'y a pas de glisser-déposer : le lien souligné cède la
634
+ * place à une vraie cible tactile. LES DEUX NŒUDS SONT POSÉS EN PERMANENCE
635
+ * DANS LE BALISAGE et c'est CE fichier qui les échange, au seul seuil du
636
+ * paquet. Ne pas y ajouter .zv-only-desktop / .zv-only-mobile (layout.css) :
637
+ * les deux mécanismes se disputeraient la même déclaration `display`, et le
638
+ * module tirerait la mise en page de la vitrine derrière lui. */
639
+ .zv-drop__browse {
640
+ display: none;
641
+ }
642
+
643
+ .zv-drop__spec {
644
+ font-family: var(--font-mono);
645
+ font-size: var(--mono);
646
+ font-weight: 500;
647
+ color: var(--label);
648
+ }
649
+
650
+ /* Le vrai <input type="file"> est retiré du flux, la zone entière lui sert
651
+ * de déclencheur. Nommé en élément de .zv-drop, et non `.zv-file` : le
652
+ * paquet a par ailleurs une vignette de format (.zv-filetype, data.css) et
653
+ * les deux se liraient comme un seul bloc.
654
+ *
655
+ * Conséquence du retrait du flux : l'input n'est PAS focusable — d'où le
656
+ * role/tabindex exigés sur .zv-drop ci-dessus. Remettre value='' après
657
+ * chaque sélection, sinon redéposer le même fichier ne déclenche rien. */
658
+ .zv-drop__input {
659
+ display: none;
660
+ }
661
+
662
+ /* ─── États transverses ─────────────────────────────────────────────────
663
+ * HORS RELEVÉ pour l'essentiel, et assumé : la production ne désactive aucun
664
+ * champ (seul le bouton d'envoi passe en `cursor-wait; opacity:.7` pendant
665
+ * la requête) et ne signale l'erreur au niveau du champ qu'à un seul
666
+ * endroit. Un système livré sans ces deux états oblige chaque app à les
667
+ * réinventer, chacune à sa façon. Les valeurs sont empruntées, sans
668
+ * exception, à ce qui existe déjà : le traitement désactivé de
669
+ * .zv-drop--disabled et le filet de l'alerte d'erreur. */
670
+
671
+ .zv-input[disabled],
672
+ .zv-textarea[disabled],
673
+ .zv-select[disabled],
674
+ .zv-seg[disabled],
675
+ .zv-check-box[disabled],
676
+ .zv-radio[disabled] {
677
+ cursor: not-allowed;
678
+ border-color: var(--line);
679
+ background: var(--panel);
680
+ opacity: 0.6;
681
+ }
682
+
683
+ /* Le survol ne doit pas rattraper un contrôle désactivé : sans ces règles,
684
+ * .zv-seg:hover et .zv-select:hover repeignent le filet et le contrôle a
685
+ * l'air actif. */
686
+ .zv-select[disabled]:hover,
687
+ .zv-check-box[disabled]:hover,
688
+ .zv-radio[disabled]:hover {
689
+ border-color: var(--line);
690
+ }
691
+
692
+ /* Champ en erreur : seul le filet change. Un fond rouge sur un champ à
693
+ * remplir rendrait la saisie illisible — l'aplat --err-soft reste réservé au
694
+ * bandeau d'alerte. Le sélecteur suit l'attribut autant que la classe :
695
+ * aria-invalid est ce qui rend l'erreur audible, il ne doit rien coûter. */
696
+ .zv-field--err .zv-input,
697
+ .zv-field--err .zv-textarea,
698
+ .zv-field--err .zv-select,
699
+ .zv-input[aria-invalid="true"],
700
+ .zv-textarea[aria-invalid="true"] {
701
+ border-color: var(--err-line);
702
+ }
703
+
704
+ /* Le focus l'emporte sur l'erreur : pendant la correction, l'utilisateur doit
705
+ * voir où il est, pas ce qui était faux. */
706
+ .zv-field--err .zv-input:focus,
707
+ .zv-field--err .zv-textarea:focus,
708
+ .zv-input[aria-invalid="true"]:focus,
709
+ .zv-textarea[aria-invalid="true"]:focus {
710
+ border-color: var(--accent-line);
711
+ }
712
+
713
+ /* ─── Mobile ────────────────────────────────────────────────────────────
714
+ * Valeurs relevées aux mêmes endroits, sous le seuil des sources (1024px),
715
+ * ramenées ici au seuil unique du paquet.
716
+ *
717
+ * NOTE : le micro-libellé du groupe de champ ne bouge PAS ici. Le relevé le
718
+ * grossit d'un pixel sous le seuil (9,5 → 10,5) ; les deux valeurs tombent
719
+ * sur --mono. La nuance n'existe plus, et une règle qui ne change rien vaut
720
+ * moins qu'une ligne qui dit pourquoi. */
721
+
722
+ @media (max-width: 767px) {
723
+ /* Le lien qui accompagne l'intitulé devient une cible tactile : la ligne
724
+ * de base ne tient plus quand les deux éléments font 40px de haut, et il
725
+ * leur faut alors une gouttière (gap-3), là où space-between suffisait. */
726
+ .zv-field__head {
727
+ align-items: center;
728
+ gap: 12px;
729
+ }
730
+
731
+ /* 44px, pas --tap (40px) : c'est la valeur des formulaires de connexion et
732
+ * du pavé d'import, les deux seuls endroits où l'on saisit au doigt.
733
+ * 16px : en dessous, Safari iOS zoome à la mise au point du champ et
734
+ * décale toute la page — ce n'est pas un choix typographique, aucun token
735
+ * n'a à porter cette valeur. */
736
+ .zv-input,
737
+ .zv-textarea {
738
+ min-height: 44px;
739
+ font-size: 16px;
740
+ }
741
+
742
+ /* Les <select> de /admin, eux, sont relevés à 40px (`min-h-10 lg:min-h-0`)
743
+ * et non à 44 : un autre écran, une autre main. */
744
+ .zv-input--select {
745
+ min-height: var(--tap);
746
+ }
747
+
748
+ .zv-input--bare {
749
+ min-height: var(--tap);
750
+ font-size: var(--app-lg);
751
+ }
752
+
753
+ /* Le cadre passe pleine largeur. Il n'est PAS figé (`flex: none`) : le
754
+ * relevé lui laisse le `0 1 auto` par défaut, il peut donc encore se
755
+ * comprimer si la ligne qui l'accueille l'exige. */
756
+ .zv-search {
757
+ width: 100%;
758
+ min-width: 0;
759
+ flex: 0 1 auto;
760
+ }
761
+
762
+ .zv-search__input {
763
+ padding-block: 12px;
764
+ }
765
+
766
+ /* Le déclencheur peut passer à la ligne : sa valeur courante est un nom de
767
+ * dossier libre, qui déborderait sinon de l'écran. */
768
+ .zv-select {
769
+ min-height: var(--tap);
770
+ flex-wrap: wrap;
771
+ }
772
+
773
+ /* La puce se desserre pour devenir une cible tactile. Le corps, lui, ne
774
+ * change plus : 11 et 12px se rabattent sur le même palier (voir la
775
+ * section « Puces de filtre »). */
776
+ .zv-seg {
777
+ display: flex;
778
+ align-items: center;
779
+ min-height: var(--tap);
780
+ padding: 8px 12px;
781
+ }
782
+
783
+ /* Cible de 40px sans gonfler la ligne de réglage : la marge négative
784
+ * annule exactement le padding ajouté. */
785
+ .zv-switch-hit {
786
+ margin-block: -12px;
787
+ padding-block: 12px;
788
+ }
789
+
790
+ .zv-drop {
791
+ padding: 32px 16px;
792
+ }
793
+
794
+ /* Pas de glisser-déposer au doigt : le lien souligné est remplacé par une
795
+ * cible pleine largeur. Les deux nœuds appartiennent à ce module, l'échange
796
+ * se fait ici et nulle part ailleurs. */
797
+ .zv-drop__hint {
798
+ display: none;
799
+ }
800
+
801
+ .zv-drop__browse {
802
+ display: flex;
803
+ width: 100%;
804
+ max-width: 280px;
805
+ min-height: 44px;
806
+ align-items: center;
807
+ justify-content: center;
808
+ border: 1px solid var(--accent-line);
809
+ background: var(--accent-soft);
810
+ padding-inline: 16px;
811
+ font-size: var(--app);
812
+ font-weight: 600;
813
+ color: var(--accent);
814
+ }
815
+ }