@zevra/support-widget 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.
@@ -0,0 +1,173 @@
1
+ // ============================================================================
2
+ // Le relevé automatique du contexte technique. PUR.
3
+ // ============================================================================
4
+ //
5
+ // Ce que le widget joint tout seul à une demande : l'URL, le navigateur, la
6
+ // version de l'app, la taille de la fenêtre, la langue, et les dernières
7
+ // erreurs de console. C'est ce qui fait la différence entre « ça marche pas »
8
+ // et un ticket qu'on peut reproduire.
9
+ //
10
+ // ⚠️ TOUT CE QUI EST RELEVÉ FINIT EN BASE, DANS UN MAIL, ET SOUS LES YEUX DE
11
+ // L'ÉQUIPE. Le relevé n'est donc pas « tout ce qu'on peut lire » : c'est ce
12
+ // qui diagnostique, et rien de plus. D'où le traitement de l'URL ci-dessous,
13
+ // qui est la décision principale de ce fichier.
14
+ //
15
+ // La fonction est PURE : on lui passe ce qu'on a lu de la fenêtre. Sans cette
16
+ // séparation, il faudrait un navigateur pour tester le cas « l'app n'a pas
17
+ // déclaré sa version » — c'est-à-dire qu'on ne le testerait pas.
18
+
19
+ import type { ContexteReleve } from './types'
20
+
21
+ export const LONGUEUR_CHAMP = 500
22
+ export const LONGUEUR_USER_AGENT = 300
23
+
24
+ export interface SourceContexte {
25
+ url?: string | null
26
+ userAgent?: string | null
27
+ langue?: string | null
28
+ largeur?: number | null
29
+ hauteur?: number | null
30
+ version?: string | null
31
+ erreurs?: readonly string[]
32
+ extra?: Record<string, unknown> | null
33
+ }
34
+
35
+ /**
36
+ * Nettoie l'URL de la page. PUR.
37
+ *
38
+ * ⚠️ LA VALEUR DES PARAMÈTRES EST RETIRÉE, LEUR NOM EST GARDÉ.
39
+ *
40
+ * Une query string porte régulièrement un jeton de lien magique
41
+ * (`?jeton=…`), une adresse (`?email=…`), un identifiant de dossier. Copiée
42
+ * dans un ticket, elle est recopiée dans la base du support, dans l'accusé de
43
+ * réception, dans la copie d'écran de l'équipe — et un jeton de connexion qui
44
+ * traîne dans un fil de support est un jeton utilisable.
45
+ *
46
+ * Mais l'information « il y avait un paramètre `jeton` » diagnostique
47
+ * réellement (elle dit sur quelle variante de la page on était). On garde donc
48
+ * les noms : `?jeton=abc&page=2` devient `?jeton&page`.
49
+ *
50
+ * Le fragment (`#…`) part entièrement : il ne sert presque jamais au
51
+ * diagnostic et sert, lui, aux jetons des flux d'autorisation implicites.
52
+ */
53
+ export function nettoyerUrl(brut: string | null | undefined): string | undefined {
54
+ const texte = typeof brut === 'string' ? brut.trim() : ''
55
+ if (texte === '') return undefined
56
+
57
+ let url: URL | null = null
58
+ try {
59
+ url = new URL(texte)
60
+ } catch {
61
+ url = null
62
+ }
63
+
64
+ if (url === null) {
65
+ // Une URL relative, ou quelque chose qu'on n'a pas su lire. On ne devine
66
+ // pas : on coupe avant le premier `?` ou `#`, ce qui est le comportement
67
+ // prudent quoi qu'il y ait derrière.
68
+ const coupe = texte.split(/[?#]/)[0]
69
+ return borner(coupe, LONGUEUR_CHAMP)
70
+ }
71
+
72
+ const noms = [...new Set([...url.searchParams.keys()])]
73
+ const query = noms.length > 0 ? `?${noms.join('&')}` : ''
74
+ return borner(`${url.origin}${url.pathname}${query}`, LONGUEUR_CHAMP)
75
+ }
76
+
77
+ /**
78
+ * Déduit le système d'exploitation du user agent. PUR.
79
+ *
80
+ * ⚠️ L'ORDRE DES TESTS EST LA FONCTION. Un user agent Android contient
81
+ * « Linux » ; un iPhone contient « like Mac OS X ». Tester Linux ou macOS en
82
+ * premier classerait tous les téléphones en poste de travail — et on
83
+ * chercherait longtemps pourquoi un bug « de bureau » ne se reproduit pas.
84
+ *
85
+ * Rend `undefined` quand on ne peut pas conclure, JAMAIS « inconnu » : un
86
+ * champ vide se lit comme « pas relevé », un « inconnu » écrit se lit comme
87
+ * une valeur mesurée.
88
+ */
89
+ export function deduireOs(userAgent: string | null | undefined): string | undefined {
90
+ const ua = typeof userAgent === 'string' ? userAgent : ''
91
+ if (ua === '') return undefined
92
+ if (/iphone|ipad|ipod/i.test(ua)) return 'iOS'
93
+ if (/android/i.test(ua)) return 'Android'
94
+ if (/windows nt/i.test(ua)) return 'Windows'
95
+ if (/mac os x|macintosh/i.test(ua)) return 'macOS'
96
+ if (/cros/i.test(ua)) return 'ChromeOS'
97
+ if (/linux/i.test(ua)) return 'Linux'
98
+ return undefined
99
+ }
100
+
101
+ /** « 1440 × 900 ». Rend `undefined` si une des deux mesures manque. */
102
+ export function formaterViewport(
103
+ largeur: number | null | undefined,
104
+ hauteur: number | null | undefined,
105
+ ): string | undefined {
106
+ if (!estMesure(largeur) || !estMesure(hauteur)) return undefined
107
+ return `${Math.round(largeur)} × ${Math.round(hauteur)}`
108
+ }
109
+
110
+ /**
111
+ * Assemble le contexte. PUR.
112
+ *
113
+ * Une clé absente est OMISE, pas mise à `null` ou à `''` : « viewport: 0 × 0 »
114
+ * ressemble à une mesure, alors que c'est une absence de mesure — et c'est sur
115
+ * cette fausse mesure qu'on partirait chercher un bug d'affichage.
116
+ */
117
+ export function relever(source: SourceContexte): ContexteReleve {
118
+ const contexte: ContexteReleve = {}
119
+
120
+ const url = nettoyerUrl(source.url)
121
+ if (url) contexte.url = url
122
+
123
+ const ua = texteOuRien(source.userAgent, LONGUEUR_USER_AGENT)
124
+ if (ua) contexte.user_agent = ua
125
+
126
+ const version = texteOuRien(source.version, 100)
127
+ if (version) contexte.version = version
128
+
129
+ const os = deduireOs(source.userAgent)
130
+ if (os) contexte.os = os
131
+
132
+ const viewport = formaterViewport(source.largeur, source.hauteur)
133
+ if (viewport) contexte.viewport = viewport
134
+
135
+ const langue = texteOuRien(source.langue, 20)
136
+ if (langue) contexte.langue = langue
137
+
138
+ const erreurs = (source.erreurs ?? [])
139
+ .map((ligne) => texteOuRien(ligne, LONGUEUR_CHAMP))
140
+ .filter((ligne): ligne is string => ligne !== undefined)
141
+ if (erreurs.length > 0) contexte.console = erreurs
142
+
143
+ if (source.extra && typeof source.extra === 'object' && !Array.isArray(source.extra)) {
144
+ // L'app hôte peut joindre ce qu'elle veut (dossier courant, rôle, drapeau
145
+ // de fonctionnalité). On ne garde que ce qui se sérialise : un objet
146
+ // circulaire ferait jeter la sérialisation du corps au moment de l'envoi,
147
+ // c'est-à-dire loin d'ici, dans un message incompréhensible.
148
+ try {
149
+ const rendu = JSON.stringify(source.extra)
150
+ if (rendu !== undefined && rendu.length <= 4000) {
151
+ contexte.extra = JSON.parse(rendu) as Record<string, unknown>
152
+ }
153
+ } catch {
154
+ // Un extra illisible ne doit pas emporter le reste du contexte.
155
+ }
156
+ }
157
+
158
+ return contexte
159
+ }
160
+
161
+ function estMesure(valeur: number | null | undefined): valeur is number {
162
+ return typeof valeur === 'number' && Number.isFinite(valeur) && valeur > 0
163
+ }
164
+
165
+ function texteOuRien(valeur: unknown, longueur: number): string | undefined {
166
+ if (typeof valeur !== 'string') return undefined
167
+ const detoure = valeur.trim()
168
+ return detoure === '' ? undefined : borner(detoure, longueur)
169
+ }
170
+
171
+ function borner(texte: string, longueur: number): string {
172
+ return texte.length <= longueur ? texte : `${texte.slice(0, longueur - 1)}…`
173
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,241 @@
1
+ 'use client'
2
+
3
+ // ============================================================================
4
+ // @zevra/support-widget — le bouton « Aide » et son tiroir.
5
+ // ============================================================================
6
+ //
7
+ // ⚠️ CE PAQUET NE CONNAÎT AUCUNE CLÉ ET AUCUNE URL DE SUPPORT. Il n'appelle
8
+ // que `/api/support/*` sur SON PROPRE domaine — les routes que
9
+ // `creerRoutesSupport()` de `@zevra/support` installe côté serveur de l'app
10
+ // hôte. C'est ce relais qui détient `SUPPORT_API_KEY` et qui décide, à partir
11
+ // de la session de l'app, de qui parle. Tout ce qui traverse ce paquet est
12
+ // lisible dans l'onglet Réseau du premier visiteur venu ; la clé, non.
13
+ //
14
+ // Pose minimale, dans une app Next :
15
+ //
16
+ // 'use client'
17
+ // import { WidgetSupport } from '@zevra/support-widget'
18
+ // export function Aide() {
19
+ // return <WidgetSupport version={process.env.NEXT_PUBLIC_VERSION} />
20
+ // }
21
+
22
+ import { useEffect, useMemo, useState, type ReactNode } from 'react'
23
+
24
+ import { creerClientRelais, BASE_PAR_DEFAUT, type ClientRelais } from './client-relais'
25
+ import { Tiroir } from './composants/tiroir'
26
+ import { injecterFeuille } from './styles'
27
+ import { installerTamponErreurs } from './tampon-erreurs'
28
+ import type { TypeDemande } from './types'
29
+
30
+ export interface ProprietesWidgetSupport {
31
+ /** Base des routes de relais dans VOTRE app. Défaut : `/api/support`. */
32
+ base?: string
33
+ /** Libellé du bouton flottant. */
34
+ libelle?: string
35
+ /** Titre du tiroir. */
36
+ titre?: string
37
+ position?:
38
+ | 'bas-droite'
39
+ | 'bas-gauche'
40
+ | 'haut-droite'
41
+ | 'haut-gauche'
42
+ | 'haut-centre'
43
+ | 'bas-centre'
44
+ /**
45
+ * Décalage vertical du bouton, en pixels.
46
+ *
47
+ * ⚠️ Il existe pour les barres fixes. Posé en haut sans décalage, le bouton
48
+ * recouvre l'en-tête de l'app hôte — dont ce paquet ne connaît ni la hauteur
49
+ * ni l'existence. C'est à l'app de la donner ; 20 px par défaut, comme en bas.
50
+ */
51
+ decalage?: number
52
+ /**
53
+ * Bouton discret : filet et papier au lieu du bouton plein.
54
+ *
55
+ * ⚠️ À employer quand le widget est posé sur une app dont il n'est PAS
56
+ * l'action principale — c'est-à-dire presque toujours. La charte n'admet
57
+ * qu'un seul bouton plein par écran, et ce bouton-là appartient à l'app
58
+ * hôte, jamais à son bouton d'aide.
59
+ */
60
+ discret?: boolean
61
+ /**
62
+ * Ce qui s'affiche DANS le bouton à la place du libellé — un logo, en
63
+ * général.
64
+ *
65
+ * ⚠️ LE WIDGET NE PORTE AUCUN LOGO, et n'en portera pas : une marque
66
+ * recopiée dans un paquet est une marque qui diverge de la charte à la
67
+ * première évolution, et qui pèse dans le bundle de toutes les apps.
68
+ * L'app hôte passe le sien, depuis le paquet ou ses propres fichiers.
69
+ *
70
+ * ⚠️ `libelle` reste OBLIGATOIRE quand on pose une marque : il devient le
71
+ * nom accessible du bouton. Une image seule, sans nom, est un bouton muet
72
+ * pour un lecteur d'écran.
73
+ */
74
+ marque?: ReactNode
75
+ /** La version de votre app, jointe au contexte technique. */
76
+ version?: string | null
77
+ /** Les types proposés. Retirez `idee` si vous n'en voulez pas. */
78
+ typesOfferts?: readonly TypeDemande[]
79
+ /** Affiche l'onglet « Bêta ». Défaut : oui — il se tait s'il n'y a rien. */
80
+ betaOfferte?: boolean
81
+ /** Propose la capture d'écran. Défaut : oui. */
82
+ captureOfferte?: boolean
83
+ /** Ce que VOUS voulez joindre en plus (dossier courant, rôle, drapeau…). */
84
+ contexteSupplementaire?: () => Record<string, unknown>
85
+ /** Client injecté — pour les tests, ou pour un transport particulier. */
86
+ client?: ClientRelais
87
+ onOuvrir?: () => void
88
+ onEnvoye?: (reference: string) => void
89
+ }
90
+
91
+ const TYPES_PAR_DEFAUT: readonly TypeDemande[] = ['bug', 'question', 'idee']
92
+
93
+ /** Le bord auquel le bouton s'accroche. Le côté, lui, se lit dans le suffixe. */
94
+ function bordVertical(position: string): 'haut' | 'bas' {
95
+ return position.startsWith('haut') ? 'haut' : 'bas'
96
+ }
97
+
98
+ /** Le côté : centre, gauche, ou droite par défaut. */
99
+ function coteHorizontal(position: string): 'centre' | 'gauche' | 'droite' {
100
+ if (position.endsWith('centre')) return 'centre'
101
+ return position.endsWith('gauche') ? 'gauche' : 'droite'
102
+ }
103
+
104
+ export function WidgetSupport({
105
+ base = BASE_PAR_DEFAUT,
106
+ libelle = 'Aide',
107
+ titre = 'Aide et retours',
108
+ position = 'bas-droite',
109
+ decalage,
110
+ discret,
111
+ marque,
112
+ version,
113
+ typesOfferts = TYPES_PAR_DEFAUT,
114
+ betaOfferte = true,
115
+ captureOfferte = true,
116
+ contexteSupplementaire,
117
+ client: clientInjecte,
118
+ onOuvrir,
119
+ onEnvoye,
120
+ }: ProprietesWidgetSupport) {
121
+ const [ouvert, setOuvert] = useState(false)
122
+ const client = useMemo(
123
+ () => clientInjecte ?? creerClientRelais(base),
124
+ [clientInjecte, base],
125
+ )
126
+
127
+ useEffect(() => {
128
+ injecterFeuille(typeof document === 'undefined' ? null : document)
129
+ // ⚠️ Filet de sécurité, PAS la pose recommandée. Posé ici, le tampon
130
+ // manque les erreurs du démarrage de l'app — précisément celles qu'on
131
+ // signale. La pose se fait dans l'entrée de l'app hôte (voir
132
+ // `installerTamponErreurs` et docs/integration.md) ; celle-ci ne sert que
133
+ // si personne ne l'a faite. La fonction est idempotente.
134
+ installerTamponErreurs()
135
+ }, [])
136
+
137
+ return (
138
+ <>
139
+ <button
140
+ type="button"
141
+ className={[
142
+ 'zvs-racine',
143
+ 'zvs-lanceur',
144
+ `zvs-lanceur--${bordVertical(position)}`,
145
+ `zvs-lanceur--${coteHorizontal(position)}`,
146
+ discret ? 'zvs-lanceur--discret' : '',
147
+ ]
148
+ .filter((c) => c !== '')
149
+ .join(' ')}
150
+ style={
151
+ typeof decalage === 'number'
152
+ ? { [bordVertical(position) === 'haut' ? 'top' : 'bottom']: `${decalage}px` }
153
+ : undefined
154
+ }
155
+ // Une image seule ne nomme pas le bouton : le libellé devient le nom
156
+ // accessible dès qu'il n'est plus écrit.
157
+ aria-label={marque ? libelle : undefined}
158
+ aria-haspopup="dialog"
159
+ aria-expanded={ouvert}
160
+ onClick={() => {
161
+ setOuvert(true)
162
+ onOuvrir?.()
163
+ }}
164
+ >
165
+ {marque ? <span className="zvs-marque">{marque}</span> : libelle}
166
+ </button>
167
+
168
+ {/* Le tiroir n'est PAS rendu quand il est fermé : son contenu interroge
169
+ le relais au montage (liste des demandes, campagne de bêta). Rendu en
170
+ permanence, il appellerait deux routes à chaque chargement de page
171
+ pour un panneau que personne n'a ouvert. */}
172
+ {ouvert ? (
173
+ <Tiroir
174
+ client={client}
175
+ titre={titre}
176
+ typesOfferts={typesOfferts}
177
+ version={version}
178
+ contexteSupplementaire={contexteSupplementaire}
179
+ betaOfferte={betaOfferte}
180
+ captureOfferte={captureOfferte}
181
+ onFermer={() => setOuvert(false)}
182
+ onEnvoye={onEnvoye}
183
+ />
184
+ ) : null}
185
+ </>
186
+ )
187
+ }
188
+
189
+ export { creerClientRelais, ErreurRelais, BASE_PAR_DEFAUT } from './client-relais'
190
+ export type { ClientRelais, DescripteurPiece } from './client-relais'
191
+
192
+ export {
193
+ TamponErreurs,
194
+ TAILLE_TAMPON,
195
+ formaterArgument,
196
+ formaterEntree,
197
+ installerTamponErreurs,
198
+ tamponErreurs,
199
+ } from './tampon-erreurs'
200
+
201
+ export { deduireOs, formaterViewport, nettoyerUrl, relever } from './contexte'
202
+ export type { SourceContexte } from './contexte'
203
+
204
+ export {
205
+ ID_FEUILLE,
206
+ PREFIXE,
207
+ classesDeLaFeuille,
208
+ feuilleDeStyle,
209
+ injecterFeuille,
210
+ referencesDeTokens,
211
+ } from './styles'
212
+
213
+ export {
214
+ LONGUEUR_DESCRIPTION,
215
+ LONGUEUR_TITRE,
216
+ estTypeDemande,
217
+ signalementValide,
218
+ typesAfficher,
219
+ verifierSignalement,
220
+ } from './validation'
221
+ export type { SaisieSignalement } from './validation'
222
+
223
+ export { capturerEcran, decoderDataUrl, deposerBinaire, TYPE_MIME_CAPTURE } from './capture'
224
+ export type { CaptureFaite, OptionsCapture, ResultatCapture } from './capture'
225
+
226
+ export { COTE_MINIMAL, choisirZone, rectangleEntre, zoneDansImage, zoneExploitable } from './zone'
227
+ export type { EntreeZoneDansImage, Point, Rectangle } from './zone'
228
+
229
+ export { LIBELLES_STATUT, LIBELLES_TYPE } from './types'
230
+ export type {
231
+ AuteurMessage,
232
+ ContexteReleve,
233
+ DemandeLue,
234
+ MessageLu,
235
+ PieceLue,
236
+ ProgrammeActif,
237
+ ResumeDemande,
238
+ ScenarioCampagne,
239
+ StatutDemande,
240
+ TypeDemande,
241
+ } from './types'
package/src/styles.ts ADDED
@@ -0,0 +1,211 @@
1
+ // ============================================================================
2
+ // L'habillage du widget. PUR (la feuille), impur (la pose dans le document).
3
+ // ============================================================================
4
+ //
5
+ // DEUX CONTRAINTES QUI SE CONTREDISENT, ET COMMENT ELLES SE RÉSOLVENT ICI.
6
+ //
7
+ // 1. Le widget doit porter l'identité Zevra quand l'app hôte a `@zevra/ui`.
8
+ // 2. Il doit rester lisible quand elle ne l'a pas — et sans conflit : il est
9
+ // posé DANS l'app de quelqu'un d'autre, dont il ne connaît ni les classes,
10
+ // ni la spécificité, ni l'ordre de chargement.
11
+ //
12
+ // La solution n'est ni d'importer `@zevra/ui` (ce serait une dépendance dure
13
+ // sur un paquet que l'app n'a peut-être pas, et une feuille entière chargée
14
+ // pour un tiroir), ni de réutiliser ses classes `zv-` (elles peuvent ne pas
15
+ // exister, et le widget se rendrait nu).
16
+ //
17
+ // Elle est celle-ci : **une feuille à nous, entièrement préfixée `zvs-`, dont
18
+ // chaque valeur de marque lit un token Zevra AVEC UN REPLI LITTÉRAL.**
19
+ //
20
+ // color: var(--ink, #0b1020);
21
+ //
22
+ // Là où le design system est chargé, `--ink` existe et le widget est dans les
23
+ // couleurs de la maison, y compris après un changement de charte. Là où il ne
24
+ // l'est pas, le repli s'applique et rien n'est invisible. Aucune classe de
25
+ // l'hôte n'est lue, aucune classe `zvs-` n'existe ailleurs : la collision est
26
+ // impossible dans les deux sens.
27
+ //
28
+ // ⚠️ UN `var()` SANS REPLI EST UN BUG SILENCIEUX : dans une app sans le design
29
+ // system, la propriété devient invalide et la couleur retombe sur l'héritage —
30
+ // du texte blanc sur blanc, un bouton sans fond. Rien ne le signale, et ça ne
31
+ // se voit pas chez nous (nos apps ont toutes le paquet). C'est le test
32
+ // `styles.test.ts` qui tient cette règle.
33
+
34
+ /** Préfixe unique du widget. Aucune classe de l'hôte ne commence par là. */
35
+ export const PREFIXE = 'zvs-'
36
+
37
+ /** Identifiant de la balise `<style>` : c'est lui qui rend la pose idempotente. */
38
+ export const ID_FEUILLE = 'zvs-support-widget'
39
+
40
+ /**
41
+ * La feuille. PUR : une chaîne, toujours la même.
42
+ *
43
+ * ⚠️ Les replis ne contiennent AUCUNE parenthèse : `referencesDeTokens` les
44
+ * relit avec une expression régulière simple, et un `rgb(…)` en repli la
45
+ * ferait passer à côté d'une référence — donc à côté du bug qu'elle garde.
46
+ */
47
+ export function feuilleDeStyle(): string {
48
+ return `
49
+ .zvs-racine { font-family: var(--font-text, system-ui, sans-serif); font-size: 14px; line-height: 1.5; color: var(--ink, #0b1020); }
50
+ .zvs-racine *, .zvs-racine *::before, .zvs-racine *::after { box-sizing: border-box; }
51
+
52
+ .zvs-lanceur { position: fixed; z-index: 2147483000; display: inline-flex; align-items: center; gap: 8px; min-height: var(--tap, 40px); padding: 0 16px; border: 1px solid var(--accent, #2a2aa8); border-radius: var(--r-btn, 0); background: var(--accent, #2a2aa8); color: var(--white, #ffffff); font: inherit; font-weight: 600; cursor: pointer; }
53
+ .zvs-lanceur:hover { background: var(--accent-deep, #1e1b8c); border-color: var(--accent-deep, #1e1b8c); }
54
+ .zvs-lanceur:focus-visible { outline: 2px solid var(--accent-deep, #1e1b8c); outline-offset: 2px; }
55
+ .zvs-lanceur--droite { right: 20px; }
56
+ .zvs-lanceur--gauche { left: 20px; }
57
+ /* Le bord vertical est porte par un modificateur et non par la regle de base :
58
+ une app qui pose le bouton en haut a souvent deja une barre fixe, et son
59
+ decalage se regle alors en ligne, par la propriete du meme nom. */
60
+ .zvs-lanceur--bas { bottom: 20px; }
61
+ .zvs-lanceur--haut { top: 20px; }
62
+ /* Centre : la moitie de sa propre largeur, pas celle de la fenetre — un
63
+ left: 50% seul decale le bouton vers la droite de sa demi-largeur. */
64
+ .zvs-lanceur--centre { left: 50%; transform: translateX(-50%); }
65
+ /* Discret : le bouton se pose sans reclamer l'attention. Les regles viennent
66
+ APRES celles du bouton plein, meme specificite : c'est l'ordre qui tranche. */
67
+ .zvs-lanceur--discret { background: var(--paper, #f5f6fa); border-color: var(--line, #e3e6ef); color: var(--muted, #5a637a); font-weight: 500; }
68
+ .zvs-lanceur--discret:hover { background: var(--white, #ffffff); border-color: var(--accent, #2a2aa8); color: var(--ink, #0b1020); }
69
+ /* La marque de l'app hote. Le widget ne porte AUCUN logo : il reserve la
70
+ place et laisse l'app poser le sien, en image ou en SVG. */
71
+ .zvs-marque { display: inline-flex; align-items: center; }
72
+ .zvs-marque img, .zvs-marque svg { display: block; height: 18px; width: auto; }
73
+
74
+ /* ── Le trace d'une zone a capturer ──────────────────────────────────
75
+ Au-dessus du tiroir : pendant le trace, le widget est masque et cette
76
+ surface est la seule chose vivante de la page.
77
+ ⚠️ PREFIXE zvs-selection ET NON zvs-zone : la classe zvs-zone designe
78
+ depuis toujours la ZONE DE TEXTE du formulaire. Une regle de voile posee
79
+ sous ce nom rend le champ fixe et plein ecran — un ecran blanc, sans la
80
+ moindre erreur en console (constate le 23/09/2026). */
81
+ .zvs-selection { position: fixed; inset: 0; z-index: 2147483003; cursor: crosshair; touch-action: none; }
82
+ .zvs-selection__voile { position: absolute; inset: 0; background: var(--ink, #0b1020); opacity: 0.38; pointer-events: none; }
83
+ .zvs-selection__consigne { position: absolute; top: 20px; left: 50%; transform: translateX(-50%); margin: 0; padding: 8px 14px; max-width: 92vw; background: var(--white, #ffffff); border: 1px solid var(--line, #e3e6ef); border-radius: var(--r-btn, 0); color: var(--ink, #0b1020); font-family: var(--font-text, system-ui, sans-serif); font-size: 13px; text-align: center; pointer-events: none; }
84
+ /* Filet d'accent DOUBLE d'un filet blanc : le trace passe aussi bien sur une
85
+ zone sombre que sur une zone claire, sans couleur semi-transparente. */
86
+ .zvs-selection__trace { position: absolute; border: 2px solid var(--accent, #2a2aa8); outline: 1px solid var(--white, #ffffff); pointer-events: none; }
87
+
88
+ /* Voile : couleur du token d'encre + opacite, jamais une couleur
89
+ semi-transparente ecrite en dur (ce serait une couleur en dur), et jamais
90
+ color-mix, qui ne se replie sur rien la ou le design system manque. */
91
+ .zvs-voile { position: fixed; inset: 0; z-index: 2147483001; background: var(--ink, #0b1020); opacity: 0.32; }
92
+ .zvs-tiroir { position: fixed; inset: 0 0 0 auto; z-index: 2147483002; display: flex; flex-direction: column; width: min(420px, 100vw); max-width: 100vw; background: var(--white, #ffffff); border-left: 1px solid var(--line, #e3e6ef); }
93
+ @media (max-width: 480px) { .zvs-tiroir { inset: auto 0 0 0; height: 92vh; border-left: none; border-top: 1px solid var(--line, #e3e6ef); } }
94
+
95
+ .zvs-tete { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 14px 16px; border-bottom: 1px solid var(--line, #e3e6ef); }
96
+ .zvs-titre { margin: 0; font-family: var(--font-display, inherit); font-size: 17px; font-weight: 500; }
97
+ .zvs-fermer { min-width: var(--tap, 40px); min-height: var(--tap, 40px); border: 1px solid transparent; border-radius: var(--r-btn, 0); background: transparent; color: var(--muted, #5a637a); font: inherit; font-size: 18px; cursor: pointer; }
98
+ .zvs-fermer:hover { border-color: var(--line, #e3e6ef); color: var(--ink, #0b1020); }
99
+
100
+ .zvs-onglets { display: flex; gap: 0; padding: 0 16px; border-bottom: 1px solid var(--line, #e3e6ef); overflow-x: auto; }
101
+ .zvs-onglet { flex: none; min-height: var(--tap, 40px); padding: 0 12px; border: none; border-bottom: 2px solid transparent; background: transparent; color: var(--muted, #5a637a); font: inherit; font-weight: 600; cursor: pointer; white-space: nowrap; }
102
+ .zvs-onglet[aria-selected='true'] { color: var(--accent, #2a2aa8); border-bottom-color: var(--accent, #2a2aa8); }
103
+
104
+ .zvs-corps { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 14px; }
105
+ .zvs-pied { flex: none; padding: 12px 16px; border-top: 1px solid var(--line, #e3e6ef); display: flex; gap: 8px; justify-content: flex-end; flex-wrap: wrap; }
106
+
107
+ .zvs-champ { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
108
+ .zvs-label { font-family: var(--font-mono, inherit); font-size: 10px; font-weight: 600; letter-spacing: 0.18em; text-transform: uppercase; color: var(--label, #8a93a8); }
109
+ .zvs-saisie, .zvs-zone, .zvs-liste-deroulante { width: 100%; min-width: 0; padding: 9px 10px; border: 1px solid var(--line, #e3e6ef); border-radius: var(--r-btn, 0); background: var(--white, #ffffff); color: var(--ink, #0b1020); font: inherit; }
110
+ .zvs-saisie:focus, .zvs-zone:focus, .zvs-liste-deroulante:focus { outline: none; border-color: var(--accent, #2a2aa8); }
111
+ .zvs-zone { min-height: 110px; resize: vertical; }
112
+ .zvs-saisie[aria-invalid='true'], .zvs-zone[aria-invalid='true'] { border-color: var(--err, #b5202c); }
113
+ .zvs-erreur { color: var(--err, #b5202c); font-size: 12px; }
114
+ .zvs-aide { color: var(--muted, #5a637a); font-size: 12px; }
115
+
116
+ .zvs-bouton { min-height: var(--tap, 40px); padding: 0 14px; border: 1px solid var(--accent, #2a2aa8); border-radius: var(--r-btn, 0); background: var(--accent, #2a2aa8); color: var(--white, #ffffff); font: inherit; font-weight: 600; cursor: pointer; }
117
+ .zvs-bouton:hover { background: var(--accent-deep, #1e1b8c); }
118
+ .zvs-bouton[disabled], .zvs-bouton[aria-disabled='true'] { opacity: 0.55; cursor: default; }
119
+ .zvs-bouton--fantome { background: transparent; color: var(--ink, #0b1020); border-color: var(--line, #e3e6ef); }
120
+ .zvs-bouton--fantome:hover { background: var(--paper, #f5f6fa); }
121
+
122
+ .zvs-carte { border: 1px solid var(--line, #e3e6ef); border-radius: var(--r-card, 0); background: var(--white, #ffffff); padding: 12px; display: flex; flex-direction: column; gap: 6px; min-width: 0; }
123
+ .zvs-carte--cliquable { cursor: pointer; text-align: left; font: inherit; color: inherit; width: 100%; }
124
+ .zvs-carte--cliquable:hover { border-color: var(--accent-line, #c9d6f5); background: var(--panel, #fbfcfe); }
125
+ .zvs-reference { font-family: var(--font-mono, inherit); font-size: 11px; letter-spacing: 0.12em; color: var(--label, #8a93a8); }
126
+ .zvs-ligne { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; min-width: 0; }
127
+ .zvs-tronque { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
128
+ .zvs-coupe { overflow-wrap: anywhere; word-break: break-word; min-width: 0; }
129
+
130
+ .zvs-pastille { display: inline-flex; align-items: center; padding: 2px 7px; border: 1px solid var(--line, #e3e6ef); border-radius: var(--r-pill, 0); background: var(--paper, #f5f6fa); color: var(--muted, #5a637a); font-family: var(--font-mono, inherit); font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; }
131
+ .zvs-pastille--ouverte { background: var(--warn-soft, #faf3e2); border-color: var(--warn-line, #ebdcb4); color: var(--warn-ink, #8a6d1e); }
132
+ .zvs-pastille--resolue { background: var(--ok-soft, #e6f3ec); border-color: var(--ok-line, #bfdccb); color: var(--ok, #1c6b4a); }
133
+ .zvs-pastille--attente { background: var(--accent-soft, #eaf1ff); border-color: var(--accent-line, #c9d6f5); color: var(--accent, #2a2aa8); }
134
+
135
+ .zvs-message { border-left: 2px solid var(--line, #e3e6ef); padding-left: 10px; display: flex; flex-direction: column; gap: 4px; }
136
+ .zvs-message--equipe { border-left-color: var(--accent, #2a2aa8); }
137
+ .zvs-message__corps { white-space: pre-wrap; overflow-wrap: anywhere; }
138
+ .zvs-meta { color: var(--muted, #5a637a); font-size: 12px; }
139
+
140
+ .zvs-alerte { border: 1px solid var(--line, #e3e6ef); border-left-width: 3px; border-radius: var(--r-card, 0); padding: 10px 12px; background: var(--paper, #f5f6fa); }
141
+ .zvs-alerte--err { background: var(--err-soft, #fdece9); border-color: var(--err-line, #efc9cd); border-left-color: var(--err, #b5202c); }
142
+ .zvs-alerte--ok { background: var(--ok-soft, #e6f3ec); border-color: var(--ok-line, #bfdccb); border-left-color: var(--ok, #1c6b4a); }
143
+
144
+ .zvs-vide { color: var(--muted, #5a637a); text-align: center; padding: 24px 8px; }
145
+ .zvs-repli { border: 1px solid var(--line, #e3e6ef); border-radius: var(--r-card, 0); padding: 8px 10px; }
146
+ .zvs-repli summary { cursor: pointer; font-family: var(--font-mono, inherit); font-size: 10px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--label, #8a93a8); }
147
+ .zvs-code { margin: 8px 0 0; padding: 8px; background: var(--paper, #f5f6fa); font-family: var(--font-code, monospace); font-size: 11px; white-space: pre-wrap; overflow-wrap: anywhere; max-height: 180px; overflow: auto; }
148
+ .zvs-vignette { display: block; max-width: 100%; height: auto; border: 1px solid var(--line, #e3e6ef); }
149
+ .zvs-scenario { display: flex; flex-direction: column; gap: 6px; border-top: 1px solid var(--hairline, #eef1f8); padding-top: 10px; }
150
+ .zvs-etapes { margin: 0; padding-left: 18px; color: var(--muted, #5a637a); font-size: 13px; }
151
+ .zvs-sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
152
+ `.trim()
153
+ }
154
+
155
+ export interface ReferenceToken {
156
+ propriete: string
157
+ repli: string | null
158
+ }
159
+
160
+ /**
161
+ * Relève toutes les références `var(--…)` d'une feuille et dit si chacune a un
162
+ * repli. PUR — c'est la fonction que le test interroge.
163
+ */
164
+ export function referencesDeTokens(css: string): ReferenceToken[] {
165
+ const references: ReferenceToken[] = []
166
+ const motif = /var\(\s*(--[\w-]+)\s*([^)]*)\)/g
167
+ let trouve: RegExpExecArray | null
168
+ while ((trouve = motif.exec(css)) !== null) {
169
+ const suite = trouve[2].trim()
170
+ const repli = suite.startsWith(',') ? suite.slice(1).trim() : null
171
+ references.push({ propriete: trouve[1], repli: repli === '' ? null : repli })
172
+ }
173
+ return references
174
+ }
175
+
176
+ /**
177
+ * Relève les classes employées par une feuille. PUR.
178
+ *
179
+ * Sert au test qui interdit toute classe hors du préfixe : une règle sur
180
+ * `.card` ou `.bouton` repeindrait les éléments de l'app hôte, depuis un
181
+ * paquet qu'elle a installé pour un bouton d'aide.
182
+ */
183
+ export function classesDeLaFeuille(css: string): string[] {
184
+ const classes = new Set<string>()
185
+ const motif = /\.(-?[_a-zA-Z][\w-]*)/g
186
+ let trouve: RegExpExecArray | null
187
+ while ((trouve = motif.exec(css)) !== null) classes.add(trouve[1])
188
+ return [...classes].sort()
189
+ }
190
+
191
+ interface DocumentObserve {
192
+ getElementById(id: string): unknown
193
+ createElement(balise: string): { id: string; textContent: string | null }
194
+ head: { appendChild(noeud: unknown): unknown } | null
195
+ }
196
+
197
+ /**
198
+ * Pose la feuille dans le document, une fois. IMPUR.
199
+ *
200
+ * ⚠️ Idempotent par l'identifiant : le widget peut être monté deux fois (un
201
+ * rendu strict de React le fait exprès en développement), et deux feuilles
202
+ * identiques ne cassent rien mais s'accumulent à chaque remontage.
203
+ */
204
+ export function injecterFeuille(document: DocumentObserve | null | undefined): void {
205
+ if (!document || typeof document.getElementById !== 'function') return
206
+ if (document.getElementById(ID_FEUILLE)) return
207
+ const balise = document.createElement('style')
208
+ balise.id = ID_FEUILLE
209
+ balise.textContent = feuilleDeStyle()
210
+ document.head?.appendChild(balise)
211
+ }