@zevra/ui 0.20.0 → 0.22.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.
@@ -0,0 +1,60 @@
1
+ // GÉNÉRÉ par scripts/build-email-tokens.mjs — ne pas éditer à la main.
2
+ // Source de vérité : tokens/index.js, lui-même miroir de src/tokens.css.
3
+
4
+ export const COULEURS = {
5
+ "ink": "#0b1020",
6
+ "ink2": "#4a5468",
7
+ "muted": "#5a637a",
8
+ "label": "#8a93a8",
9
+ "line": "#e3e6ef",
10
+ "hairline": "#eef1f8",
11
+ "paper": "#f5f6fa",
12
+ "panel": "#fbfcfe",
13
+ "white": "#ffffff",
14
+ "accentDeep": "#1e1b8c",
15
+ "accent": "#2a2aa8",
16
+ "accentLine": "#c9d6f5",
17
+ "accentSoft": "#eaf1ff",
18
+ "violet": "#8a3af8",
19
+ "iris": "#5848f8",
20
+ "bleu": "#2f6ef8",
21
+ "cyan": "#14c2f8",
22
+ "lavande": "#b8a8f8",
23
+ "accentOnDark": "#a5b4fc",
24
+ "ok": "#1c6b4a",
25
+ "okSoft": "#e6f3ec",
26
+ "okLine": "#bfdccb",
27
+ "warn": "#b98a1f",
28
+ "warnSoft": "#faf3e2",
29
+ "warnLine": "#ebdcb4",
30
+ "warnInk": "#8a6d1e",
31
+ "err": "#b5202c",
32
+ "errSoft": "#fdece9",
33
+ "errLine": "#efc9cd",
34
+ "fmtPdf": "#c4362b",
35
+ "fmtDocx": "#2a5ba8"
36
+ } as const
37
+
38
+ export const POLICES = {
39
+ "display": "\"Outfit\", \"Helvetica Neue\", Arial, sans-serif",
40
+ "texte": "\"Public Sans\", \"Helvetica Neue\", Arial, sans-serif",
41
+ "mono": "\"Space Grotesk\", \"Menlo\", monospace",
42
+ "code": "ui-monospace, \"SF Mono\", \"Menlo\", \"Consolas\", monospace"
43
+ } as const
44
+
45
+ export const TAILLES = {
46
+ "h1": "76px",
47
+ "h2": "56px",
48
+ "h3": "38px",
49
+ "cardTitle": "24px",
50
+ "lead": "18px",
51
+ "body": "15px",
52
+ "appLg": "14px",
53
+ "app": "13px",
54
+ "appSm": "12px",
55
+ "appH2": "20px",
56
+ "appH3": "16px",
57
+ "monoLg": "11px",
58
+ "mono": "10px",
59
+ "monoSm": "9px"
60
+ } as const
package/react/email.ts ADDED
@@ -0,0 +1,219 @@
1
+ // Gabarits d'e-mail « Encre & Papier ».
2
+ //
3
+ // ⚠️ CE MODULE N'EST PAS DU REACT ET N'EMPLOIE PAS LE CSS DU PAQUET. Il rend
4
+ // des chaînes de HTML. C'est la seule dérogation à la règle première du dépôt
5
+ // (« le CSS est la source de vérité, les composants n'en sont qu'une
6
+ // façade »), et elle est imposée par le support, pas choisie :
7
+ //
8
+ // · il n'y a pas de feuille de style à charger dans un e-mail ;
9
+ // · Outlook et Gmail SUPPRIMENT les propriétés personnalisées : un
10
+ // `var(--accent)` s'y rend en couleur nulle, donc en texte noir sur fond
11
+ // transparent. Les valeurs sont donc écrites en clair — mais tirées du
12
+ // miroir JS des tokens, que tests/tokens.test.js tient aligné sur le CSS ;
13
+ // · ni flexbox ni grid ne sont fiables : la mise en page est en TABLES ;
14
+ // · les dégradés ne se rendent pas dans Outlook — le bouton plein perd le
15
+ // sien et devient un aplat, le filet de spectre devient trois aplats.
16
+ //
17
+ // Il vit dans react/ parce que c'est la racine de compilation du paquet
18
+ // (`rootDir`), pas parce qu'il en dépend : il n'importe pas une ligne de
19
+ // React et n'émet aucune classe `zv-`.
20
+ //
21
+ // ⚠️ Ce module ÉCHAPPE à ta feuille de style : ce qui n'est pas ici ne
22
+ // s'affichera pas. Ne pas y ajouter de classes en espérant qu'elles soient
23
+ // stylées.
24
+
25
+ import { COULEURS, POLICES as PILES } from './email-tokens.js'
26
+
27
+ /** ⚠️ Les piles de polices du paquet contiennent des GUILLEMETS DOUBLES
28
+ * (`"Public Sans", …`), et tout ici s'écrit dans un attribut `style="…"`
29
+ * délimité par ces mêmes guillemets : l'attribut se refermerait au premier
30
+ * guillemet interne, et TOUT le style du nœud serait perdu. En HTML, un
31
+ * guillemet simple est équivalent à l'intérieur d'une valeur de police.
32
+ * Cette faute ne se voit pas à la relecture — elle se voit au rendu, où
33
+ * l'e-mail entier repasse en Times sans style. */
34
+ const pourAttribut = (pile: string) => pile.replace(/"/g, "'")
35
+
36
+ const POLICES = {
37
+ display: pourAttribut(PILES.display),
38
+ texte: pourAttribut(PILES.texte),
39
+ mono: pourAttribut(PILES.mono),
40
+ }
41
+
42
+ /* ─── Réglages communs ─────────────────────────────────────────────── */
43
+
44
+ /** 600px : la largeur qui traverse tous les clients depuis vingt ans, dont
45
+ * le volet de lecture d'Outlook. Ce n'est pas --page-max, et ça n'a rien à
46
+ * voir : c'est une contrainte du support. */
47
+ const LARGEUR = 600
48
+
49
+ const CORPS = `font-family:${POLICES.texte};font-size:15px;line-height:1.6;color:${COULEURS.muted};`
50
+ const TITRE = `font-family:${POLICES.display};font-weight:300;letter-spacing:-0.02em;color:${COULEURS.ink};`
51
+
52
+ /** Échappe ce qui entre dans le HTML. Un objet d'e-mail vient souvent d'une
53
+ * base de données : un nom de client avec une esperluette casserait le
54
+ * document, et un `<` bien placé y injecterait du balisage. */
55
+ export function echappe(texte: string): string {
56
+ return texte
57
+ .replace(/&/g, '&amp;')
58
+ .replace(/</g, '&lt;')
59
+ .replace(/>/g, '&gt;')
60
+ .replace(/"/g, '&quot;')
61
+ }
62
+
63
+ /* ─── Blocs ────────────────────────────────────────────────────────── */
64
+
65
+ /** Sur-titre : le filet de spectre suivi du label en capitales.
66
+ * PREMIER des trois emplois autorisés du spectre. Le dégradé est rendu en
67
+ * TROIS APLATS de 19px (violet, bleu, cyan) : un `linear-gradient` ne se
68
+ * rend pas dans Outlook, qui afficherait une bande vide. */
69
+ export function emailKicker(texte: string): string {
70
+ const brin = (couleur: string) =>
71
+ `<td width="19" height="2" style="background:${couleur};font-size:0;line-height:0;">&nbsp;</td>`
72
+ return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 14px;"><tr>
73
+ <td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr>${brin(COULEURS.violet)}${brin(COULEURS.bleu)}${brin(COULEURS.cyan)}</tr></table></td>
74
+ <td style="padding-left:12px;font-family:${POLICES.mono};font-size:11px;font-weight:500;letter-spacing:0.2em;text-transform:uppercase;color:${COULEURS.accent};">${echappe(texte)}</td>
75
+ </tr></table>`
76
+ }
77
+
78
+ /** Le titre du message. Un seul par e-mail : c'est la promesse de l'objet,
79
+ * tenue à l'ouverture. */
80
+ export function emailTitre(texte: string): string {
81
+ return `<h1 style="${TITRE}font-size:26px;line-height:1.2;margin:0 0 18px;">${echappe(texte)}</h1>`
82
+ }
83
+
84
+ /** Un paragraphe. Le HTML d'enrichissement (`<strong>`, `<a>`) est admis :
85
+ * c'est du texte rédigé, pas une saisie utilisateur — l'échapper
86
+ * afficherait des chevrons. Passer par `echappe` toute valeur qui vient
87
+ * d'une base. */
88
+ export function emailTexte(html: string): string {
89
+ return `<p style="${CORPS}margin:0 0 16px;">${html}</p>`
90
+ }
91
+
92
+ /** L'action. UN SEUL bouton plein par e-mail, comme par page — c'est ce qui
93
+ * fait qu'on sait où cliquer.
94
+ * Construit en table : le `padding` d'un `<a>` est ignoré par Outlook, qui
95
+ * rendrait un lien nu au milieu du message. Aplat indigo et non dégradé,
96
+ * pour la même raison. Angle vif, comme partout. */
97
+ export function emailBouton({ href, libelle }: { href: string; libelle: string }): string {
98
+ return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:26px 0;"><tr>
99
+ <td align="center" bgcolor="${COULEURS.accentDeep}" style="background:${COULEURS.accentDeep};">
100
+ <a href="${echappe(href)}" style="display:inline-block;padding:15px 30px;font-family:${POLICES.texte};font-size:15px;font-weight:600;color:${COULEURS.white};text-decoration:none;">${echappe(libelle)}</a>
101
+ </td></tr></table>`
102
+ }
103
+
104
+ /** Le lien secondaire, sous le bouton : l'autre chemin, pour qui ne veut pas
105
+ * du premier. Souligné — dans un e-mail, la couleur seule ne suffit pas à
106
+ * annoncer un lien. */
107
+ export function emailLienSecondaire({ href, libelle }: { href: string; libelle: string }): string {
108
+ return `<p style="${CORPS}font-size:14px;margin:0 0 16px;"><a href="${echappe(href)}" style="color:${COULEURS.accent};text-decoration:underline;">${echappe(libelle)}</a></p>`
109
+ }
110
+
111
+ /** La mention discrète : ce qu'on lit après avoir décidé. */
112
+ export function emailNote(html: string): string {
113
+ return `<p style="font-family:${POLICES.texte};font-size:13px;line-height:1.55;color:${COULEURS.label};margin:0 0 12px;">${html}</p>`
114
+ }
115
+
116
+ /** L'encart : un extrait, un récapitulatif de commande, un code. */
117
+ export function emailEncart(html: string): string {
118
+ return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 18px;"><tr>
119
+ <td style="background:${COULEURS.panel};border:1px solid ${COULEURS.hairline};border-left:2px solid ${COULEURS.accentLine};padding:14px 18px;font-family:${POLICES.texte};font-size:13px;line-height:1.6;color:${COULEURS.ink};">${html}</td>
120
+ </tr></table>`
121
+ }
122
+
123
+ /** Le filet de séparation. */
124
+ export function emailFilet(): string {
125
+ return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:22px 0;"><tr>
126
+ <td height="1" style="background:${COULEURS.line};font-size:0;line-height:0;">&nbsp;</td></tr></table>`
127
+ }
128
+
129
+ /* ─── Le gabarit ───────────────────────────────────────────────────── */
130
+
131
+ export interface EmailOptions {
132
+ /** Repris en `<title>`. Ce n'est PAS l'objet du message : l'objet se pose
133
+ * à l'envoi, dans l'en-tête SMTP. */
134
+ titre: string
135
+ /** Le texte gris qui suit l'objet dans la liste des messages. Sans lui,
136
+ * le client y affiche les premiers mots du HTML — souvent « Voir cet
137
+ * e-mail dans votre navigateur », ce qui gâche la seule ligne dont on
138
+ * dispose pour convaincre d'ouvrir. */
139
+ preheader?: string
140
+ /** Le nom de la marque, écrit en toutes lettres. ⚠️ OBLIGATOIRE même avec
141
+ * un logo : une image sur deux est bloquée par défaut, et un e-mail qui
142
+ * ne dit pas de qui il vient part à la corbeille. */
143
+ marque: string
144
+ /** L'URL ABSOLUE du logo — une image d'e-mail ne se résout pas depuis un
145
+ * chemin relatif, et les data-URI sont bloquées par Gmail. */
146
+ logo?: { src: string; hauteur?: number }
147
+ /** Les blocs, concaténés. */
148
+ contenu: string
149
+ /** Sous la carte : mentions légales, désinscription. */
150
+ pied?: string
151
+ lang?: string
152
+ }
153
+
154
+ /**
155
+ * Assemble un e-mail complet.
156
+ *
157
+ * ⚠️ Le mode sombre est REFUSÉ explicitement (`color-scheme: light`) : la
158
+ * charte est claire par nature, et l'inversion automatique d'Apple Mail
159
+ * retourne les neutres sans toucher aux aplats — l'indigo du bouton
160
+ * resterait sur un fond devenu noir, et les contrastes réglés dans un sens
161
+ * partiraient dans l'autre. C'est la même règle que pour les surfaces
162
+ * sombres du système : elles se font avec la bande manifeste, jamais en
163
+ * inversant les neutres.
164
+ */
165
+ export function email({
166
+ titre,
167
+ preheader,
168
+ marque,
169
+ logo,
170
+ contenu,
171
+ pied,
172
+ lang = 'fr',
173
+ }: EmailOptions): string {
174
+ const enTete = logo
175
+ ? `<img src="${echappe(logo.src)}" height="${logo.hauteur ?? 28}" alt="${echappe(marque)}" style="display:block;border:0;height:${logo.hauteur ?? 28}px;width:auto;">`
176
+ : `<span style="font-family:${POLICES.display};font-weight:600;font-size:20px;letter-spacing:-0.02em;color:${COULEURS.ink};">${echappe(marque)}</span>`
177
+
178
+ return `<!doctype html>
179
+ <html lang="${lang}">
180
+ <head>
181
+ <meta charset="utf-8">
182
+ <meta name="viewport" content="width=device-width,initial-scale=1">
183
+ <meta name="color-scheme" content="light">
184
+ <meta name="supported-color-schemes" content="light">
185
+ <title>${echappe(titre)}</title>
186
+ <style>
187
+ /* Le seul <style> du document, et il ne porte QUE du responsive : ce que
188
+ Gmail en supprime ne fait rien perdre, tout le reste est en ligne. */
189
+ @media (max-width:620px) {
190
+ .zv-carte { padding:26px 20px !important; }
191
+ .zv-marge { padding:20px 12px !important; }
192
+ }
193
+ </style>
194
+ </head>
195
+ <body style="margin:0;padding:0;width:100%;background:${COULEURS.paper};-webkit-text-size-adjust:100%;">
196
+ <div style="display:none;max-height:0;overflow:hidden;opacity:0;">${preheader ? echappe(preheader) : ''}</div>
197
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:${COULEURS.paper};">
198
+ <tr><td align="center" class="zv-marge" style="padding:32px 16px;">
199
+ <table role="presentation" width="${LARGEUR}" cellpadding="0" cellspacing="0" border="0" style="width:100%;max-width:${LARGEUR}px;">
200
+
201
+ <tr><td style="padding:0 0 20px;">${enTete}</td></tr>
202
+
203
+ <tr><td class="zv-carte" bgcolor="${COULEURS.white}" style="background:${COULEURS.white};border:1px solid ${COULEURS.line};padding:34px 36px;">
204
+ ${contenu}
205
+ </td></tr>
206
+
207
+ ${
208
+ pied
209
+ ? `<tr><td style="padding:20px 4px 0;font-family:${POLICES.texte};font-size:12px;line-height:1.6;color:${COULEURS.label};">${pied}</td></tr>`
210
+ : ''
211
+ }
212
+
213
+ </table>
214
+ </td></tr>
215
+ </table>
216
+ </body>
217
+ </html>
218
+ `
219
+ }
@@ -636,8 +636,23 @@
636
636
  /* ─── FAQ ───────────────────────────────────────────────────────────────
637
637
  * Cinq questions par page au maximum. */
638
638
 
639
+ /* Le filet, comme `.zv-fold` : sans lui, la FAQ est un aplat blanc posé sur
640
+ * le papier, sans début ni fin. Toutes les autres surfaces blanches de la
641
+ * vitrine — carte, encart, tarif — en portent un ; celle-ci faisait tache. */
639
642
  .zv-faq {
640
643
  background: var(--white);
644
+ border: 1px solid var(--line);
645
+ }
646
+
647
+ /* Chaque question est un `<details>` : le repli est natif, sans JavaScript.
648
+ * Le filet sépare les questions ENTRE elles ; la première n'en porte pas, il
649
+ * tomberait sur celui du bloc et ferait un trait double. */
650
+ .zv-faq__item {
651
+ border-top: 1px solid var(--hairline);
652
+ }
653
+
654
+ .zv-faq__item:first-child {
655
+ border-top: 0;
641
656
  }
642
657
 
643
658
  .zv-faq__q {
@@ -646,22 +661,54 @@
646
661
  align-items: center;
647
662
  gap: 16px;
648
663
  padding: 18px 24px;
649
- border-bottom: 1px solid var(--hairline);
650
664
  font-size: 14px;
651
665
  font-weight: 600;
652
666
  cursor: pointer;
667
+ /* Le marqueur natif du `<summary>` tombe : le chevron du composant fait le
668
+ * même travail sans imposer le triangle du navigateur. Même traitement que
669
+ * `.zv-fold__titre`. */
670
+ list-style: none;
653
671
  }
654
672
 
655
- .zv-faq__q svg {
673
+ .zv-faq__q::-webkit-details-marker {
674
+ display: none;
675
+ }
676
+
677
+ /* Une FAQ de vitrine veut ses questions dans le PLAN DU DOCUMENT : c'est ce
678
+ * que lisent les moteurs, et ce que parcourt un lecteur d'écran qui saute de
679
+ * titre en titre. On accepte donc un `<h2>`/`<h3>`/`<h4>` comme question,
680
+ * et on lui retire ce que le navigateur lui met — taille, graisse, marges —
681
+ * pour qu'il se rende comme le reste des questions. Le niveau reste au
682
+ * balisage, l'apparence au système. */
683
+ .zv-faq__q > :is(h2, h3, h4) {
684
+ margin: 0;
685
+ font: inherit;
686
+ color: inherit;
687
+ }
688
+
689
+ .zv-faq__q:hover {
690
+ background: var(--panel);
691
+ }
692
+
693
+ /* Le chevron porte sa classe : cibler tout `svg` du titre attraperait aussi
694
+ * une icône posée dans le libellé, qui se verrait rétrécie et retournée. */
695
+ .zv-faq__chevron {
656
696
  width: 14px;
657
697
  height: 14px;
658
698
  flex: none;
659
699
  color: var(--accent);
700
+ transition: rotate 160ms cubic-bezier(0.2, 0.7, 0.2, 1);
701
+ }
702
+
703
+ .zv-faq__item[open] .zv-faq__chevron {
704
+ rotate: 180deg;
660
705
  }
661
706
 
662
707
  .zv-faq__a {
663
- padding: 14px 24px;
708
+ margin: 0;
709
+ padding: 0 24px 18px;
664
710
  font-size: 13px;
711
+ line-height: 1.6;
665
712
  color: var(--muted);
666
713
  }
667
714
 
package/src/layout.css CHANGED
@@ -395,11 +395,15 @@
395
395
  padding-top: 22px;
396
396
  padding-bottom: 12px;
397
397
  z-index: 40;
398
- background: linear-gradient(
399
- 180deg,
400
- color-mix(in srgb, var(--paper) 92%, transparent),
401
- color-mix(in srgb, var(--paper) 0%, transparent)
402
- );
398
+ /* Le voile est PLEIN derrière la barre, et il se dégrade EN DESSOUS d'elle
399
+ * (::after). Il était auparavant dégradé sur toute sa hauteur : à sa base
400
+ * il ne restait donc presque rien, et un titre de 38 ou 56px qui passait
401
+ * dessous se retrouvait COUPÉ EN DEUX — moitié voilée, moitié nette, avec
402
+ * les entrées de nav posées sur ses lettres. Deux textes superposés, aucun
403
+ * lisible. Signalé en recette sur mcp-factory.
404
+ * Le dégradé n'est pas perdu : il vit sous la barre, là où il adoucit le
405
+ * passage sans se disputer la place avec la navigation. */
406
+ background: color-mix(in srgb, var(--paper) 92%, transparent);
403
407
  /* ⚠️ LE PRÉFIXÉ D'ABORD. Lightning CSS — le compilateur de Tailwind v4,
404
408
  * donc celui de toutes nos applications Next — SUPPRIME `backdrop-filter`
405
409
  * non préfixé quand il précède la forme `-webkit-`. Or Firefox ne connaît
@@ -419,6 +423,25 @@
419
423
  backdrop-filter: blur(6px);
420
424
  }
421
425
 
426
+ /* La frange de dégradé, posée SOUS la barre. `pointer-events: none` : elle
427
+ * couvre le haut du contenu, elle ne doit rien intercepter. */
428
+ .zv-header::after {
429
+ content: '';
430
+ position: absolute;
431
+ top: 100%;
432
+ left: 0;
433
+ right: 0;
434
+ height: 24px;
435
+ background: linear-gradient(
436
+ 180deg,
437
+ color-mix(in srgb, var(--paper) 92%, transparent),
438
+ color-mix(in srgb, var(--paper) 0%, transparent)
439
+ );
440
+ -webkit-backdrop-filter: blur(6px);
441
+ backdrop-filter: blur(6px);
442
+ pointer-events: none;
443
+ }
444
+
422
445
  .zv-header:has(> .zv-nav) {
423
446
  display: block;
424
447
  padding-top: 0;