@zevra/ui 0.4.0 → 0.5.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.
Files changed (82) hide show
  1. package/README.md +64 -1
  2. package/dist/actions.d.ts +41 -0
  3. package/dist/actions.d.ts.map +1 -0
  4. package/dist/actions.js +24 -0
  5. package/dist/actions.js.map +1 -0
  6. package/dist/catalogue.d.ts +30 -0
  7. package/dist/catalogue.d.ts.map +1 -0
  8. package/dist/catalogue.js +830 -0
  9. package/dist/catalogue.js.map +1 -0
  10. package/dist/content.d.ts +135 -0
  11. package/dist/content.d.ts.map +1 -0
  12. package/dist/content.js +85 -0
  13. package/dist/content.js.map +1 -0
  14. package/dist/css-inline.d.ts +11 -0
  15. package/dist/css-inline.d.ts.map +1 -0
  16. package/dist/css-inline.js +13 -0
  17. package/dist/css-inline.js.map +1 -0
  18. package/dist/cx.d.ts +16 -0
  19. package/dist/cx.d.ts.map +1 -0
  20. package/dist/cx.js +20 -0
  21. package/dist/cx.js.map +1 -0
  22. package/dist/data.d.ts +136 -0
  23. package/dist/data.d.ts.map +1 -0
  24. package/dist/data.js +98 -0
  25. package/dist/data.js.map +1 -0
  26. package/dist/feedback.d.ts +88 -0
  27. package/dist/feedback.d.ts.map +1 -0
  28. package/dist/feedback.js +38 -0
  29. package/dist/feedback.js.map +1 -0
  30. package/dist/forms.d.ts +120 -0
  31. package/dist/forms.d.ts.map +1 -0
  32. package/dist/forms.js +60 -0
  33. package/dist/forms.js.map +1 -0
  34. package/dist/icon-data.d.ts +6 -0
  35. package/dist/icon-data.d.ts.map +1 -0
  36. package/dist/icon-data.js +77 -0
  37. package/dist/icon-data.js.map +1 -0
  38. package/dist/icon.d.ts +33 -0
  39. package/dist/icon.d.ts.map +1 -0
  40. package/dist/icon.js +13 -0
  41. package/dist/icon.js.map +1 -0
  42. package/dist/index.d.ts +13 -0
  43. package/dist/index.d.ts.map +1 -0
  44. package/dist/index.js +41 -0
  45. package/dist/index.js.map +1 -0
  46. package/dist/layout.d.ts +166 -0
  47. package/dist/layout.d.ts.map +1 -0
  48. package/dist/layout.js +120 -0
  49. package/dist/layout.js.map +1 -0
  50. package/dist/navigation.d.ts +150 -0
  51. package/dist/navigation.d.ts.map +1 -0
  52. package/dist/navigation.js +104 -0
  53. package/dist/navigation.js.map +1 -0
  54. package/dist/overlays.d.ts +68 -0
  55. package/dist/overlays.d.ts.map +1 -0
  56. package/dist/overlays.js +114 -0
  57. package/dist/overlays.js.map +1 -0
  58. package/dist/surfaces.d.ts +61 -0
  59. package/dist/surfaces.d.ts.map +1 -0
  60. package/dist/surfaces.js +54 -0
  61. package/dist/surfaces.js.map +1 -0
  62. package/dist/typography.d.ts +89 -0
  63. package/dist/typography.d.ts.map +1 -0
  64. package/dist/typography.js +76 -0
  65. package/dist/typography.js.map +1 -0
  66. package/package.json +36 -4
  67. package/react/actions.tsx +138 -0
  68. package/react/catalogue.tsx +1644 -0
  69. package/react/content.tsx +313 -0
  70. package/react/css-inline.ts +14 -0
  71. package/react/cx.ts +20 -0
  72. package/react/data.tsx +300 -0
  73. package/react/feedback.tsx +229 -0
  74. package/react/forms.tsx +383 -0
  75. package/react/icon-data.ts +114 -0
  76. package/react/icon.tsx +84 -0
  77. package/react/index.ts +42 -0
  78. package/react/layout.tsx +347 -0
  79. package/react/navigation.tsx +329 -0
  80. package/react/overlays.tsx +237 -0
  81. package/react/surfaces.tsx +161 -0
  82. package/react/typography.tsx +220 -0
