@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.
- package/README.md +80 -0
- package/package.json +26 -0
- package/src/capture.ts +260 -0
- package/src/client-relais.ts +130 -0
- package/src/composants/briques.tsx +146 -0
- package/src/composants/onglet-beta.tsx +156 -0
- package/src/composants/onglet-mes-demandes.tsx +256 -0
- package/src/composants/onglet-signaler.tsx +369 -0
- package/src/composants/tiroir.tsx +163 -0
- package/src/contexte.ts +173 -0
- package/src/index.tsx +241 -0
- package/src/styles.ts +211 -0
- package/src/tampon-erreurs.ts +211 -0
- package/src/types.ts +105 -0
- package/src/validation.ts +113 -0
- package/src/zone.ts +284 -0
package/src/contexte.ts
ADDED
|
@@ -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
|
+
}
|