@tekkare/romulus 0.1.148 → 0.1.150

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/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": ">=3.16.0"
6
6
  },
7
- "version": "0.1.147",
7
+ "version": "0.1.149",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "0.8.4",
10
10
  "unbuild": "2.0.0"
@@ -192,12 +192,16 @@ onBeforeUnmount(() => {
192
192
  <span class="rm-rail__caption">{{ profileLabel }}</span>
193
193
  </NuxtLink>
194
194
 
195
+ <!-- La cle de la tuile sort aussi en attribut : une visite guidee designe
196
+ une commande par selecteur (`[data-tile="Views"]`), et le rail est le
197
+ seul endroit qui sait quelle tuile porte quelle cle. -->
195
198
  <button
196
199
  v-for="tile in tiles"
197
200
  :key="tile.key"
198
201
  class="rm-rail__item"
199
202
  :class="{ 'rm-rail__item--active': active === tile.key }"
200
203
  type="button"
204
+ :data-tile="tile.key"
201
205
  :title="tileTitle(tile)"
202
206
  :aria-pressed="active === tile.key"
203
207
  @click="emit('select', tile.key)"
@@ -0,0 +1,413 @@
1
+ <!--
2
+ RmTour - la visite guidee : le moteur qui montre une partie de l'ecran et
3
+ explique ce qu'elle fait.
4
+
5
+ Une note explique une page, une visite explique un geste. Les deux repondent
6
+ a des questions differentes : « d'ou vient ce chiffre » se lit dans
7
+ `RmNotes`, « comment j'enregistre mes filtres » ne se lit nulle part, parce
8
+ que la reponse tient dans un enchainement de trois commandes posees a trois
9
+ endroits du cadre. Le texte seul obligerait le lecteur a les retrouver ; la
10
+ visite les designe.
11
+
12
+ Le composant ne connait ni l'app ni ses ecrans. Une etape porte un selecteur
13
+ CSS, et c'est tout ce qui la relie a la page : le moteur cherche le noeud, le
14
+ detoure, pose sa bulle a cote et laisse l'appelant decider du reste. Une
15
+ etape peut donc viser le cadre (une tuile du rail) comme le contenu (une
16
+ carte), sans que le moteur ait a distinguer les deux.
17
+
18
+ L'appelant garde l'index de l'etape (`v-model:step`) : c'est lui qui sait ce
19
+ qu'il faut ouvrir pour qu'une cible existe — un panneau replie n'a pas de
20
+ noeud a detourer. Le moteur, lui, attend : tant que le selecteur ne rend
21
+ rien, la bulle reste centree et reprend sa place des que la cible parait.
22
+ -->
23
+ <script setup lang="ts">
24
+ import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
25
+ import type { RmTourStep } from '../utils/tours'
26
+
27
+ export type { RmTourStep }
28
+
29
+ const props = withDefaults(defineProps<{
30
+ steps: RmTourStep[]
31
+ open?: boolean
32
+ /** Nom de la visite, rappele au-dessus du titre de l'etape. */
33
+ title?: string
34
+ lang?: 'fr' | 'en'
35
+ }>(), {
36
+ open: false,
37
+ title: undefined,
38
+ lang: 'fr',
39
+ })
40
+
41
+ /** Index de l'etape affichee. Tenu par l'appelant : cf. l'en-tete. */
42
+ const step = defineModel<number>('step', { default: 0 })
43
+
44
+ const emit = defineEmits<{
45
+ /** Visite quittee avant la fin (croix, Echap, « Quitter »). */
46
+ close: []
47
+ /** Derniere etape validee. */
48
+ finish: []
49
+ }>()
50
+
51
+ const WORDS = {
52
+ fr: {
53
+ prev: 'Précédent',
54
+ next: 'Suivant',
55
+ finish: 'Terminer',
56
+ quit: 'Quitter la visite',
57
+ step: 'Étape',
58
+ of: 'sur',
59
+ },
60
+ en: {
61
+ prev: 'Back',
62
+ next: 'Next',
63
+ finish: 'Done',
64
+ quit: 'Leave the tour',
65
+ step: 'Step',
66
+ of: 'of',
67
+ },
68
+ } as const
69
+
70
+ const T = computed(() => WORDS[props.lang] ?? WORDS.fr)
71
+
72
+ const current = computed<RmTourStep | undefined>(() => props.steps[step.value])
73
+ const isLast = computed(() => step.value >= props.steps.length - 1)
74
+
75
+ /** Cadre de la cible, en coordonnees de fenetre. Nul tant qu'elle manque. */
76
+ const spot = ref<{ top: number, left: number, width: number, height: number } | null>(null)
77
+ const card = ref<HTMLElement | null>(null)
78
+ const cardSize = ref({ width: 340, height: 220 })
79
+
80
+ const PAD = 8
81
+ const GAP = 14
82
+ /** Marge minimale entre la bulle et le bord de la fenetre. */
83
+ const EDGE = 16
84
+
85
+ /**
86
+ * Le noeud vise. Une etape peut donner plusieurs selecteurs separes par une
87
+ * virgule : ils sont essayes DANS L'ORDRE ECRIT, du plus precis au repli.
88
+ * `document.querySelector` avec la meme liste rendrait le premier noeud dans
89
+ * l'ordre du document, donc souvent le repli — un panneau plutot que la liste
90
+ * qu'il contient.
91
+ */
92
+ function findTarget(selector: string): HTMLElement | null {
93
+ for (const part of selector.split(',')) {
94
+ const node = document.querySelector(part.trim()) as HTMLElement | null
95
+ if (node) return node
96
+ }
97
+ return null
98
+ }
99
+
100
+ function measure() {
101
+ const selector = current.value?.target
102
+ if (!selector) {
103
+ spot.value = null
104
+ return
105
+ }
106
+ const node = findTarget(selector)
107
+ if (!node) {
108
+ spot.value = null
109
+ return
110
+ }
111
+ const rect = node.getBoundingClientRect()
112
+ // Un noeud replie (panneau ferme, transition en cours) mesure zero : le
113
+ // detourer dessinerait un point au coin de l'ecran. On le traite comme
114
+ // absent, et la mesure suivante le rattrapera quand il se sera deploye.
115
+ if (rect.width < 2 || rect.height < 2) {
116
+ spot.value = null
117
+ return
118
+ }
119
+ const pad = current.value?.padding ?? PAD
120
+ spot.value = {
121
+ top: rect.top - pad,
122
+ left: rect.left - pad,
123
+ width: rect.width + pad * 2,
124
+ height: rect.height + pad * 2,
125
+ }
126
+ const box = card.value?.getBoundingClientRect()
127
+ if (box) cardSize.value = { width: box.width, height: box.height }
128
+ }
129
+
130
+ /**
131
+ * La mesure tourne image par image tant que la visite est ouverte.
132
+ *
133
+ * Les cibles bougent sans evenement qu'on puisse ecouter : le panneau du rail
134
+ * s'ouvre en glissant, la page se refloue derriere lui, une carte se charge et
135
+ * grandit. Ecouter `scroll` et `resize` laissait le detourage a cote de sa
136
+ * cible pendant toute la transition. Lire un `getBoundingClientRect` par image
137
+ * sur un seul noeud coute moins qu'un observateur par cause de mouvement.
138
+ */
139
+ let frame: number | null = null
140
+
141
+ function loop() {
142
+ measure()
143
+ frame = requestAnimationFrame(loop)
144
+ }
145
+
146
+ function start() {
147
+ if (frame === null) frame = requestAnimationFrame(loop)
148
+ }
149
+
150
+ function stop() {
151
+ if (frame !== null) cancelAnimationFrame(frame)
152
+ frame = null
153
+ }
154
+
155
+ /** La cible est amenee a l'ecran au changement d'etape, jamais apres. */
156
+ function reveal() {
157
+ const selector = current.value?.target
158
+ if (!selector) return
159
+ const node = findTarget(selector)
160
+ if (!node) return
161
+ const still = document.body.classList.contains('reduce-motion')
162
+ || window.matchMedia('(prefers-reduced-motion: reduce)').matches
163
+ node.scrollIntoView({ block: 'center', inline: 'nearest', behavior: still ? 'auto' : 'smooth' })
164
+ }
165
+
166
+ function onKeydown(event: KeyboardEvent) {
167
+ if (!props.open) return
168
+ if (event.key === 'Escape') {
169
+ event.preventDefault()
170
+ emit('close')
171
+ return
172
+ }
173
+ if (event.key === 'ArrowRight') {
174
+ event.preventDefault()
175
+ next()
176
+ return
177
+ }
178
+ if (event.key === 'ArrowLeft') {
179
+ event.preventDefault()
180
+ prev()
181
+ }
182
+ }
183
+
184
+ function next() {
185
+ if (isLast.value) {
186
+ emit('finish')
187
+ return
188
+ }
189
+ step.value = step.value + 1
190
+ }
191
+
192
+ function prev() {
193
+ if (step.value > 0) step.value = step.value - 1
194
+ }
195
+
196
+ watch(() => props.open, (open) => {
197
+ if (open) {
198
+ step.value = Math.min(Math.max(step.value, 0), Math.max(props.steps.length - 1, 0))
199
+ document.addEventListener('keydown', onKeydown)
200
+ start()
201
+ nextTick(() => {
202
+ reveal()
203
+ measure()
204
+ card.value?.focus()
205
+ })
206
+ }
207
+ else {
208
+ document.removeEventListener('keydown', onKeydown)
209
+ stop()
210
+ spot.value = null
211
+ }
212
+ }, { immediate: false })
213
+
214
+ // Chaque etape amene sa cible a l'ecran et reprend la main au clavier : la
215
+ // bulle est le seul element focalisable de la visite.
216
+ watch(step, () => {
217
+ if (!props.open) return
218
+ nextTick(() => {
219
+ reveal()
220
+ measure()
221
+ card.value?.focus()
222
+ })
223
+ })
224
+
225
+ onBeforeUnmount(() => {
226
+ document.removeEventListener('keydown', onKeydown)
227
+ stop()
228
+ })
229
+
230
+ /**
231
+ * Position de la bulle : le premier cote qui la contient entierement, en
232
+ * partant du cote demande. Sans cible, ou si aucun cote ne tient, elle est
233
+ * centree — et le detourage, lui, reste en place.
234
+ */
235
+ const cardStyle = computed(() => {
236
+ const rect = spot.value
237
+ const { width, height } = cardSize.value
238
+ const vw = typeof window === 'undefined' ? 1280 : window.innerWidth
239
+ const vh = typeof window === 'undefined' ? 800 : window.innerHeight
240
+
241
+ if (!rect || current.value?.placement === 'center') {
242
+ return {
243
+ top: `${Math.max(EDGE, (vh - height) / 2)}px`,
244
+ left: `${Math.max(EDGE, (vw - width) / 2)}px`,
245
+ }
246
+ }
247
+
248
+ const clamp = (value: number, max: number) =>
249
+ Math.min(Math.max(value, EDGE), Math.max(EDGE, max - EDGE))
250
+
251
+ const fits = {
252
+ bottom: vh - (rect.top + rect.height) - GAP >= height,
253
+ top: rect.top - GAP >= height,
254
+ right: vw - (rect.left + rect.width) - GAP >= width,
255
+ left: rect.left - GAP >= width,
256
+ }
257
+
258
+ const order: ('top' | 'bottom' | 'left' | 'right')[] = current.value?.placement
259
+ && current.value.placement !== 'center'
260
+ ? [current.value.placement, 'bottom', 'top', 'right', 'left']
261
+ : ['bottom', 'top', 'right', 'left']
262
+
263
+ const side = order.find(item => fits[item])
264
+
265
+ if (side === 'bottom' || side === 'top') {
266
+ const top = side === 'bottom' ? rect.top + rect.height + GAP : rect.top - GAP - height
267
+ return {
268
+ top: `${clamp(top, vh - height)}px`,
269
+ left: `${clamp(rect.left + rect.width / 2 - width / 2, vw - width)}px`,
270
+ }
271
+ }
272
+ if (side === 'right' || side === 'left') {
273
+ const left = side === 'right' ? rect.left + rect.width + GAP : rect.left - GAP - width
274
+ return {
275
+ top: `${clamp(rect.top + rect.height / 2 - height / 2, vh - height)}px`,
276
+ left: `${clamp(left, vw - width)}px`,
277
+ }
278
+ }
279
+
280
+ // Aucun cote ne tient (une cible qui occupe l'ecran) : la bulle se pose en
281
+ // bas a droite plutot que par-dessus le milieu de la cible.
282
+ return {
283
+ top: `${Math.max(EDGE, vh - height - EDGE)}px`,
284
+ left: `${Math.max(EDGE, vw - width - EDGE)}px`,
285
+ }
286
+ })
287
+
288
+ const spotStyle = computed(() => {
289
+ const rect = spot.value
290
+ if (!rect) return { display: 'none' }
291
+ return {
292
+ top: `${rect.top}px`,
293
+ left: `${rect.left}px`,
294
+ width: `${rect.width}px`,
295
+ height: `${rect.height}px`,
296
+ }
297
+ })
298
+
299
+ /**
300
+ * Les bandes du voile : au-dessus, en dessous, a gauche et a droite de la
301
+ * cible. Sans cible, une seule bande couvre l'ecran.
302
+ */
303
+ const veil = computed(() => {
304
+ const rect = spot.value
305
+ if (!rect) return [{ top: '0px', left: '0px', right: '0px', bottom: '0px' }]
306
+ return [
307
+ { top: '0px', left: '0px', right: '0px', height: `${Math.max(rect.top, 0)}px` },
308
+ {
309
+ top: `${rect.top + rect.height}px`,
310
+ left: '0px',
311
+ right: '0px',
312
+ bottom: '0px',
313
+ },
314
+ {
315
+ top: `${rect.top}px`,
316
+ left: '0px',
317
+ width: `${Math.max(rect.left, 0)}px`,
318
+ height: `${rect.height}px`,
319
+ },
320
+ {
321
+ top: `${rect.top}px`,
322
+ left: `${rect.left + rect.width}px`,
323
+ right: '0px',
324
+ height: `${rect.height}px`,
325
+ },
326
+ ]
327
+ })
328
+
329
+ /** `**gras**`, comme dans les notes. Rien d'autre n'est interprete. */
330
+ function rich(text: string) {
331
+ return text
332
+ .replace(/&/g, '&amp;')
333
+ .replace(/</g, '&lt;')
334
+ .replace(/\*\*(.+?)\*\*/g, '<b>$1</b>')
335
+ }
336
+ </script>
337
+
338
+ <template>
339
+ <Teleport to="body">
340
+ <div v-if="open && current" class="rm-tour" role="dialog" aria-modal="true" :aria-label="title">
341
+ <!-- Le voile est decoupe en quatre bandes autour de la cible, qui reste
342
+ donc visible et nette dans le trou, sans qu'on ait a deplacer son
343
+ noeud ni a lui poser un z-index. Une seule bande plein ecran quand
344
+ l'etape ne vise rien.
345
+
346
+ Il laisse passer les clics : la visite explique des gestes, elle ne
347
+ doit pas empecher de les faire. Quitter passe par « Quitter » ou
348
+ Echap, pas par un clic dans le vide. -->
349
+ <div
350
+ v-for="(part, index) in veil"
351
+ :key="index"
352
+ class="rm-tour__veil"
353
+ :style="part"
354
+ />
355
+
356
+ <!-- Le cadre de la cible, pose par-dessus les bandes : c'est lui qui
357
+ dit ce dont parle la bulle. -->
358
+ <div class="rm-tour__spot" :style="spotStyle" />
359
+
360
+ <div ref="card" class="rm-tour__card" :style="cardStyle" tabindex="-1">
361
+ <div class="rm-tour__head">
362
+ <p v-if="title" class="rm-tour__eyebrow">{{ title }}</p>
363
+ <button
364
+ class="rm-tour__close"
365
+ type="button"
366
+ :title="T.quit"
367
+ :aria-label="T.quit"
368
+ @click="emit('close')"
369
+ >
370
+ <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5">
371
+ <path d="M5 5l10 10M15 5L5 15" />
372
+ </svg>
373
+ </button>
374
+ </div>
375
+
376
+ <h2 class="rm-tour__title">{{ current.title }}</h2>
377
+ <!-- eslint-disable-next-line vue/no-v-html -->
378
+ <p class="rm-tour__text" v-html="rich(current.text)" />
379
+
380
+ <ul v-if="current.bullets?.length" class="rm-tour__list">
381
+ <!-- eslint-disable-next-line vue/no-v-html -->
382
+ <li v-for="(item, index) in current.bullets" :key="index" v-html="rich(item)" />
383
+ </ul>
384
+
385
+ <div class="rm-tour__foot">
386
+ <span class="rm-tour__count">{{ T.step }} {{ step + 1 }} {{ T.of }} {{ steps.length }}</span>
387
+
388
+ <div class="rm-tour__dots" aria-hidden="true">
389
+ <span
390
+ v-for="(item, index) in steps"
391
+ :key="index"
392
+ class="rm-tour__dot"
393
+ :class="{ 'rm-tour__dot--on': index === step, 'rm-tour__dot--done': index < step }"
394
+ />
395
+ </div>
396
+
397
+ <div class="rm-tour__actions">
398
+ <button v-if="step > 0" class="rm-tour__btn" type="button" @click="prev">
399
+ {{ T.prev }}
400
+ </button>
401
+ <button class="rm-tour__btn rm-tour__btn--primary" type="button" @click="next">
402
+ {{ isLast ? T.finish : T.next }}
403
+ </button>
404
+ </div>
405
+ </div>
406
+ </div>
407
+ </div>
408
+ </Teleport>
409
+ </template>
410
+
411
+ <style scoped>
412
+ .rm-tour{inset:0;position:fixed;z-index:900}.rm-tour__veil{backdrop-filter:blur(2px);-webkit-backdrop-filter:blur(2px);background:rgba(15,23,42,.45)}.rm-tour__spot,.rm-tour__veil{pointer-events:none;position:fixed}.rm-tour__spot{border:2px solid var(--color-secondary);border-radius:var(--radius-lg);transition:all var(--duration-fast) var(--ease-out)}.rm-tour__card{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-2xl);box-shadow:var(--shadow-lg);flex-direction:column;max-width:calc(100vw - 32px);outline:none;padding:var(--space-16);position:fixed;width:340px}.rm-tour__card,.rm-tour__head{display:flex;gap:var(--space-8)}.rm-tour__head{align-items:center;justify-content:space-between}.rm-tour__eyebrow{color:var(--color-secondary);font-size:var(--font-10);font-weight:var(--weight-semibold);letter-spacing:.08em;text-transform:uppercase}.rm-tour__close{align-items:center;background:none;border:none;border-radius:var(--radius-md);color:var(--text-muted);cursor:pointer;display:flex;height:24px;justify-content:center;margin-left:auto;padding:0;width:24px}.rm-tour__close:hover{background:var(--bg-surface-3);color:var(--text-primary)}.rm-tour__close svg{height:14px;width:14px}.rm-tour__title{color:var(--text-primary);font-size:var(--font-16);font-weight:var(--weight-semibold);line-height:1.3}.rm-tour__text{color:var(--text-secondary);font-size:var(--font-13);line-height:1.55}.rm-tour__list{color:var(--text-secondary);display:flex;flex-direction:column;font-size:var(--font-12);gap:var(--space-4);line-height:1.5;margin:0;padding-left:var(--space-16)}.rm-tour__foot{align-items:center;border-top:1px solid var(--border-default);display:flex;gap:var(--space-8);margin-top:var(--space-4);padding-top:var(--space-12)}.rm-tour__count{color:var(--text-muted);font-size:var(--font-11);font-variant-numeric:tabular-nums}.rm-tour__dots{display:flex;gap:4px}.rm-tour__dot{background:var(--border-default);border-radius:var(--radius-pill);height:5px;width:5px}.rm-tour__dot--done{background:var(--color-violet-pale)}.rm-tour__dot--on{background:var(--color-secondary);width:14px}.rm-tour__actions{display:flex;gap:var(--space-6);margin-left:auto}.rm-tour__btn{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-md);color:var(--text-secondary);cursor:pointer;font-family:inherit;font-size:var(--font-12);padding:5px var(--space-12)}.rm-tour__btn:hover{background:var(--bg-surface-3)}.rm-tour__btn--primary{background:var(--color-secondary);border-color:var(--color-secondary);color:#fff;font-weight:var(--weight-semibold)}.rm-tour__btn--primary:hover{background:var(--color-secondary);filter:brightness(1.05)}
413
+ </style>
@@ -0,0 +1,130 @@
1
+ <!--
2
+ RmTutorials - le panneau « Tutoriels » du rail : la liste des visites
3
+ guidees que l'ecran ouvert sait jouer.
4
+
5
+ C'est le pendant de `RmNotes` pour les gestes. La note repond a « d'ou vient
6
+ ce chiffre », le tutoriel a « comment je m'en sers » : il ne se lit pas, il
7
+ se joue, et c'est `RmTour` qui le joue. Ce panneau ne fait que presenter le
8
+ catalogue et rendre la main a l'appelant sur « Démarrer ».
9
+
10
+ Une visite peut ne pas etre jouable sur l'ecran affiche (elle designe une
11
+ commande que la page n'a pas). Elle reste alors listee, avec sa raison sous
12
+ elle : la masquer ferait croire que la fonction n'existe pas, alors que
13
+ c'est l'ecran qui ne la porte pas.
14
+
15
+ Les visites deja terminees gardent leur place et changent de bouton
16
+ (« Revoir ») : une visite se rejoue, ce n'est pas une case a cocher.
17
+ -->
18
+ <script setup lang="ts">
19
+ import { computed } from 'vue'
20
+ import type { RmTutorial } from '../utils/tours'
21
+
22
+ export type { RmTutorial }
23
+
24
+ const props = withDefaults(defineProps<{
25
+ tutorials: RmTutorial[]
26
+ /** Identifiants des visites deja terminees par le lecteur. */
27
+ done?: string[]
28
+ title?: string
29
+ /** Phrase d'introduction du panneau. */
30
+ description?: string
31
+ /** Pastille d'auteur en haut a droite (« TEKKARE »). */
32
+ badge?: string
33
+ lang?: 'fr' | 'en'
34
+ }>(), {
35
+ done: () => [],
36
+ title: '',
37
+ description: undefined,
38
+ badge: undefined,
39
+ lang: 'fr',
40
+ })
41
+
42
+ const emit = defineEmits<{ start: [id: string] }>()
43
+
44
+ const WORDS = {
45
+ fr: {
46
+ start: 'Démarrer',
47
+ again: 'Revoir',
48
+ steps: 'étapes',
49
+ minutes: 'min',
50
+ done: 'Terminé',
51
+ empty: 'Aucune visite pour cet écran',
52
+ emptyText: "Les visites sont rattachées aux fonctions du cadre : ouvrez une page d'analyse pour les retrouver.",
53
+ },
54
+ en: {
55
+ start: 'Start',
56
+ again: 'Replay',
57
+ steps: 'steps',
58
+ minutes: 'min',
59
+ done: 'Done',
60
+ empty: 'No tour for this screen',
61
+ emptyText: 'Tours belong to the frame features: open an analysis page to find them again.',
62
+ },
63
+ } as const
64
+
65
+ const T = computed(() => WORDS[props.lang] ?? WORDS.fr)
66
+
67
+ const isDone = (id: string) => props.done.includes(id)
68
+ </script>
69
+
70
+ <template>
71
+ <div class="rm-tuto">
72
+ <div v-if="title || badge" class="rm-tuto__head">
73
+ <h2 v-if="title" class="rm-tuto__title">{{ title }}</h2>
74
+ <span v-if="badge" class="rm-tuto__badge">{{ badge }}</span>
75
+ </div>
76
+
77
+ <p v-if="description" class="rm-tuto__desc">{{ description }}</p>
78
+
79
+ <div v-if="tutorials.length" class="rm-tuto__list">
80
+ <article
81
+ v-for="item in tutorials"
82
+ :key="item.id"
83
+ class="rm-tuto__card"
84
+ :class="{ 'rm-tuto__card--off': !!item.disabledReason }"
85
+ >
86
+ <div class="rm-tuto__row">
87
+ <span class="rm-tuto__icon">
88
+ <RmIcon :name="item.icon || 'Compass'" :size="18" />
89
+ </span>
90
+
91
+ <div class="rm-tuto__body">
92
+ <div class="rm-tuto__line">
93
+ <h3 class="rm-tuto__name">{{ item.title }}</h3>
94
+ <span v-if="item.tag" class="rm-tuto__tag">{{ item.tag }}</span>
95
+ <span v-if="isDone(item.id)" class="rm-tuto__done">
96
+ <RmIcon name="CheckCircle" :size="13" weight="fill" />
97
+ {{ T.done }}
98
+ </span>
99
+ </div>
100
+
101
+ <p class="rm-tuto__summary">{{ item.summary }}</p>
102
+
103
+ <p class="rm-tuto__meta">
104
+ {{ item.steps }} {{ T.steps }}
105
+ <template v-if="item.minutes"> · {{ item.minutes }} {{ T.minutes }}</template>
106
+ </p>
107
+ </div>
108
+
109
+ <RmButton
110
+ variant="secondary"
111
+ size="sm"
112
+ prefix-icon="Play"
113
+ :disabled="!!item.disabledReason"
114
+ @click="emit('start', item.id)"
115
+ >
116
+ {{ isDone(item.id) ? T.again : T.start }}
117
+ </RmButton>
118
+ </div>
119
+
120
+ <p v-if="item.disabledReason" class="rm-tuto__reason">{{ item.disabledReason }}</p>
121
+ </article>
122
+ </div>
123
+
124
+ <RmEmptyState v-else :title="T.empty" :text="T.emptyText" />
125
+ </div>
126
+ </template>
127
+
128
+ <style scoped>
129
+ .rm-tuto{display:flex;flex-direction:column;gap:var(--space-12);width:100%}.rm-tuto__head{align-items:center;display:flex;gap:var(--space-8);justify-content:space-between}.rm-tuto__title{color:var(--text-primary);font-size:var(--font-16)}.rm-tuto__badge,.rm-tuto__title{font-weight:var(--weight-semibold)}.rm-tuto__badge{background:var(--bg-hover);border-radius:var(--radius-pill);color:var(--color-primary);font-size:var(--font-10);letter-spacing:.06em;padding:2px var(--space-8);text-transform:uppercase}.rm-tuto__desc{color:var(--text-secondary);font-size:var(--font-12);line-height:1.55}.rm-tuto__card,.rm-tuto__list{display:flex;flex-direction:column;gap:var(--space-8)}.rm-tuto__card{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-xl);padding:var(--space-16);transition:border-color var(--duration-fast) var(--ease-out)}.rm-tuto__card:hover{border-color:var(--color-violet-pale)}.rm-tuto__card--off{opacity:.72}.rm-tuto__row{align-items:flex-start;display:flex;gap:var(--space-12)}.rm-tuto__icon{align-items:center;background:var(--bg-hover);border-radius:var(--radius-lg);color:var(--color-secondary);display:flex;flex-shrink:0;height:36px;justify-content:center;width:36px}.rm-tuto__body{display:flex;flex:1;flex-direction:column;gap:3px;min-width:0}.rm-tuto__line{align-items:center;display:flex;flex-wrap:wrap;gap:var(--space-6)}.rm-tuto__name{color:var(--text-primary);font-size:var(--font-13);font-weight:var(--weight-semibold)}.rm-tuto__tag{background:var(--bg-surface-3);border-radius:var(--radius-pill);color:var(--text-muted);font-size:var(--font-10);padding:1px var(--space-6)}.rm-tuto__done{align-items:center;color:var(--color-success);display:inline-flex;font-size:var(--font-10);font-weight:var(--weight-semibold);gap:3px}.rm-tuto__summary{color:var(--text-secondary);font-size:var(--font-12);line-height:1.5}.rm-tuto__meta{color:var(--text-muted);font-size:var(--font-11);font-variant-numeric:tabular-nums}.rm-tuto__reason{color:var(--text-muted);font-size:var(--font-11);line-height:1.5;padding-left:48px}
130
+ </style>
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Types des visites guidees et de leur catalogue.
3
+ *
4
+ * Ils vivent ici plutot que dans les composants parce que le paquet n'expose
5
+ * que `utils/*` : une app qui ecrit ses tutoriels doit pouvoir les typer sans
6
+ * importer un `.vue`. Meme raison que pour `utils/alerts.ts`.
7
+ */
8
+ /** Une etape de visite : ce qu'on montre, et ce qu'on en dit. */
9
+ export interface RmTourStep {
10
+ /** Titre de l'etape, lu en tete de la bulle. */
11
+ title: string;
12
+ /** Texte de l'etape. `**gras**` est reconnu, rien d'autre. */
13
+ text: string;
14
+ /** Points de detail, sous le texte. */
15
+ bullets?: string[];
16
+ /**
17
+ * Selecteur CSS de la cible. Sans lui, l'etape est centree et rien n'est
18
+ * detoure : c'est la forme des etapes d'introduction et de conclusion.
19
+ */
20
+ target?: string;
21
+ /** Cote ou poser la bulle. Par defaut, le premier cote qui a la place. */
22
+ placement?: 'top' | 'bottom' | 'left' | 'right' | 'center';
23
+ /** Marge entre le cadre de la cible et son detourage, en pixels. */
24
+ padding?: number;
25
+ }
26
+ /** Une visite, telle que le panneau « Tutoriels » la presente. */
27
+ export interface RmTutorial {
28
+ id: string;
29
+ title: string;
30
+ /** Ce que la visite apprend, en une phrase. */
31
+ summary: string;
32
+ /** Icone Phosphor, nom PascalCase (cf. RmIcon). */
33
+ icon?: string;
34
+ /** Nombre d'etapes, annonce avant de partir. */
35
+ steps: number;
36
+ /** Duree estimee, en minutes. */
37
+ minutes?: number;
38
+ /** Rubrique, en pastille : « Vues », « Alertes »... */
39
+ tag?: string;
40
+ /**
41
+ * Raison pour laquelle la visite ne peut pas partir d'ici. Presente, elle
42
+ * desactive le bouton et s'ecrit sous la carte.
43
+ */
44
+ disabledReason?: string;
45
+ }
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekkare/romulus",
3
- "version": "0.1.148",
3
+ "version": "0.1.150",
4
4
  "description": "Romulus Design System - Nuxt module by Tekkare",
5
5
  "license": "MIT",
6
6
  "type": "module",