@@ -0,0 +1,237 @@
1
+ 'use client'
2
+
3
+ // Enrobages — voile, tiroir, modale, notification flottante.
4
+ //
5
+ // SEUL fichier du paquet marqué « use client », avec un hook. Tout ce qui vit
6
+ // ici vole le regard à la page : il faut une touche Échap, un piège de focus
7
+ // et un verrou de défilement, et rien de cela ne s'écrit en CSS.
8
+ //
9
+ // ⚠️ Ne PAS écrire `data-open={ouvert}` en React : cela rend `data-open="false"`,
10
+ // que le sélecteur d'attribut du CSS considère comme PRÉSENT — le panneau
11
+ // resterait ouvert. Ces composants emploient la classe `--open`, prévue pour ça.
12
+
13
+ import { useCallback, useEffect, useRef } from 'react'
14
+ import type { HTMLAttributes, ReactNode, RefObject } from 'react'
15
+ import { cx } from './cx.js'
16
+
17
+ type Div = Omit<HTMLAttributes<HTMLDivElement>, 'className'>
18
+
19
+ const FOCUSABLES =
20
+ 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'
21
+
22
+ export interface UseOverlayOptions {
23
+ ouvert: boolean
24
+ onClose: () => void
25
+ /** Le nœud à piéger. Sans lui, pas de piège — mais Échap fonctionne quand
26
+ * même. */
27
+ panneau?: RefObject<HTMLElement | null>
28
+ /** Verrouille le défilement du document. Vrai pour une modale, vrai pour un
29
+ * tiroir mobile ; faux pour ce qui ne recouvre pas la page. */
30
+ verrouille?: boolean
31
+ }
32
+
33
+ /** Échap, piège de focus, restitution du focus, verrou de défilement.
34
+ * Exporté : une app qui dessine son propre enrobage doit pouvoir reprendre
35
+ * ce comportement sans recopier trente lignes qui se trompent une fois sur
36
+ * deux. */
37
+ export function useOverlay({ ouvert, onClose, panneau, verrouille = true }: UseOverlayOptions) {
38
+ // Le nœud qui avait le focus AVANT l'ouverture : c'est à lui qu'il revient
39
+ // à la fermeture. Sans cela, le focus repart au début du document et un
40
+ // utilisateur au clavier perd sa place dans la page.
41
+ const origine = useRef<HTMLElement | null>(null)
42
+
43
+ useEffect(() => {
44
+ if (!ouvert) return
45
+ const cible = panneau?.current
46
+ // ⚠️ Le document du PANNEAU, pas `document` : un enrobage peut vivre dans
47
+ // un autre document que celui du script (planche de composants rendue dans
48
+ // un iframe, fenêtre détachée). Écouter `document` y poserait la touche
49
+ // Échap sur la page hôte — elle fermerait la modale d'à côté et pas
50
+ // celle-ci. Sans panneau, on retombe sur le document courant.
51
+ const docu = cible?.ownerDocument ?? document
52
+ origine.current = docu.activeElement as HTMLElement | null
53
+
54
+ // ⚠️ La valeur PRÉCÉDENTE est restaurée, pas remise à '' : une page qui
55
+ // gérait déjà son propre défilement serait cassée par un reset aveugle.
56
+ const overflowPrecedent = verrouille ? docu.body.style.overflow : null
57
+ if (verrouille) docu.body.style.overflow = 'hidden'
58
+
59
+ const premier = cible?.querySelector<HTMLElement>(FOCUSABLES)
60
+ premier?.focus()
61
+
62
+ function onKey(e: KeyboardEvent) {
63
+ if (e.key === 'Escape') {
64
+ e.stopPropagation()
65
+ onClose()
66
+ return
67
+ }
68
+ if (e.key !== 'Tab' || !cible) return
69
+ const noeuds = Array.from(cible.querySelectorAll<HTMLElement>(FOCUSABLES)).filter(
70
+ (n) => n.offsetParent !== null || n === docu.activeElement,
71
+ )
72
+ if (noeuds.length === 0) return
73
+ const debut = noeuds[0]!
74
+ const fin = noeuds[noeuds.length - 1]!
75
+ // Le cycle est refermé à la main : sans cela, Tab sort du panneau et
76
+ // l'utilisateur se retrouve à parcourir la page cachée derrière le voile.
77
+ if (e.shiftKey && docu.activeElement === debut) {
78
+ e.preventDefault()
79
+ fin.focus()
80
+ } else if (!e.shiftKey && docu.activeElement === fin) {
81
+ e.preventDefault()
82
+ debut.focus()
83
+ }
84
+ }
85
+
86
+ docu.addEventListener('keydown', onKey)
87
+ return () => {
88
+ docu.removeEventListener('keydown', onKey)
89
+ if (overflowPrecedent !== null) docu.body.style.overflow = overflowPrecedent
90
+ origine.current?.focus?.()
91
+ }
92
+ }, [ouvert, onClose, panneau, verrouille])
93
+ }
94
+
95
+ /* ─── Voile ────────────────────────────────────────────────────────── */
96
+
97
+ /** Toujours l'encre de charte diluée, jamais un noir pur : la page est
98
+ * bleutée, un gris neutre y ferait un trou sale. */
99
+ export function Scrim({ className, ...reste }: Div & { className?: string }) {
100
+ return <div className={cx('zv-scrim', className)} aria-hidden="true" {...reste} />
101
+ }
102
+
103
+ /* ─── Tiroir ───────────────────────────────────────────────────────── */
104
+
105
+ export interface DrawerProps extends Omit<Div, 'title'> {
106
+ ouvert: boolean
107
+ onClose: () => void
108
+ /** Nom du dialogue — ce que le lecteur d'écran annonce à l'ouverture. */
109
+ label?: string
110
+ /** Croix de fermeture posée en absolu par-dessus la tête du panneau. */
111
+ fermeture?: ReactNode
112
+ className?: string
113
+ children?: ReactNode
114
+ }
115
+
116
+ /** ⚠️ Le tiroir n'existe QUE sous le point de rupture : au-dessus, la
117
+ * navigation est une colonne et il n'y a rien à ouvrir. Le CSS s'en charge —
118
+ * ne pas conditionner le rendu sur la largeur en JavaScript, le serveur ne
119
+ * la connaît pas. */
120
+ export function Drawer({
121
+ ouvert,
122
+ onClose,
123
+ label = 'Navigation',
124
+ fermeture,
125
+ className,
126
+ children,
127
+ ...reste
128
+ }: DrawerProps) {
129
+ const panneau = useRef<HTMLDivElement>(null)
130
+ useOverlay({ ouvert, onClose, panneau })
131
+ const fermer = useCallback(() => onClose(), [onClose])
132
+
133
+ return (
134
+ <div
135
+ role="dialog"
136
+ aria-modal="true"
137
+ aria-label={label}
138
+ aria-hidden={!ouvert}
139
+ className={cx('zv-drawer', ouvert && 'zv-drawer--open', className)}
140
+ {...reste}
141
+ >
142
+ <div className="zv-scrim" onClick={fermer} aria-hidden="true" />
143
+ <div className="zv-drawer__panel" ref={panneau}>
144
+ {fermeture && <div className="zv-drawer__close">{fermeture}</div>}
145
+ {children}
146
+ </div>
147
+ </div>
148
+ )
149
+ }
150
+
151
+ /* ─── Modale ───────────────────────────────────────────────────────── */
152
+
153
+ export interface ModalProps extends Omit<Div, 'title'> {
154
+ ouvert: boolean
155
+ onClose: () => void
156
+ titre?: ReactNode
157
+ /** Actions de tête, à droite du titre (la croix, en général). */
158
+ actions?: ReactNode
159
+ /** Pied d'actions. Il reste visible quand le corps défile — c'est tout
160
+ * l'intérêt du plafond de hauteur. */
161
+ pied?: ReactNode
162
+ /** Modale destructive : le filet HAUT porte l'avertissement, pas un fond
163
+ * rouge — une surface d'alerte pleine rendrait le texte pénible à lire. */
164
+ danger?: boolean
165
+ className?: string
166
+ children?: ReactNode
167
+ }
168
+
169
+ export function Modal({
170
+ ouvert,
171
+ onClose,
172
+ titre,
173
+ actions,
174
+ pied,
175
+ danger,
176
+ className,
177
+ children,
178
+ ...reste
179
+ }: ModalProps) {
180
+ const panneau = useRef<HTMLDivElement>(null)
181
+ useOverlay({ ouvert, onClose, panneau })
182
+ const fermer = useCallback(() => onClose(), [onClose])
183
+
184
+ // Une modale fermée n'est pas rendue du tout : contrairement au tiroir, elle
185
+ // n'a pas d'animation d'entrée à préserver, et son contenu (un formulaire,
186
+ // le plus souvent) ne doit pas rester dans le DOM à l'état fantôme.
187
+ if (!ouvert) return null
188
+
189
+ return (
190
+ <div
191
+ role="dialog"
192
+ aria-modal="true"
193
+ aria-label={typeof titre === 'string' ? titre : undefined}
194
+ className={cx('zv-modal', 'zv-modal--open', danger && 'zv-modal--danger', className)}
195
+ {...reste}
196
+ >
197
+ <div className="zv-scrim" onClick={fermer} aria-hidden="true" />
198
+ <div className="zv-modal__panel" ref={panneau}>
199
+ {(titre || actions) && (
200
+ <div className="zv-modal__head">
201
+ {titre && <h2 className="zv-modal__title">{titre}</h2>}
202
+ {actions}
203
+ </div>
204
+ )}
205
+ <div className="zv-modal__body">{children}</div>
206
+ {pied && <div className="zv-modal__foot">{pied}</div>}
207
+ </div>
208
+ </div>
209
+ )
210
+ }
211
+
212
+ /* ─── Notification flottante ───────────────────────────────────────── */
213
+
214
+ /** Le conteneur ne capte RIEN : seules les notifications le font, sans quoi
215
+ * une colonne invisible bloquerait les clics dans tout le coin de l'écran. */
216
+ export function Toasts({ className, ...reste }: Div & { className?: string }) {
217
+ return (
218
+ <div
219
+ className={cx('zv-toasts', className)}
220
+ role="region"
221
+ aria-live="polite"
222
+ {...reste}
223
+ />
224
+ )
225
+ }
226
+
227
+ export interface ToastProps extends Div {
228
+ ton?: 'ok' | 'err'
229
+ className?: string
230
+ }
231
+
232
+ /** ⚠️ Ne remplace PAS `Alert` : une erreur de formulaire se dit à côté du
233
+ * champ, jamais dans un coin qui disparaît. Réserver ce composant à ce qui
234
+ * est vrai APRÈS COUP — « copié », « enregistré », « envoyé ». */
235
+ export function Toast({ ton, className, ...reste }: ToastProps) {
236
+ return <div className={cx('zv-toast', ton && `zv-toast--${ton}`, className)} {...reste} />
237
+ }
@@ -0,0 +1,161 @@
1
+ // Surfaces — halos, sols quadrillés, voiles de fondu, bandeau manifeste,
2
+ // glyphes. C'est ce qui distingue une page Zevra d'une page blanche bien
3
+ // composée.
4
+ //
5
+ // ⚠️ TOUT est DÉCORATIF : posé en absolu derrière le contenu, sans capter le
6
+ // pointeur, et masqué aux lecteurs d'écran. Ces composants posent
7
+ // `aria-hidden` eux-mêmes — ne pas y mettre de texte à lire.
8
+ //
9
+ // ⚠️ Tous supposent un parent `position: relative; overflow: hidden`. Sans le
10
+ // second, un halo de 1400px de large fait déborder la page.
11
+
12
+ import type { HTMLAttributes, ReactNode } from 'react'
13
+ import { cx } from './cx.js'
14
+
15
+ type Div = Omit<HTMLAttributes<HTMLDivElement>, 'className'>
16
+
17
+ /* ─── Halo ambiant ─────────────────────────────────────────────────── */
18
+
19
+ /** Le violet et le cyan du spectre, très dilués. Ce n'est PAS un emploi du
20
+ * spectre en aplat : c'est une lumière — elle ne compte pas dans les trois
21
+ * emplois autorisés. */
22
+ export function Halo({
23
+ place,
24
+ className,
25
+ ...reste
26
+ }: Div & { place: 'hero' | 'cta' | 'core'; className?: string }) {
27
+ return (
28
+ <div
29
+ aria-hidden="true"
30
+ className={cx('zv-halo', `zv-halo--${place}`, className)}
31
+ {...reste}
32
+ />
33
+ )
34
+ }
35
+
36
+ /* ─── Sol quadrillé ────────────────────────────────────────────────── */
37
+
38
+ export function GridFloor({
39
+ carte,
40
+ className,
41
+ ...reste
42
+ }: Div & { carte?: boolean; className?: string }) {
43
+ return (
44
+ <div
45
+ aria-hidden="true"
46
+ className={cx('zv-grid-floor', carte && 'zv-grid-floor--card', className)}
47
+ {...reste}
48
+ />
49
+ )
50
+ }
51
+
52
+ /* ─── Voile de fondu ───────────────────────────────────────────────── */
53
+
54
+ /** Éteint le bas d'une scène pour que le texte qui la suit reste lisible.
55
+ * `blanc` quand la page est blanche sous la scène. */
56
+ export function Fade({
57
+ blanc,
58
+ className,
59
+ ...reste
60
+ }: Div & { blanc?: boolean; className?: string }) {
61
+ return (
62
+ <div
63
+ aria-hidden="true"
64
+ className={cx('zv-fade', blanc && 'zv-fade--white', className)}
65
+ {...reste}
66
+ />
67
+ )
68
+ }
69
+
70
+ /* ─── Bandeau manifeste ────────────────────────────────────────────── */
71
+
72
+ export interface BandProps extends Div {
73
+ /** Glyphe en filigrane, très dilué, dans un coin. */
74
+ filigrane?: ReactNode
75
+ /** Lueur d'accent derrière le contenu. */
76
+ lueur?: boolean
77
+ className?: string
78
+ children?: ReactNode
79
+ }
80
+
81
+ /** LA surface sombre du système. ⚠️ Les surfaces sombres se font avec elle et
82
+ * `--accent-on-dark`, JAMAIS en inversant les neutres : les contrastes ont
83
+ * été réglés dans ce sens-là et pas dans l'autre. */
84
+ export function Band({ filigrane, lueur, className, children, ...reste }: BandProps) {
85
+ return (
86
+ <section className={cx('zv-band', className)} {...reste}>
87
+ {lueur && <div className="zv-band__glow" aria-hidden="true" />}
88
+ {filigrane && (
89
+ <div className="zv-band__watermark" aria-hidden="true">
90
+ {filigrane}
91
+ </div>
92
+ )}
93
+ <div className="zv-band__content">{children}</div>
94
+ </section>
95
+ )
96
+ }
97
+
98
+ /** Panneau sombre plus petit qu'une bande : une carte, un encart. */
99
+ export function PanelNavy({ className, ...reste }: Div & { className?: string }) {
100
+ return <div className={cx('zv-panel-navy', className)} {...reste} />
101
+ }
102
+
103
+ /** Repeint un texte pour une surface sombre. `Band` le fait déjà pour son
104
+ * contenu courant — cette classe sert aux cas isolés. */
105
+ export function TextOnDark({ className, ...reste }: Div & { className?: string }) {
106
+ return <div className={cx('zv-text-on-dark', className)} {...reste} />
107
+ }
108
+
109
+ /* ─── Glyphes ──────────────────────────────────────────────────────── */
110
+
111
+ export interface GlyphProps extends Div {
112
+ /** `deep` accentue l'ombre portée, `watermark` dilue le glyphe jusqu'au
113
+ * filigrane. */
114
+ variante?: 'deep' | 'watermark'
115
+ className?: string
116
+ children?: ReactNode
117
+ }
118
+
119
+ /** Enveloppe d'un logo de produit posé comme motif. Le glyphe lui-même est
120
+ * une image du paquet (`@zevra/ui/logos/…`), pas un tracé du CSS. */
121
+ export function Glyph({ variante, className, children, ...reste }: GlyphProps) {
122
+ return (
123
+ <div
124
+ aria-hidden="true"
125
+ className={cx('zv-glyph', variante && `zv-glyph--${variante}`, className)}
126
+ {...reste}
127
+ >
128
+ {children}
129
+ </div>
130
+ )
131
+ }
132
+
133
+ /* ─── Mouvement ────────────────────────────────────────────────────── */
134
+
135
+ export type Animation =
136
+ | 'rise'
137
+ | 'pop'
138
+ | 'filet'
139
+ | 'glyph-in'
140
+ | 'converge'
141
+ | 'breathe'
142
+ | 'drift'
143
+ | 'dot-active'
144
+ | 'progress'
145
+
146
+ /** Les neuf animations du paquet, nommées. ⚠️ Elles sont pilotées AU
147
+ * DÉFILEMENT et réservées à la vitrine : le chrome applicatif n'anime rien.
148
+ * `prefers-reduced-motion` les coupe toutes, globalement, depuis base.css.
149
+ *
150
+ * `option` échelonne les entrées d'une même rangée (`pop`, rangs 2 à 6) ou
151
+ * donne le coin d'arrivée (`converge` : tr, bl, br — le défaut vient du
152
+ * haut-gauche). Passée à une autre animation, elle est ignorée. */
153
+ export function anim(
154
+ nom: Animation,
155
+ option?: 2 | 3 | 4 | 5 | 6 | 'tr' | 'bl' | 'br',
156
+ ): string {
157
+ if (nom === 'pop' && typeof option === 'number') return `zv-anim-pop zv-anim-pop--${option}`
158
+ if (nom === 'converge' && typeof option === 'string')
159
+ return `zv-anim-converge zv-anim-converge--${option}`
160
+ return `zv-anim-${nom}`
161
+ }
@@ -0,0 +1,220 @@
1
+ // Typographie — titres, corps, kickers, labels mono, filets.
2
+ //
3
+ // La HIÉRARCHIE VISUELLE et le NIVEAU SÉMANTIQUE sont deux choses : une page
4
+ // n'a qu'un <h1>, mais elle peut avoir plusieurs blocs à la taille du h1.
5
+ // D'où `as` sur les titres — la taille est un choix de mise en page, la
6
+ // balise un choix de structure. Les confondre casse le plan du document pour
7
+ // un lecteur d'écran sans que rien ne se voie à l'écran.
8
+
9
+ import type { ElementType, HTMLAttributes, ReactNode } from 'react'
10
+ import { cx } from './cx.js'
11
+
12
+ type Div = Omit<HTMLAttributes<HTMLElement>, 'className'>
13
+ type Span = Omit<HTMLAttributes<HTMLSpanElement>, 'className'>
14
+
15
+ /* ─── Titres ───────────────────────────────────────────────────────── */
16
+
17
+ export type TitreTaille = 'h1' | 'h2' | 'h2-sm' | 'h2-cta' | 'h3' | 'carte'
18
+
19
+ const CLASSE_TITRE: Record<TitreTaille, string> = {
20
+ h1: 'zv-h1',
21
+ h2: 'zv-h2',
22
+ 'h2-sm': 'zv-h2 zv-h2--sm',
23
+ 'h2-cta': 'zv-h2 zv-h2--cta',
24
+ h3: 'zv-h3',
25
+ carte: 'zv-card-title',
26
+ }
27
+
28
+ export interface HeadingProps extends Div {
29
+ taille?: TitreTaille
30
+ /** Balise réellement rendue. Par défaut, celle qui correspond à la taille. */
31
+ as?: ElementType
32
+ className?: string
33
+ children?: ReactNode
34
+ }
35
+
36
+ const BALISE_PAR_DEFAUT: Record<TitreTaille, ElementType> = {
37
+ h1: 'h1',
38
+ h2: 'h2',
39
+ 'h2-sm': 'h2',
40
+ 'h2-cta': 'h2',
41
+ h3: 'h3',
42
+ carte: 'h3',
43
+ }
44
+
45
+ export function Heading({ taille = 'h2', as, className, ...reste }: HeadingProps) {
46
+ const Balise = as ?? BALISE_PAR_DEFAUT[taille]
47
+ return <Balise className={cx(CLASSE_TITRE[taille], className)} {...reste} />
48
+ }
49
+
50
+ export const H1 = (p: Omit<HeadingProps, 'taille'>) => <Heading taille="h1" {...p} />
51
+ export const H2 = (p: Omit<HeadingProps, 'taille'>) => <Heading taille="h2" {...p} />
52
+ export const H3 = (p: Omit<HeadingProps, 'taille'>) => <Heading taille="h3" {...p} />
53
+ export const CardTitle = (p: Omit<HeadingProps, 'taille'>) => (
54
+ <Heading taille="carte" {...p} />
55
+ )
56
+
57
+ /** Chiffre ou mot posé pour sa PRÉSENCE, hors de la hiérarchie des titres —
58
+ * d'où le `<div>` : un chiffre géant annoncé comme titre de section pollue
59
+ * le plan du document. */
60
+ export function Display({ className, ...reste }: Div & { className?: string }) {
61
+ return <div className={cx('zv-display', className)} {...reste} />
62
+ }
63
+
64
+ /* ─── Corps ────────────────────────────────────────────────────────── */
65
+
66
+ export interface BodyProps extends Div {
67
+ taille?: 'normal' | 'lg' | 'dense'
68
+ as?: ElementType
69
+ className?: string
70
+ children?: ReactNode
71
+ }
72
+
73
+ export function Body({ taille = 'normal', as, className, ...reste }: BodyProps) {
74
+ const Balise = as ?? 'p'
75
+ return (
76
+ <Balise
77
+ className={cx(
78
+ 'zv-body',
79
+ taille === 'lg' && 'zv-body--lg',
80
+ taille === 'dense' && 'zv-body--dense',
81
+ className,
82
+ )}
83
+ {...reste}
84
+ />
85
+ )
86
+ }
87
+
88
+ /** Chapô : le paragraphe qui suit immédiatement un titre de section. */
89
+ export function Lead({
90
+ as,
91
+ className,
92
+ ...reste
93
+ }: Div & { as?: ElementType; className?: string }) {
94
+ const Balise = as ?? 'p'
95
+ return <Balise className={cx('zv-lead', className)} {...reste} />
96
+ }
97
+
98
+ /** Mention légale, précision de bas de bloc. */
99
+ export function Note({
100
+ as,
101
+ className,
102
+ ...reste
103
+ }: Div & { as?: ElementType; className?: string }) {
104
+ const Balise = as ?? 'p'
105
+ return <Balise className={cx('zv-note', className)} {...reste} />
106
+ }
107
+
108
+ /** Signature de marque, sous un logo. */
109
+ export function Tagline({ className, ...reste }: Span & { className?: string }) {
110
+ return <span className={cx('zv-tagline', className)} {...reste} />
111
+ }
112
+
113
+ /** Mise en valeur dans le fil du texte. Le pendant `surSombre` existe parce
114
+ * que l'indigo d'accent devient illisible sur la bande manifeste. */
115
+ export function Em({
116
+ surSombre,
117
+ className,
118
+ ...reste
119
+ }: Span & { surSombre?: boolean; className?: string }) {
120
+ return (
121
+ <em className={cx('zv-em', surSombre && 'zv-em--on-dark', className)} {...reste} />
122
+ )
123
+ }
124
+
125
+ export function Link({
126
+ className,
127
+ ...reste
128
+ }: Omit<HTMLAttributes<HTMLAnchorElement>, 'className'> & {
129
+ className?: string
130
+ href?: string
131
+ }) {
132
+ return <a className={cx('zv-link', className)} {...reste} />
133
+ }
134
+
135
+ /* ─── Kicker et filet de signature ─────────────────────────────────── */
136
+
137
+ export interface KickerProps extends Div {
138
+ /** `center` centre le bloc, `stack` empile le filet AU-DESSUS du texte au
139
+ * lieu de le poser devant. */
140
+ variante?: 'center' | 'stack'
141
+ /** Le filet de spectre 56×2 devant le kicker. Premier des trois emplois
142
+ * autorisés du spectre. */
143
+ filet?: boolean
144
+ surSombre?: boolean
145
+ className?: string
146
+ children?: ReactNode
147
+ }
148
+
149
+ export function Kicker({
150
+ variante,
151
+ filet = true,
152
+ surSombre,
153
+ className,
154
+ children,
155
+ ...reste
156
+ }: KickerProps) {
157
+ return (
158
+ <div
159
+ className={cx('zv-kicker', variante && `zv-kicker--${variante}`, className)}
160
+ {...reste}
161
+ >
162
+ {filet && <Spec surSombre={surSombre} />}
163
+ {children}
164
+ </div>
165
+ )
166
+ }
167
+
168
+ /** Le filet de spectre seul. Décoratif : masqué aux lecteurs d'écran. */
169
+ export function Spec({
170
+ petit,
171
+ surSombre,
172
+ className,
173
+ ...reste
174
+ }: Span & { petit?: boolean; surSombre?: boolean; className?: string }) {
175
+ return (
176
+ <span
177
+ aria-hidden="true"
178
+ className={cx(
179
+ 'zv-spec',
180
+ petit && 'zv-spec--sm',
181
+ surSombre && 'zv-spec--on-dark',
182
+ className,
183
+ )}
184
+ {...reste}
185
+ />
186
+ )
187
+ }
188
+
189
+ /** Filet neutre de séparation de section. */
190
+ export function Rule({ className, ...reste }: Div & { className?: string }) {
191
+ return <div className={cx('zv-rule', className)} {...reste} />
192
+ }
193
+
194
+ /* ─── Label mono ───────────────────────────────────────────────────── */
195
+
196
+ export interface MonoProps extends Span {
197
+ /** Le ton, pas la taille : `label` est le gris d'intitulé, `ink` l'encre
198
+ * pleine, `muted` le gris secondaire, `on-dark` la bande manifeste. */
199
+ ton?: 'label' | 'ink' | 'muted' | 'on-dark'
200
+ /** Le plus petit palier — badge dense, compteur de coque. */
201
+ micro?: boolean
202
+ className?: string
203
+ }
204
+
205
+ /** Capitales espacées. ⚠️ Réservé aux LIBELLÉS : une date ou un nom de
206
+ * fichier passés en capitales espacées deviennent illisibles et s'alignent
207
+ * mal — pour ceux-là, c'est `Meta` (bas de casse, chasse tabulaire). */
208
+ export function Mono({ ton, micro, className, ...reste }: MonoProps) {
209
+ return (
210
+ <span
211
+ className={cx(
212
+ 'zv-mono',
213
+ ton && `zv-mono--${ton}`,
214
+ micro && 'zv-mono--micro',
215
+ className,
216
+ )}
217
+ {...reste}
218
+ />
219
+ )
220
+ }