fix(taches): le stepper de date ne ment plus sur le libellé du jourUnverified
Le JS des ± 1 jour réécrivait le libellé de son côté ; il ne fait plus que remplir le motif et les noms fournis par le serveur (period::DateVocab). Ajoute au passage l'outillage de screencast de démo (docs/slides/demo).
892cf36 parent: 5d9c31d modified
.gitignore +1 -0 | @@ -1,3 +1,4 @@ | ||
| 1 | 1 | /target |
| 2 | 2 | .clever.json |
| 3 | 3 | /_site |
| 4 | +/docs/slides/demo/out | |
| @@ -1,3 +1,4 @@ | |||
| 1 | /target | 1 | /target |
| 2 | .clever.json | 2 | .clever.json |
| 3 | /_site | 3 | /_site |
| 4 | +/docs/slides/demo/out | ||
modified
docs/slides/README.md +34 -0 | @@ -42,3 +42,37 @@ de `clever env`. | ||
| 42 | 42 | |
| 43 | 43 | 10 écrans sont capturés en 390×844 @2x (thème Organic). La capture |
| 44 | 44 | `stats*` vise la semaine ISO précédente, toujours pleine. |
| 45 | + | |
| 46 | +## Enregistrer la vidéo de démo (`demo/out/`) | |
| 47 | + | |
| 48 | +`demo/video.mjs` rejoue la prise en main d'une **équipe neuve** et l'enregistre | |
| 49 | +en vidéo (~4 min 40, 780×1688) : connexion, création des quatre catégories, | |
| 50 | +quatre saisies de temps (stepper, presets, clavier, date), les statistiques et | |
| 51 | +le détail d'un segment, le minuteur lancé puis la création de tâches pendant | |
| 52 | +qu'il tourne, l'arrêt depuis la pill de l'onglet Tâches et l'enregistrement de | |
| 53 | +la mesure. Chaque geste est joué à vitesse humaine, curseur tactile incrusté, | |
| 54 | +avec une pause à chaque changement d'écran pour la voix off au montage. | |
| 55 | + | |
| 56 | +Le jeu de données n'est pas celui des captures : équipe `Chez nous`, comptes | |
| 57 | +`alex` (filmé) et `lou` — le scénario suppose l'équipe **vide** au départ. | |
| 58 | + | |
| 59 | +1. Équipe et comptes (une fois) — voir l'en-tête de `demo/video-reset.sql`. | |
| 60 | +2. Avant **chaque** prise, remettre l'équipe à zéro : | |
| 61 | + | |
| 62 | + ```bash | |
| 63 | + psql -X "$DATABASE_URL" -f demo/video-reset.sql | |
| 64 | + ``` | |
| 65 | + | |
| 66 | +3. Serveur local puis enregistrement : | |
| 67 | + | |
| 68 | + ```bash | |
| 69 | + cd docs/slides/demo | |
| 70 | + TT_USER=alex TT_PASS=… node video.mjs | |
| 71 | + ``` | |
| 72 | + | |
| 73 | +Sortie : `out/demo.webm` (brut de capture, VP9) et `out/demo.mp4` (H.264, | |
| 74 | +c'est celui qu'on monte). `out/` est ignoré par git. | |
| 75 | + | |
| 76 | +`TT_SPEED=6` rejoue tout le scénario en ~1 min 20 pour vérifier qu'il passe | |
| 77 | +sans attendre la prise complète — les durées de commentaire sont divisées, | |
| 78 | +les attentes mécaniques (navigations, fins de défilement) ne le sont pas. | |
| @@ -42,3 +42,37 @@ de `clever env`. | |||
| 42 | 42 | ||
| 43 | 10 écrans sont capturés en 390×844 @2x (thème Organic). La capture | 43 | 10 écrans sont capturés en 390×844 @2x (thème Organic). La capture |
| 44 | `stats*` vise la semaine ISO précédente, toujours pleine. | 44 | `stats*` vise la semaine ISO précédente, toujours pleine. |
| 45 | + | ||
| 46 | +## Enregistrer la vidéo de démo (`demo/out/`) | ||
| 47 | + | ||
| 48 | +`demo/video.mjs` rejoue la prise en main d'une **équipe neuve** et l'enregistre | ||
| 49 | +en vidéo (~4 min 40, 780×1688) : connexion, création des quatre catégories, | ||
| 50 | +quatre saisies de temps (stepper, presets, clavier, date), les statistiques et | ||
| 51 | +le détail d'un segment, le minuteur lancé puis la création de tâches pendant | ||
| 52 | +qu'il tourne, l'arrêt depuis la pill de l'onglet Tâches et l'enregistrement de | ||
| 53 | +la mesure. Chaque geste est joué à vitesse humaine, curseur tactile incrusté, | ||
| 54 | +avec une pause à chaque changement d'écran pour la voix off au montage. | ||
| 55 | + | ||
| 56 | +Le jeu de données n'est pas celui des captures : équipe `Chez nous`, comptes | ||
| 57 | +`alex` (filmé) et `lou` — le scénario suppose l'équipe **vide** au départ. | ||
| 58 | + | ||
| 59 | +1. Équipe et comptes (une fois) — voir l'en-tête de `demo/video-reset.sql`. | ||
| 60 | +2. Avant **chaque** prise, remettre l'équipe à zéro : | ||
| 61 | + | ||
| 62 | + ```bash | ||
| 63 | + psql -X "$DATABASE_URL" -f demo/video-reset.sql | ||
| 64 | + ``` | ||
| 65 | + | ||
| 66 | +3. Serveur local puis enregistrement : | ||
| 67 | + | ||
| 68 | + ```bash | ||
| 69 | + cd docs/slides/demo | ||
| 70 | + TT_USER=alex TT_PASS=… node video.mjs | ||
| 71 | + ``` | ||
| 72 | + | ||
| 73 | +Sortie : `out/demo.webm` (brut de capture, VP9) et `out/demo.mp4` (H.264, | ||
| 74 | +c'est celui qu'on monte). `out/` est ignoré par git. | ||
| 75 | + | ||
| 76 | +`TT_SPEED=6` rejoue tout le scénario en ~1 min 20 pour vérifier qu'il passe | ||
| 77 | +sans attendre la prise complète — les durées de commentaire sont divisées, | ||
| 78 | +les attentes mécaniques (navigations, fins de défilement) ne le sont pas. | ||
added
docs/slides/demo/video-reset.sql +34 -0 | new file mode 100644 | ||
| @@ -0,0 +1,34 @@ | ||
| 1 | +-- Remet l'équipe de démo vidéo à l'état « équipe neuve » : deux membres, | |
| 2 | +-- rien d'autre. À jouer sur la base DEV avant chaque prise (jamais la prod !) : | |
| 3 | +-- | |
| 4 | +-- psql -X "$DATABASE_URL" -f video-reset.sql | |
| 5 | +-- | |
| 6 | +-- Prérequis (une seule fois, via la CLI qui hash les mots de passe) : | |
| 7 | +-- | |
| 8 | +-- exego team add "Chez nous" | |
| 9 | +-- exego user add alex --team "Chez nous" --password <mdp> | |
| 10 | +-- exego user add lou --team "Chez nous" --password <mdp> | |
| 11 | +-- | |
| 12 | +-- `alex` est le compte filmé, `lou` le binôme (il apparaît dans les membres | |
| 13 | +-- de l'équipe et dans les puces d'assignation des tâches). | |
| 14 | + | |
| 15 | +BEGIN; | |
| 16 | + | |
| 17 | +DELETE FROM time_entries | |
| 18 | + WHERE category_id IN (SELECT id FROM categories | |
| 19 | + WHERE team_id = (SELECT id FROM teams WHERE name = 'Chez nous')); | |
| 20 | + | |
| 21 | +-- todo_state_changes et todo_assignees partent en cascade. | |
| 22 | +DELETE FROM todos | |
| 23 | + WHERE category_id IN (SELECT id FROM categories | |
| 24 | + WHERE team_id = (SELECT id FROM teams WHERE name = 'Chez nous')); | |
| 25 | + | |
| 26 | +DELETE FROM categories WHERE team_id = (SELECT id FROM teams WHERE name = 'Chez nous'); | |
| 27 | + | |
| 28 | +-- Minuteur ou mesure en attente d'une prise précédente. | |
| 29 | +DELETE FROM timers WHERE team_id = (SELECT id FROM teams WHERE name = 'Chez nous'); | |
| 30 | + | |
| 31 | +-- Invitations reçues : elles poseraient un badge sur l'onglet Réglages. | |
| 32 | +DELETE FROM invitations WHERE invitee_id IN (SELECT id FROM users WHERE username IN ('alex', 'lou')); | |
| 33 | + | |
| 34 | +COMMIT; | |
| new file mode 100644 | |||
| @@ -0,0 +1,34 @@ | |||
| 1 | +-- Remet l'équipe de démo vidéo à l'état « équipe neuve » : deux membres, | ||
| 2 | +-- rien d'autre. À jouer sur la base DEV avant chaque prise (jamais la prod !) : | ||
| 3 | +-- | ||
| 4 | +-- psql -X "$DATABASE_URL" -f video-reset.sql | ||
| 5 | +-- | ||
| 6 | +-- Prérequis (une seule fois, via la CLI qui hash les mots de passe) : | ||
| 7 | +-- | ||
| 8 | +-- exego team add "Chez nous" | ||
| 9 | +-- exego user add alex --team "Chez nous" --password <mdp> | ||
| 10 | +-- exego user add lou --team "Chez nous" --password <mdp> | ||
| 11 | +-- | ||
| 12 | +-- `alex` est le compte filmé, `lou` le binôme (il apparaît dans les membres | ||
| 13 | +-- de l'équipe et dans les puces d'assignation des tâches). | ||
| 14 | + | ||
| 15 | +BEGIN; | ||
| 16 | + | ||
| 17 | +DELETE FROM time_entries | ||
| 18 | + WHERE category_id IN (SELECT id FROM categories | ||
| 19 | + WHERE team_id = (SELECT id FROM teams WHERE name = 'Chez nous')); | ||
| 20 | + | ||
| 21 | +-- todo_state_changes et todo_assignees partent en cascade. | ||
| 22 | +DELETE FROM todos | ||
| 23 | + WHERE category_id IN (SELECT id FROM categories | ||
| 24 | + WHERE team_id = (SELECT id FROM teams WHERE name = 'Chez nous')); | ||
| 25 | + | ||
| 26 | +DELETE FROM categories WHERE team_id = (SELECT id FROM teams WHERE name = 'Chez nous'); | ||
| 27 | + | ||
| 28 | +-- Minuteur ou mesure en attente d'une prise précédente. | ||
| 29 | +DELETE FROM timers WHERE team_id = (SELECT id FROM teams WHERE name = 'Chez nous'); | ||
| 30 | + | ||
| 31 | +-- Invitations reçues : elles poseraient un badge sur l'onglet Réglages. | ||
| 32 | +DELETE FROM invitations WHERE invitee_id IN (SELECT id FROM users WHERE username IN ('alex', 'lou')); | ||
| 33 | + | ||
| 34 | +COMMIT; | ||
added
docs/slides/demo/video.mjs +498 -0 | new file mode 100644 | ||
| @@ -0,0 +1,498 @@ | ||
| 1 | +// Vidéo de démo : une équipe neuve prise en main de bout en bout. | |
| 2 | +// | |
| 3 | +// Rejoue le parcours complet dans un Chrome piloté (viewport mobile 390×844) | |
| 4 | +// et l'enregistre en vidéo. Le rythme est celui d'un humain : chaque geste est | |
| 5 | +// précédé d'un déplacement de curseur visible, chaque changement d'écran laisse | |
| 6 | +// une pause de commentaire (voix off ou sous-titres au montage). | |
| 7 | +// | |
| 8 | +// Prérequis : | |
| 9 | +// 1. un serveur exego local sur la base DEV (jamais la prod) ; | |
| 10 | +// 2. l'équipe et les comptes de démo créés (cf. video-reset.sql) ; | |
| 11 | +// 3. `psql -X "$DATABASE_URL" -f video-reset.sql` juste avant la prise ; | |
| 12 | +// 4. les dépendances node d'a11y/ (puppeteer-core) et ffmpeg. | |
| 13 | +// | |
| 14 | +// Usage : | |
| 15 | +// cd docs/slides/demo | |
| 16 | +// TT_USER=alex TT_PASS=… node video.mjs | |
| 17 | +// | |
| 18 | +// Variables : TT_URL (défaut http://localhost:8090), TT_USER, TT_PASS, | |
| 19 | +// CHROME (chemin ABSOLU du binaire), FFMPEG, TT_OUT (dossier de sortie, | |
| 20 | +// défaut ./out), TT_SPEED (1 = vitesse humaine ; 2 = deux fois plus vite, | |
| 21 | +// pratique pour vérifier le scénario sans attendre). | |
| 22 | +// | |
| 23 | +// Sortie : out/demo.webm (VP9, brut de capture) et out/demo.mp4 (H.264, | |
| 24 | +// partageable). Le .mp4 est ce qu'on monte. | |
| 25 | + | |
| 26 | +import { execSync, spawnSync } from 'node:child_process'; | |
| 27 | +import { mkdirSync } from 'node:fs'; | |
| 28 | +import { createRequire } from 'node:module'; | |
| 29 | +import path from 'node:path'; | |
| 30 | +import { fileURLToPath } from 'node:url'; | |
| 31 | + | |
| 32 | +// puppeteer-core est installé dans a11y/ — on le résout depuis là-bas. | |
| 33 | +const require = createRequire(new URL('../../../a11y/package.json', import.meta.url)); | |
| 34 | +const puppeteer = require('puppeteer-core'); | |
| 35 | + | |
| 36 | +const BASE = process.env.TT_URL ?? 'http://localhost:8090'; | |
| 37 | +const USER = process.env.TT_USER; | |
| 38 | +const PASS = process.env.TT_PASS; | |
| 39 | +if (!USER || !PASS) { | |
| 40 | + console.error('TT_USER et TT_PASS sont requis (compte de démo, jamais un compte réel).'); | |
| 41 | + process.exit(2); | |
| 42 | +} | |
| 43 | +const CHROME = | |
| 44 | + process.env.CHROME ?? | |
| 45 | + execSync('command -v google-chrome-stable || command -v google-chrome || command -v chromium').toString().trim(); | |
| 46 | +const FFMPEG = process.env.FFMPEG ?? execSync('command -v ffmpeg').toString().trim(); | |
| 47 | + | |
| 48 | +const HERE = path.dirname(fileURLToPath(import.meta.url)); | |
| 49 | +const OUT = process.env.TT_OUT ?? path.join(HERE, 'out'); | |
| 50 | +mkdirSync(OUT, { recursive: true }); | |
| 51 | + | |
| 52 | +// ─────────────────────────────────────────────────────────── rythme | |
| 53 | + | |
| 54 | +// Toutes les durées ci-dessous sont en millisecondes « humaines ». TT_SPEED | |
| 55 | +// les divise : la relecture de contrôle se fait à 3, la prise finale à 1. | |
| 56 | +const SPEED = Number(process.env.TT_SPEED ?? 1); | |
| 57 | +const scale = (n) => Math.max(1, Math.round(n / SPEED)); | |
| 58 | +const sleep = (n) => new Promise((r) => setTimeout(r, scale(n))); | |
| 59 | + | |
| 60 | +const BEAT = 500; // entre deux gestes de la même séquence | |
| 61 | +const READ = 1300; // le temps de lire ce qui vient de changer | |
| 62 | +const SCENE = 2500; // changement d'écran : place pour un commentaire | |
| 63 | +const TOAST = 1900; // durée de vie du toast (tt-fade : 1,5 s + 0,4 s) | |
| 64 | +const KEY = 95; // délai entre deux frappes clavier | |
| 65 | +const DWELL = 2400; // scène des tâches : on prend son temps, le minuteur tourne | |
| 66 | +const GLIDE = 420; // déplacement du curseur d'une cible à l'autre | |
| 67 | + | |
| 68 | +// ────────────────────────────────────────────── curseur tactile incrusté | |
| 69 | + | |
| 70 | +// Le screencast CDP ne dessine pas le pointeur : on incruste une pastille qui | |
| 71 | +// suit les gestes (déplacement en transition CSS, écrasement + onde au tap). | |
| 72 | +// Réinjecté à chaque document — une navigation emporte l'incrustation. | |
| 73 | +const CURSOR_BOOTSTRAP = () => { | |
| 74 | + const S = { x: 195, y: 620, el: null }; | |
| 75 | + const CSS = ` | |
| 76 | + .demo-cursor, .demo-ripple { position: fixed; left: 0; top: 0; pointer-events: none; } | |
| 77 | + .demo-cursor { z-index: 2147483647; } | |
| 78 | + .demo-ripple { z-index: 2147483646; } | |
| 79 | + .demo-cursor > i, .demo-ripple > i { | |
| 80 | + display: block; width: 34px; height: 34px; margin: -17px 0 0 -17px; border-radius: 50%; | |
| 81 | + } | |
| 82 | + .demo-cursor > i { | |
| 83 | + background: rgba(26, 20, 14, .26); | |
| 84 | + border: 2px solid rgba(255, 255, 255, .95); | |
| 85 | + box-shadow: 0 3px 12px rgba(0, 0, 0, .38); | |
| 86 | + } | |
| 87 | + .demo-cursor.tap > i { animation: demo-tap .34s ease-out; } | |
| 88 | + .demo-ripple > i { border: 2.5px solid rgba(214, 109, 62, .95); animation: demo-ripple .62s ease-out forwards; } | |
| 89 | + @keyframes demo-tap { 0% { transform: scale(1) } 45% { transform: scale(.6) } 100% { transform: scale(1) } } | |
| 90 | + @keyframes demo-ripple { from { transform: scale(.9); opacity: .95 } to { transform: scale(2.9); opacity: 0 } } | |
| 91 | + `; | |
| 92 | + function host() { | |
| 93 | + return document.body || document.documentElement; | |
| 94 | + } | |
| 95 | + function ensure() { | |
| 96 | + if (S.el && S.el.isConnected) return S.el; | |
| 97 | + if (!host()) return null; | |
| 98 | + const style = document.createElement('style'); | |
| 99 | + style.textContent = CSS; | |
| 100 | + host().appendChild(style); | |
| 101 | + const el = document.createElement('div'); | |
| 102 | + el.className = 'demo-cursor'; | |
| 103 | + el.appendChild(document.createElement('i')); | |
| 104 | + el.style.transform = `translate3d(${S.x}px, ${S.y}px, 0)`; | |
| 105 | + host().appendChild(el); | |
| 106 | + S.el = el; | |
| 107 | + return el; | |
| 108 | + } | |
| 109 | + window.__demo = { | |
| 110 | + // Repositionne sans animation (après une navigation : le curseur reprend | |
| 111 | + // là où il était sur la page précédente). | |
| 112 | + place(x, y) { | |
| 113 | + S.x = x; | |
| 114 | + S.y = y; | |
| 115 | + const el = ensure(); | |
| 116 | + if (!el) return; | |
| 117 | + el.style.transition = 'none'; | |
| 118 | + el.style.transform = `translate3d(${x}px, ${y}px, 0)`; | |
| 119 | + void el.offsetWidth; | |
| 120 | + el.style.transition = ''; | |
| 121 | + }, | |
| 122 | + move(x, y, dur) { | |
| 123 | + S.x = x; | |
| 124 | + S.y = y; | |
| 125 | + const el = ensure(); | |
| 126 | + if (!el) return; | |
| 127 | + el.style.transition = `transform ${dur}ms cubic-bezier(.33, .02, .2, 1)`; | |
| 128 | + el.style.transform = `translate3d(${x}px, ${y}px, 0)`; | |
| 129 | + }, | |
| 130 | + tap() { | |
| 131 | + const el = ensure(); | |
| 132 | + if (!el) return; | |
| 133 | + el.classList.remove('tap'); | |
| 134 | + void el.offsetWidth; | |
| 135 | + el.classList.add('tap'); | |
| 136 | + const r = document.createElement('div'); | |
| 137 | + r.className = 'demo-ripple'; | |
| 138 | + r.appendChild(document.createElement('i')); | |
| 139 | + r.style.transform = `translate3d(${S.x}px, ${S.y}px, 0)`; | |
| 140 | + host().appendChild(r); | |
| 141 | + setTimeout(() => r.remove(), 800); | |
| 142 | + }, | |
| 143 | + }; | |
| 144 | +}; | |
| 145 | + | |
| 146 | +// ─────────────────────────────────────────────────────────── pilotage | |
| 147 | + | |
| 148 | +const browser = await puppeteer.launch({ | |
| 149 | + executablePath: CHROME, | |
| 150 | + headless: true, | |
| 151 | + args: ['--no-sandbox', '--disable-dev-shm-usage', '--force-device-scale-factor=1'], | |
| 152 | +}); | |
| 153 | +// « Lancer le minuteur » demande la permission notifications au premier | |
| 154 | +// clic (app.html) : on l'accorde d'avance, la demande n'a pas lieu et le | |
| 155 | +// formulaire part directement. | |
| 156 | +await browser.defaultBrowserContext().overridePermissions(BASE, ['notifications']); | |
| 157 | + | |
| 158 | +const page = await browser.newPage(); | |
| 159 | +await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true, deviceScaleFactor: 2 }); | |
| 160 | +await page.evaluateOnNewDocument(CURSOR_BOOTSTRAP); | |
| 161 | + | |
| 162 | +// Position du curseur côté node : elle survit aux navigations, l'incrustation non. | |
| 163 | +const cursor = { x: 195, y: 620 }; | |
| 164 | + | |
| 165 | +async function restoreCursor() { | |
| 166 | + await page.evaluate((p) => window.__demo && window.__demo.place(p.x, p.y), cursor).catch(() => {}); | |
| 167 | +} | |
| 168 | + | |
| 169 | +async function moveTo(x, y, dur = GLIDE) { | |
| 170 | + cursor.x = x; | |
| 171 | + cursor.y = y; | |
| 172 | + const d = scale(dur); | |
| 173 | + await page.evaluate((a) => window.__demo.move(a.x, a.y, a.d), { x, y, d }); | |
| 174 | + await page.mouse.move(x, y); | |
| 175 | + await sleep(dur); | |
| 176 | +} | |
| 177 | + | |
| 178 | +async function ripple() { | |
| 179 | + await page.evaluate(() => window.__demo.tap()); | |
| 180 | +} | |
| 181 | + | |
| 182 | +/** Élément par sélecteur, éventuellement filtré sur son texte. */ | |
| 183 | +async function locate(selector, text) { | |
| 184 | + const handle = text | |
| 185 | + ? await page | |
| 186 | + .evaluateHandle( | |
| 187 | + (sel, t) => [...document.querySelectorAll(sel)].find((e) => e.textContent.trim().includes(t)) ?? null, | |
| 188 | + selector, | |
| 189 | + text, | |
| 190 | + ) | |
| 191 | + .then((h) => h.asElement()) | |
| 192 | + : await page.$(selector); | |
| 193 | + if (!handle) throw new Error(`introuvable : ${selector}${text ? ` « ${text} »` : ''}`); | |
| 194 | + return handle; | |
| 195 | +} | |
| 196 | + | |
| 197 | +/** | |
| 198 | + * Attend la fin du défilement doux en cours. Attente mécanique : elle n'est | |
| 199 | + * PAS soumise à TT_SPEED — viser une cible encore en mouvement rate le clic. | |
| 200 | + */ | |
| 201 | +async function settleScroll() { | |
| 202 | + await page.evaluate( | |
| 203 | + () => | |
| 204 | + new Promise((resolve) => { | |
| 205 | + const el = document.querySelector('.app-main') ?? document.scrollingElement; | |
| 206 | + const bail = setTimeout(resolve, 2500); | |
| 207 | + let last = NaN; | |
| 208 | + let same = 0; | |
| 209 | + (function tick() { | |
| 210 | + same = el.scrollTop === last ? same + 1 : 0; | |
| 211 | + last = el.scrollTop; | |
| 212 | + if (same >= 3) { | |
| 213 | + clearTimeout(bail); | |
| 214 | + return resolve(); | |
| 215 | + } | |
| 216 | + requestAnimationFrame(tick); | |
| 217 | + })(); | |
| 218 | + }), | |
| 219 | + ); | |
| 220 | +} | |
| 221 | + | |
| 222 | +/** Amène l'élément dans la zone confortable (sous l'en-tête, au-dessus de la tab bar). */ | |
| 223 | +async function bringIntoView(handle) { | |
| 224 | + const scrolled = await handle.evaluate((el) => { | |
| 225 | + const r = el.getBoundingClientRect(); | |
| 226 | + if (r.top >= 96 && r.bottom <= window.innerHeight - 132) return false; | |
| 227 | + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); | |
| 228 | + return true; | |
| 229 | + }); | |
| 230 | + if (scrolled) await settleScroll(); | |
| 231 | +} | |
| 232 | + | |
| 233 | +/** | |
| 234 | + * Tape sur un élément. `nav: true` attend la navigation qui suit (et remet | |
| 235 | + * l'incrustation en place sur la nouvelle page). `fixed: true` pour ce qui ne | |
| 236 | + * scrolle pas (tab bar, pill du minuteur, bottom sheets) : les amener « dans | |
| 237 | + * la vue » ferait bouger la page sous eux pour rien. | |
| 238 | + */ | |
| 239 | +async function tap(selector, { text, nav = false, fixed = false, pause = BEAT } = {}) { | |
| 240 | + const handle = await locate(selector, text); | |
| 241 | + if (!fixed) await bringIntoView(handle); | |
| 242 | + const box = await handle.boundingBox(); | |
| 243 | + if (!box) throw new Error(`invisible : ${selector}${text ? ` « ${text} »` : ''}`); | |
| 244 | + const x = Math.round(box.x + box.width / 2); | |
| 245 | + const y = Math.round(box.y + box.height / 2); | |
| 246 | + await moveTo(x, y); | |
| 247 | + await sleep(170); | |
| 248 | + await ripple(); | |
| 249 | + if (nav) { | |
| 250 | + await Promise.all([ | |
| 251 | + page.waitForNavigation({ waitUntil: 'domcontentloaded' }), | |
| 252 | + page.mouse.click(x, y, { delay: 55 }), | |
| 253 | + ]); | |
| 254 | + await restoreCursor(); | |
| 255 | + } else { | |
| 256 | + await page.mouse.click(x, y, { delay: 55 }); | |
| 257 | + } | |
| 258 | + await sleep(pause); | |
| 259 | + return handle; | |
| 260 | +} | |
| 261 | + | |
| 262 | +/** Tape dans un champ puis y saisit le texte lettre à lettre. */ | |
| 263 | +async function fill(selector, value, { pause = BEAT } = {}) { | |
| 264 | + const handle = await tap(selector, { pause: 260 }); | |
| 265 | + await handle.type(value, { delay: scale(KEY) }); | |
| 266 | + await sleep(pause); | |
| 267 | +} | |
| 268 | + | |
| 269 | +async function scrollTop({ pause = 900 } = {}) { | |
| 270 | + await page.evaluate(() => { | |
| 271 | + (document.querySelector('.app-main') ?? document.scrollingElement).scrollTo({ top: 0, behavior: 'smooth' }); | |
| 272 | + }); | |
| 273 | + await settleScroll(); | |
| 274 | + await sleep(pause); | |
| 275 | +} | |
| 276 | + | |
| 277 | +/** | |
| 278 | + * Descend la page par paliers de 62 % de hauteur, en marquant un temps à | |
| 279 | + * chaque palier — c'est le geste de quelqu'un qui parcourt un écran long. | |
| 280 | + * S'arrête au bas de page (nombre de paliers borné par `max`). | |
| 281 | + */ | |
| 282 | +async function scrollThrough({ max = 8, pause = 1500 } = {}) { | |
| 283 | + for (let i = 0; i < max; i++) { | |
| 284 | + const more = await page.evaluate(() => { | |
| 285 | + const el = document.querySelector('.app-main') ?? document.scrollingElement; | |
| 286 | + const bottom = el.scrollHeight - el.clientHeight - el.scrollTop; | |
| 287 | + if (bottom < 24) return false; | |
| 288 | + el.scrollBy({ top: Math.min(bottom, el.clientHeight * 0.62), behavior: 'smooth' }); | |
| 289 | + return true; | |
| 290 | + }); | |
| 291 | + if (!more) return; | |
| 292 | + await settleScroll(); | |
| 293 | + await sleep(pause); | |
| 294 | + } | |
| 295 | +} | |
| 296 | + | |
| 297 | +/** Onglets de la barre du bas. */ | |
| 298 | +const TAB = { add: '/temps/nouveau', stats: '/stats', todos: '/taches', settings: '/parametres' }; | |
| 299 | +async function tab(name, { pause = SCENE } = {}) { | |
| 300 | + await tap(`.tabbar a[href="${TAB[name]}"]`, { nav: true, fixed: true, pause }); | |
| 301 | +} | |
| 302 | + | |
| 303 | +function scene(title) { | |
| 304 | + console.log(`▸ ${title}`); | |
| 305 | +} | |
| 306 | + | |
| 307 | +// ─────────────────────────────────────────────────────────── le film | |
| 308 | + | |
| 309 | +await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' }); | |
| 310 | +await restoreCursor(); | |
| 311 | + | |
| 312 | +const recorder = await page.screencast({ | |
| 313 | + path: path.join(OUT, 'demo.webm'), | |
| 314 | + ffmpegPath: FFMPEG, | |
| 315 | + fps: 30, | |
| 316 | + scale: 2, // 780×1688 : lisible en plein écran comme en vignette | |
| 317 | +}); | |
| 318 | + | |
| 319 | +try { | |
| 320 | + // ─── 1. Connexion ─────────────────────────────────────────────────────── | |
| 321 | + scene('Connexion'); | |
| 322 | + await sleep(2200); // plan d'ouverture sur le logo et la tagline | |
| 323 | + await fill('input[name="username"]', USER); | |
| 324 | + await fill('input[name="password"]', PASS, { pause: 700 }); | |
| 325 | + await tap('button[type="submit"]', { nav: true, pause: SCENE }); | |
| 326 | + | |
| 327 | + if (page.url().includes('/login')) throw new Error('connexion échouée — vérifie TT_USER / TT_PASS.'); | |
| 328 | + | |
| 329 | + // ─── 2. Une équipe vide ───────────────────────────────────────────────── | |
| 330 | + // Atterrissage sur l'onglet Ajouter : minuteur ou saisie manuelle. | |
| 331 | + scene('Équipe vide'); | |
| 332 | + await tap('.choice-manual', { nav: true, pause: SCENE }); // « Ajouter un temps » | |
| 333 | + // L'étape 1 n'a rien à proposer : pas encore de catégorie. | |
| 334 | + await sleep(READ); | |
| 335 | + | |
| 336 | + // ─── 3. Créer les catégories ──────────────────────────────────────────── | |
| 337 | + scene('Création des catégories'); | |
| 338 | + await tap('a[href="/parametres/categories"]', { nav: true, pause: BEAT }); | |
| 339 | + | |
| 340 | + for (const nom of ['Ménage', 'Cuisine', 'Vaisselle', 'Administratif']) { | |
| 341 | + await fill('.category-add-row .input', nom, { pause: 150 }); | |
| 342 | + await tap('.category-add-row button[type="submit"]', { nav: true, pause: BEAT }); | |
| 343 | + await scrollTop({ pause: 150 }); | |
| 344 | + } | |
| 345 | + await sleep(SCENE); // les quatre catégories et leurs couleurs | |
| 346 | + | |
| 347 | + // ─── 4. Enregistrer des temps ─────────────────────────────────────────── | |
| 348 | + // Chaque enregistrement renvoie sur l'écran de choix de l'onglet Ajouter | |
| 349 | + // (redirection `?ok=`), d'où le passage par « Ajouter un temps » à chaque tour. | |
| 350 | + scene('Saisie de temps'); | |
| 351 | + await tap('a[href="/temps/nouveau"]', { text: 'Retour', nav: true, pause: READ }); | |
| 352 | + | |
| 353 | + // Chaque saisie montre une façon différente de poser la durée. | |
| 354 | + const saisies = [ | |
| 355 | + // Le stepper ± 5 min, puis un preset : les deux gestes du sélecteur. | |
| 356 | + async () => { | |
| 357 | + await tap('.stepper-btn[data-step="5"]', { pause: 380 }); | |
| 358 | + await tap('.stepper-btn[data-step="5"]', { pause: 900 }); | |
| 359 | + await tap('.preset[data-minutes="45"]', { pause: READ }); | |
| 360 | + }, | |
| 361 | + // Un preset, et la veille (les flèches de date rechargent la page). | |
| 362 | + async () => { | |
| 363 | + await tap('.preset[data-minutes="30"]', { pause: READ }); | |
| 364 | + await tap('.date-row a[aria-label]', { nav: true, pause: READ }); | |
| 365 | + }, | |
| 366 | + // La durée exacte, au clavier. | |
| 367 | + async () => { | |
| 368 | + await tap('#minutes', { pause: 260 }); | |
| 369 | + await page.evaluate(() => document.querySelector('#minutes').select()); | |
| 370 | + await page.keyboard.type('20', { delay: scale(KEY) }); | |
| 371 | + await page.evaluate(() => | |
| 372 | + document.querySelector('#minutes').dispatchEvent(new Event('input', { bubbles: true })), | |
| 373 | + ); | |
| 374 | + await sleep(READ); | |
| 375 | + }, | |
| 376 | + async () => { | |
| 377 | + await tap('.preset[data-minutes="60"]', { pause: READ }); | |
| 378 | + }, | |
| 379 | + ]; | |
| 380 | + const categoriesSaisies = ['Cuisine', 'Ménage', 'Vaisselle', 'Administratif']; | |
| 381 | + for (const [i, nom] of categoriesSaisies.entries()) { | |
| 382 | + await tap('.choice-manual', { nav: true, pause: i === 0 ? SCENE : READ }); | |
| 383 | + await tap('.category-pick a', { text: nom, nav: true, pause: READ }); | |
| 384 | + await saisies[i](); | |
| 385 | + await tap('form[data-duration] button[type="submit"]', { nav: true, pause: TOAST }); | |
| 386 | + } | |
| 387 | + | |
| 388 | + // ─── 5. Les statistiques ──────────────────────────────────────────────── | |
| 389 | + scene('Statistiques'); | |
| 390 | + await tab('stats'); | |
| 391 | + await scrollThrough({ pause: 1400 }); // jour, aujourd'hui, semaine, mois, année, tout | |
| 392 | + await sleep(READ); | |
| 393 | + await scrollTop({ pause: 1000 }); | |
| 394 | + | |
| 395 | + // Détail d'un segment : bottom sheet en :target, zéro JS. On vise le plus | |
| 396 | + // gros segment visible — le petit ne se touche pas confortablement. | |
| 397 | + const segment = ( | |
| 398 | + await page.evaluateHandle(() => { | |
| 399 | + let best = null; | |
| 400 | + let area = 0; | |
| 401 | + for (const s of document.querySelectorAll('.col-bar a')) { | |
| 402 | + const r = s.getBoundingClientRect(); | |
| 403 | + if (r.top < 96 || r.bottom > window.innerHeight - 132) continue; // hors de la zone confortable | |
| 404 | + if (r.width * r.height > area) { | |
| 405 | + area = r.width * r.height; | |
| 406 | + best = s; | |
| 407 | + } | |
| 408 | + } | |
| 409 | + return best; | |
| 410 | + }) | |
| 411 | + ).asElement(); | |
| 412 | + if (segment) { | |
| 413 | + const box = await segment.boundingBox(); | |
| 414 | + await moveTo(Math.round(box.x + box.width / 2), Math.round(box.y + box.height / 2)); | |
| 415 | + await sleep(200); | |
| 416 | + await ripple(); | |
| 417 | + await page.mouse.click(cursor.x, cursor.y, { delay: 55 }); | |
| 418 | + await sleep(SCENE); | |
| 419 | + if (await page.$('.sheet-wrap:target')) { | |
| 420 | + await tap('.sheet-wrap:target .close', { fixed: true, pause: READ }); | |
| 421 | + } else { | |
| 422 | + console.warn('⚠ la feuille de détail ne s’est pas ouverte'); | |
| 423 | + } | |
| 424 | + } else { | |
| 425 | + console.warn('⚠ aucun segment à ouvrir — le détail est sauté'); | |
| 426 | + } | |
| 427 | + | |
| 428 | + // ─── 6. Lancer le minuteur ────────────────────────────────────────────── | |
| 429 | + scene('Minuteur'); | |
| 430 | + await tab('add', { pause: READ }); | |
| 431 | + await tap('.choice-timer', { nav: true, pause: 5000 }); // les secondes défilent | |
| 432 | + | |
| 433 | + // ─── 7. Créer des tâches (le minuteur tourne pendant ce temps) ────────── | |
| 434 | + scene('Tâches'); | |
| 435 | + await tab('todos'); | |
| 436 | + | |
| 437 | + // Toutes datées d'aujourd'hui : les trois restent visibles dans « À faire » | |
| 438 | + // (une date plus lointaine les enverrait dans le bloc replié « À venir »). | |
| 439 | + const taches = [ | |
| 440 | + { nom: 'Vider le lave-vaisselle', cat: 'Vaisselle', assigne: 'alex' }, | |
| 441 | + { nom: 'Passer l’aspirateur', cat: 'Ménage', assigne: 'lou' }, | |
| 442 | + { nom: 'Trier le courrier', cat: 'Administratif', assigne: null }, | |
| 443 | + ]; | |
| 444 | + // La scène est volontairement posée : c'est elle qui fait tourner le | |
| 445 | + // minuteur, et la mesure enregistrée à la fin doit être crédible. | |
| 446 | + for (const t of taches) { | |
| 447 | + await tap('a[href="/taches/nouvelle"]', { nav: true, pause: SCENE }); | |
| 448 | + await fill('#todo-nom', t.nom, { pause: DWELL }); | |
| 449 | + await tap('.chips[role="radiogroup"] .chip', { text: t.cat, pause: DWELL }); | |
| 450 | + if (t.assigne) await tap('.chips[role="group"] .chip', { text: t.assigne, pause: DWELL }); | |
| 451 | + await tap('form button[type="submit"]', { text: 'Créer', nav: true, pause: SCENE }); | |
| 452 | + await sleep(BEAT); // la tâche vient s'ajouter à la liste | |
| 453 | + } | |
| 454 | + | |
| 455 | + // Le filtre « Mes tâches » du bloc À faire, aller-retour : « Passer | |
| 456 | + // l'aspirateur », assignée à lou, disparaît puis revient. | |
| 457 | + await tap('.blk-toggle', { nav: true, pause: 3600 }); | |
| 458 | + await tap('.blk-toggle', { nav: true, pause: 3600 }); | |
| 459 | + | |
| 460 | + // ─── 8. Arrêter le minuteur depuis la pill ────────────────────────────── | |
| 461 | + scene('Arrêt du minuteur'); | |
| 462 | + await tap('.timer-banner.timer-running', { nav: true, fixed: true, pause: SCENE }); | |
| 463 | + | |
| 464 | + // ─── 9. Ranger la mesure dans une catégorie ───────────────────────────── | |
| 465 | + scene('Enregistrement de la mesure'); | |
| 466 | + await tap('.category-pick a', { text: 'Ménage', nav: true, pause: SCENE }); | |
| 467 | + await tap('form[data-duration] button[type="submit"]', { nav: true, pause: TOAST }); | |
| 468 | + | |
| 469 | + // ─── 10. Plan de fin sur les stats ────────────────────────────────────── | |
| 470 | + scene('Plan de fin'); | |
| 471 | + await tab('stats', { pause: SCENE }); | |
| 472 | + await moveTo(195, 880, 700); // le curseur sort du cadre | |
| 473 | + await sleep(1600); | |
| 474 | +} finally { | |
| 475 | + // Le screencast ne comble un blanc qu'à l'arrivée de l'image suivante : | |
| 476 | + // un dernier repaint garantit que la fin de plan est bien dans le fichier. | |
| 477 | + await page.evaluate(() => window.__demo && window.__demo.tap()).catch(() => {}); | |
| 478 | + await sleep(600); | |
| 479 | + await recorder.stop(); | |
| 480 | + await browser.close(); | |
| 481 | +} | |
| 482 | + | |
| 483 | +// ─────────────────────────────────────────────────────── mp4 partageable | |
| 484 | + | |
| 485 | +// Le .webm sort en VP9 ; le H.264 passe partout (montage, Slack, keynote). | |
| 486 | +const webm = path.join(OUT, 'demo.webm'); | |
| 487 | +const mp4 = path.join(OUT, 'demo.mp4'); | |
| 488 | +const conv = spawnSync( | |
| 489 | + FFMPEG, | |
| 490 | + ['-y', '-loglevel', 'error', '-i', webm, '-c:v', 'libx264', '-preset', 'slow', '-crf', '20', | |
| 491 | + '-pix_fmt', 'yuv420p', '-movflags', '+faststart', mp4], | |
| 492 | + { stdio: 'inherit' }, | |
| 493 | +); | |
| 494 | +if (conv.status !== 0) { | |
| 495 | + console.warn(`⚠ conversion mp4 échouée (code ${conv.status}) — le .webm est utilisable tel quel`); | |
| 496 | +} else { | |
| 497 | + console.log(`✓ ${webm}\n✓ ${mp4}`); | |
| 498 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,498 @@ | |||
| 1 | +// Vidéo de démo : une équipe neuve prise en main de bout en bout. | ||
| 2 | +// | ||
| 3 | +// Rejoue le parcours complet dans un Chrome piloté (viewport mobile 390×844) | ||
| 4 | +// et l'enregistre en vidéo. Le rythme est celui d'un humain : chaque geste est | ||
| 5 | +// précédé d'un déplacement de curseur visible, chaque changement d'écran laisse | ||
| 6 | +// une pause de commentaire (voix off ou sous-titres au montage). | ||
| 7 | +// | ||
| 8 | +// Prérequis : | ||
| 9 | +// 1. un serveur exego local sur la base DEV (jamais la prod) ; | ||
| 10 | +// 2. l'équipe et les comptes de démo créés (cf. video-reset.sql) ; | ||
| 11 | +// 3. `psql -X "$DATABASE_URL" -f video-reset.sql` juste avant la prise ; | ||
| 12 | +// 4. les dépendances node d'a11y/ (puppeteer-core) et ffmpeg. | ||
| 13 | +// | ||
| 14 | +// Usage : | ||
| 15 | +// cd docs/slides/demo | ||
| 16 | +// TT_USER=alex TT_PASS=… node video.mjs | ||
| 17 | +// | ||
| 18 | +// Variables : TT_URL (défaut http://localhost:8090), TT_USER, TT_PASS, | ||
| 19 | +// CHROME (chemin ABSOLU du binaire), FFMPEG, TT_OUT (dossier de sortie, | ||
| 20 | +// défaut ./out), TT_SPEED (1 = vitesse humaine ; 2 = deux fois plus vite, | ||
| 21 | +// pratique pour vérifier le scénario sans attendre). | ||
| 22 | +// | ||
| 23 | +// Sortie : out/demo.webm (VP9, brut de capture) et out/demo.mp4 (H.264, | ||
| 24 | +// partageable). Le .mp4 est ce qu'on monte. | ||
| 25 | + | ||
| 26 | +import { execSync, spawnSync } from 'node:child_process'; | ||
| 27 | +import { mkdirSync } from 'node:fs'; | ||
| 28 | +import { createRequire } from 'node:module'; | ||
| 29 | +import path from 'node:path'; | ||
| 30 | +import { fileURLToPath } from 'node:url'; | ||
| 31 | + | ||
| 32 | +// puppeteer-core est installé dans a11y/ — on le résout depuis là-bas. | ||
| 33 | +const require = createRequire(new URL('../../../a11y/package.json', import.meta.url)); | ||
| 34 | +const puppeteer = require('puppeteer-core'); | ||
| 35 | + | ||
| 36 | +const BASE = process.env.TT_URL ?? 'http://localhost:8090'; | ||
| 37 | +const USER = process.env.TT_USER; | ||
| 38 | +const PASS = process.env.TT_PASS; | ||
| 39 | +if (!USER || !PASS) { | ||
| 40 | + console.error('TT_USER et TT_PASS sont requis (compte de démo, jamais un compte réel).'); | ||
| 41 | + process.exit(2); | ||
| 42 | +} | ||
| 43 | +const CHROME = | ||
| 44 | + process.env.CHROME ?? | ||
| 45 | + execSync('command -v google-chrome-stable || command -v google-chrome || command -v chromium').toString().trim(); | ||
| 46 | +const FFMPEG = process.env.FFMPEG ?? execSync('command -v ffmpeg').toString().trim(); | ||
| 47 | + | ||
| 48 | +const HERE = path.dirname(fileURLToPath(import.meta.url)); | ||
| 49 | +const OUT = process.env.TT_OUT ?? path.join(HERE, 'out'); | ||
| 50 | +mkdirSync(OUT, { recursive: true }); | ||
| 51 | + | ||
| 52 | +// ─────────────────────────────────────────────────────────── rythme | ||
| 53 | + | ||
| 54 | +// Toutes les durées ci-dessous sont en millisecondes « humaines ». TT_SPEED | ||
| 55 | +// les divise : la relecture de contrôle se fait à 3, la prise finale à 1. | ||
| 56 | +const SPEED = Number(process.env.TT_SPEED ?? 1); | ||
| 57 | +const scale = (n) => Math.max(1, Math.round(n / SPEED)); | ||
| 58 | +const sleep = (n) => new Promise((r) => setTimeout(r, scale(n))); | ||
| 59 | + | ||
| 60 | +const BEAT = 500; // entre deux gestes de la même séquence | ||
| 61 | +const READ = 1300; // le temps de lire ce qui vient de changer | ||
| 62 | +const SCENE = 2500; // changement d'écran : place pour un commentaire | ||
| 63 | +const TOAST = 1900; // durée de vie du toast (tt-fade : 1,5 s + 0,4 s) | ||
| 64 | +const KEY = 95; // délai entre deux frappes clavier | ||
| 65 | +const DWELL = 2400; // scène des tâches : on prend son temps, le minuteur tourne | ||
| 66 | +const GLIDE = 420; // déplacement du curseur d'une cible à l'autre | ||
| 67 | + | ||
| 68 | +// ────────────────────────────────────────────── curseur tactile incrusté | ||
| 69 | + | ||
| 70 | +// Le screencast CDP ne dessine pas le pointeur : on incruste une pastille qui | ||
| 71 | +// suit les gestes (déplacement en transition CSS, écrasement + onde au tap). | ||
| 72 | +// Réinjecté à chaque document — une navigation emporte l'incrustation. | ||
| 73 | +const CURSOR_BOOTSTRAP = () => { | ||
| 74 | + const S = { x: 195, y: 620, el: null }; | ||
| 75 | + const CSS = ` | ||
| 76 | + .demo-cursor, .demo-ripple { position: fixed; left: 0; top: 0; pointer-events: none; } | ||
| 77 | + .demo-cursor { z-index: 2147483647; } | ||
| 78 | + .demo-ripple { z-index: 2147483646; } | ||
| 79 | + .demo-cursor > i, .demo-ripple > i { | ||
| 80 | + display: block; width: 34px; height: 34px; margin: -17px 0 0 -17px; border-radius: 50%; | ||
| 81 | + } | ||
| 82 | + .demo-cursor > i { | ||
| 83 | + background: rgba(26, 20, 14, .26); | ||
| 84 | + border: 2px solid rgba(255, 255, 255, .95); | ||
| 85 | + box-shadow: 0 3px 12px rgba(0, 0, 0, .38); | ||
| 86 | + } | ||
| 87 | + .demo-cursor.tap > i { animation: demo-tap .34s ease-out; } | ||
| 88 | + .demo-ripple > i { border: 2.5px solid rgba(214, 109, 62, .95); animation: demo-ripple .62s ease-out forwards; } | ||
| 89 | + @keyframes demo-tap { 0% { transform: scale(1) } 45% { transform: scale(.6) } 100% { transform: scale(1) } } | ||
| 90 | + @keyframes demo-ripple { from { transform: scale(.9); opacity: .95 } to { transform: scale(2.9); opacity: 0 } } | ||
| 91 | + `; | ||
| 92 | + function host() { | ||
| 93 | + return document.body || document.documentElement; | ||
| 94 | + } | ||
| 95 | + function ensure() { | ||
| 96 | + if (S.el && S.el.isConnected) return S.el; | ||
| 97 | + if (!host()) return null; | ||
| 98 | + const style = document.createElement('style'); | ||
| 99 | + style.textContent = CSS; | ||
| 100 | + host().appendChild(style); | ||
| 101 | + const el = document.createElement('div'); | ||
| 102 | + el.className = 'demo-cursor'; | ||
| 103 | + el.appendChild(document.createElement('i')); | ||
| 104 | + el.style.transform = `translate3d(${S.x}px, ${S.y}px, 0)`; | ||
| 105 | + host().appendChild(el); | ||
| 106 | + S.el = el; | ||
| 107 | + return el; | ||
| 108 | + } | ||
| 109 | + window.__demo = { | ||
| 110 | + // Repositionne sans animation (après une navigation : le curseur reprend | ||
| 111 | + // là où il était sur la page précédente). | ||
| 112 | + place(x, y) { | ||
| 113 | + S.x = x; | ||
| 114 | + S.y = y; | ||
| 115 | + const el = ensure(); | ||
| 116 | + if (!el) return; | ||
| 117 | + el.style.transition = 'none'; | ||
| 118 | + el.style.transform = `translate3d(${x}px, ${y}px, 0)`; | ||
| 119 | + void el.offsetWidth; | ||
| 120 | + el.style.transition = ''; | ||
| 121 | + }, | ||
| 122 | + move(x, y, dur) { | ||
| 123 | + S.x = x; | ||
| 124 | + S.y = y; | ||
| 125 | + const el = ensure(); | ||
| 126 | + if (!el) return; | ||
| 127 | + el.style.transition = `transform ${dur}ms cubic-bezier(.33, .02, .2, 1)`; | ||
| 128 | + el.style.transform = `translate3d(${x}px, ${y}px, 0)`; | ||
| 129 | + }, | ||
| 130 | + tap() { | ||
| 131 | + const el = ensure(); | ||
| 132 | + if (!el) return; | ||
| 133 | + el.classList.remove('tap'); | ||
| 134 | + void el.offsetWidth; | ||
| 135 | + el.classList.add('tap'); | ||
| 136 | + const r = document.createElement('div'); | ||
| 137 | + r.className = 'demo-ripple'; | ||
| 138 | + r.appendChild(document.createElement('i')); | ||
| 139 | + r.style.transform = `translate3d(${S.x}px, ${S.y}px, 0)`; | ||
| 140 | + host().appendChild(r); | ||
| 141 | + setTimeout(() => r.remove(), 800); | ||
| 142 | + }, | ||
| 143 | + }; | ||
| 144 | +}; | ||
| 145 | + | ||
| 146 | +// ─────────────────────────────────────────────────────────── pilotage | ||
| 147 | + | ||
| 148 | +const browser = await puppeteer.launch({ | ||
| 149 | + executablePath: CHROME, | ||
| 150 | + headless: true, | ||
| 151 | + args: ['--no-sandbox', '--disable-dev-shm-usage', '--force-device-scale-factor=1'], | ||
| 152 | +}); | ||
| 153 | +// « Lancer le minuteur » demande la permission notifications au premier | ||
| 154 | +// clic (app.html) : on l'accorde d'avance, la demande n'a pas lieu et le | ||
| 155 | +// formulaire part directement. | ||
| 156 | +await browser.defaultBrowserContext().overridePermissions(BASE, ['notifications']); | ||
| 157 | + | ||
| 158 | +const page = await browser.newPage(); | ||
| 159 | +await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true, deviceScaleFactor: 2 }); | ||
| 160 | +await page.evaluateOnNewDocument(CURSOR_BOOTSTRAP); | ||
| 161 | + | ||
| 162 | +// Position du curseur côté node : elle survit aux navigations, l'incrustation non. | ||
| 163 | +const cursor = { x: 195, y: 620 }; | ||
| 164 | + | ||
| 165 | +async function restoreCursor() { | ||
| 166 | + await page.evaluate((p) => window.__demo && window.__demo.place(p.x, p.y), cursor).catch(() => {}); | ||
| 167 | +} | ||
| 168 | + | ||
| 169 | +async function moveTo(x, y, dur = GLIDE) { | ||
| 170 | + cursor.x = x; | ||
| 171 | + cursor.y = y; | ||
| 172 | + const d = scale(dur); | ||
| 173 | + await page.evaluate((a) => window.__demo.move(a.x, a.y, a.d), { x, y, d }); | ||
| 174 | + await page.mouse.move(x, y); | ||
| 175 | + await sleep(dur); | ||
| 176 | +} | ||
| 177 | + | ||
| 178 | +async function ripple() { | ||
| 179 | + await page.evaluate(() => window.__demo.tap()); | ||
| 180 | +} | ||
| 181 | + | ||
| 182 | +/** Élément par sélecteur, éventuellement filtré sur son texte. */ | ||
| 183 | +async function locate(selector, text) { | ||
| 184 | + const handle = text | ||
| 185 | + ? await page | ||
| 186 | + .evaluateHandle( | ||
| 187 | + (sel, t) => [...document.querySelectorAll(sel)].find((e) => e.textContent.trim().includes(t)) ?? null, | ||
| 188 | + selector, | ||
| 189 | + text, | ||
| 190 | + ) | ||
| 191 | + .then((h) => h.asElement()) | ||
| 192 | + : await page.$(selector); | ||
| 193 | + if (!handle) throw new Error(`introuvable : ${selector}${text ? ` « ${text} »` : ''}`); | ||
| 194 | + return handle; | ||
| 195 | +} | ||
| 196 | + | ||
| 197 | +/** | ||
| 198 | + * Attend la fin du défilement doux en cours. Attente mécanique : elle n'est | ||
| 199 | + * PAS soumise à TT_SPEED — viser une cible encore en mouvement rate le clic. | ||
| 200 | + */ | ||
| 201 | +async function settleScroll() { | ||
| 202 | + await page.evaluate( | ||
| 203 | + () => | ||
| 204 | + new Promise((resolve) => { | ||
| 205 | + const el = document.querySelector('.app-main') ?? document.scrollingElement; | ||
| 206 | + const bail = setTimeout(resolve, 2500); | ||
| 207 | + let last = NaN; | ||
| 208 | + let same = 0; | ||
| 209 | + (function tick() { | ||
| 210 | + same = el.scrollTop === last ? same + 1 : 0; | ||
| 211 | + last = el.scrollTop; | ||
| 212 | + if (same >= 3) { | ||
| 213 | + clearTimeout(bail); | ||
| 214 | + return resolve(); | ||
| 215 | + } | ||
| 216 | + requestAnimationFrame(tick); | ||
| 217 | + })(); | ||
| 218 | + }), | ||
| 219 | + ); | ||
| 220 | +} | ||
| 221 | + | ||
| 222 | +/** Amène l'élément dans la zone confortable (sous l'en-tête, au-dessus de la tab bar). */ | ||
| 223 | +async function bringIntoView(handle) { | ||
| 224 | + const scrolled = await handle.evaluate((el) => { | ||
| 225 | + const r = el.getBoundingClientRect(); | ||
| 226 | + if (r.top >= 96 && r.bottom <= window.innerHeight - 132) return false; | ||
| 227 | + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); | ||
| 228 | + return true; | ||
| 229 | + }); | ||
| 230 | + if (scrolled) await settleScroll(); | ||
| 231 | +} | ||
| 232 | + | ||
| 233 | +/** | ||
| 234 | + * Tape sur un élément. `nav: true` attend la navigation qui suit (et remet | ||
| 235 | + * l'incrustation en place sur la nouvelle page). `fixed: true` pour ce qui ne | ||
| 236 | + * scrolle pas (tab bar, pill du minuteur, bottom sheets) : les amener « dans | ||
| 237 | + * la vue » ferait bouger la page sous eux pour rien. | ||
| 238 | + */ | ||
| 239 | +async function tap(selector, { text, nav = false, fixed = false, pause = BEAT } = {}) { | ||
| 240 | + const handle = await locate(selector, text); | ||
| 241 | + if (!fixed) await bringIntoView(handle); | ||
| 242 | + const box = await handle.boundingBox(); | ||
| 243 | + if (!box) throw new Error(`invisible : ${selector}${text ? ` « ${text} »` : ''}`); | ||
| 244 | + const x = Math.round(box.x + box.width / 2); | ||
| 245 | + const y = Math.round(box.y + box.height / 2); | ||
| 246 | + await moveTo(x, y); | ||
| 247 | + await sleep(170); | ||
| 248 | + await ripple(); | ||
| 249 | + if (nav) { | ||
| 250 | + await Promise.all([ | ||
| 251 | + page.waitForNavigation({ waitUntil: 'domcontentloaded' }), | ||
| 252 | + page.mouse.click(x, y, { delay: 55 }), | ||
| 253 | + ]); | ||
| 254 | + await restoreCursor(); | ||
| 255 | + } else { | ||
| 256 | + await page.mouse.click(x, y, { delay: 55 }); | ||
| 257 | + } | ||
| 258 | + await sleep(pause); | ||
| 259 | + return handle; | ||
| 260 | +} | ||
| 261 | + | ||
| 262 | +/** Tape dans un champ puis y saisit le texte lettre à lettre. */ | ||
| 263 | +async function fill(selector, value, { pause = BEAT } = {}) { | ||
| 264 | + const handle = await tap(selector, { pause: 260 }); | ||
| 265 | + await handle.type(value, { delay: scale(KEY) }); | ||
| 266 | + await sleep(pause); | ||
| 267 | +} | ||
| 268 | + | ||
| 269 | +async function scrollTop({ pause = 900 } = {}) { | ||
| 270 | + await page.evaluate(() => { | ||
| 271 | + (document.querySelector('.app-main') ?? document.scrollingElement).scrollTo({ top: 0, behavior: 'smooth' }); | ||
| 272 | + }); | ||
| 273 | + await settleScroll(); | ||
| 274 | + await sleep(pause); | ||
| 275 | +} | ||
| 276 | + | ||
| 277 | +/** | ||
| 278 | + * Descend la page par paliers de 62 % de hauteur, en marquant un temps à | ||
| 279 | + * chaque palier — c'est le geste de quelqu'un qui parcourt un écran long. | ||
| 280 | + * S'arrête au bas de page (nombre de paliers borné par `max`). | ||
| 281 | + */ | ||
| 282 | +async function scrollThrough({ max = 8, pause = 1500 } = {}) { | ||
| 283 | + for (let i = 0; i < max; i++) { | ||
| 284 | + const more = await page.evaluate(() => { | ||
| 285 | + const el = document.querySelector('.app-main') ?? document.scrollingElement; | ||
| 286 | + const bottom = el.scrollHeight - el.clientHeight - el.scrollTop; | ||
| 287 | + if (bottom < 24) return false; | ||
| 288 | + el.scrollBy({ top: Math.min(bottom, el.clientHeight * 0.62), behavior: 'smooth' }); | ||
| 289 | + return true; | ||
| 290 | + }); | ||
| 291 | + if (!more) return; | ||
| 292 | + await settleScroll(); | ||
| 293 | + await sleep(pause); | ||
| 294 | + } | ||
| 295 | +} | ||
| 296 | + | ||
| 297 | +/** Onglets de la barre du bas. */ | ||
| 298 | +const TAB = { add: '/temps/nouveau', stats: '/stats', todos: '/taches', settings: '/parametres' }; | ||
| 299 | +async function tab(name, { pause = SCENE } = {}) { | ||
| 300 | + await tap(`.tabbar a[href="${TAB[name]}"]`, { nav: true, fixed: true, pause }); | ||
| 301 | +} | ||
| 302 | + | ||
| 303 | +function scene(title) { | ||
| 304 | + console.log(`▸ ${title}`); | ||
| 305 | +} | ||
| 306 | + | ||
| 307 | +// ─────────────────────────────────────────────────────────── le film | ||
| 308 | + | ||
| 309 | +await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' }); | ||
| 310 | +await restoreCursor(); | ||
| 311 | + | ||
| 312 | +const recorder = await page.screencast({ | ||
| 313 | + path: path.join(OUT, 'demo.webm'), | ||
| 314 | + ffmpegPath: FFMPEG, | ||
| 315 | + fps: 30, | ||
| 316 | + scale: 2, // 780×1688 : lisible en plein écran comme en vignette | ||
| 317 | +}); | ||
| 318 | + | ||
| 319 | +try { | ||
| 320 | + // ─── 1. Connexion ─────────────────────────────────────────────────────── | ||
| 321 | + scene('Connexion'); | ||
| 322 | + await sleep(2200); // plan d'ouverture sur le logo et la tagline | ||
| 323 | + await fill('input[name="username"]', USER); | ||
| 324 | + await fill('input[name="password"]', PASS, { pause: 700 }); | ||
| 325 | + await tap('button[type="submit"]', { nav: true, pause: SCENE }); | ||
| 326 | + | ||
| 327 | + if (page.url().includes('/login')) throw new Error('connexion échouée — vérifie TT_USER / TT_PASS.'); | ||
| 328 | + | ||
| 329 | + // ─── 2. Une équipe vide ───────────────────────────────────────────────── | ||
| 330 | + // Atterrissage sur l'onglet Ajouter : minuteur ou saisie manuelle. | ||
| 331 | + scene('Équipe vide'); | ||
| 332 | + await tap('.choice-manual', { nav: true, pause: SCENE }); // « Ajouter un temps » | ||
| 333 | + // L'étape 1 n'a rien à proposer : pas encore de catégorie. | ||
| 334 | + await sleep(READ); | ||
| 335 | + | ||
| 336 | + // ─── 3. Créer les catégories ──────────────────────────────────────────── | ||
| 337 | + scene('Création des catégories'); | ||
| 338 | + await tap('a[href="/parametres/categories"]', { nav: true, pause: BEAT }); | ||
| 339 | + | ||
| 340 | + for (const nom of ['Ménage', 'Cuisine', 'Vaisselle', 'Administratif']) { | ||
| 341 | + await fill('.category-add-row .input', nom, { pause: 150 }); | ||
| 342 | + await tap('.category-add-row button[type="submit"]', { nav: true, pause: BEAT }); | ||
| 343 | + await scrollTop({ pause: 150 }); | ||
| 344 | + } | ||
| 345 | + await sleep(SCENE); // les quatre catégories et leurs couleurs | ||
| 346 | + | ||
| 347 | + // ─── 4. Enregistrer des temps ─────────────────────────────────────────── | ||
| 348 | + // Chaque enregistrement renvoie sur l'écran de choix de l'onglet Ajouter | ||
| 349 | + // (redirection `?ok=`), d'où le passage par « Ajouter un temps » à chaque tour. | ||
| 350 | + scene('Saisie de temps'); | ||
| 351 | + await tap('a[href="/temps/nouveau"]', { text: 'Retour', nav: true, pause: READ }); | ||
| 352 | + | ||
| 353 | + // Chaque saisie montre une façon différente de poser la durée. | ||
| 354 | + const saisies = [ | ||
| 355 | + // Le stepper ± 5 min, puis un preset : les deux gestes du sélecteur. | ||
| 356 | + async () => { | ||
| 357 | + await tap('.stepper-btn[data-step="5"]', { pause: 380 }); | ||
| 358 | + await tap('.stepper-btn[data-step="5"]', { pause: 900 }); | ||
| 359 | + await tap('.preset[data-minutes="45"]', { pause: READ }); | ||
| 360 | + }, | ||
| 361 | + // Un preset, et la veille (les flèches de date rechargent la page). | ||
| 362 | + async () => { | ||
| 363 | + await tap('.preset[data-minutes="30"]', { pause: READ }); | ||
| 364 | + await tap('.date-row a[aria-label]', { nav: true, pause: READ }); | ||
| 365 | + }, | ||
| 366 | + // La durée exacte, au clavier. | ||
| 367 | + async () => { | ||
| 368 | + await tap('#minutes', { pause: 260 }); | ||
| 369 | + await page.evaluate(() => document.querySelector('#minutes').select()); | ||
| 370 | + await page.keyboard.type('20', { delay: scale(KEY) }); | ||
| 371 | + await page.evaluate(() => | ||
| 372 | + document.querySelector('#minutes').dispatchEvent(new Event('input', { bubbles: true })), | ||
| 373 | + ); | ||
| 374 | + await sleep(READ); | ||
| 375 | + }, | ||
| 376 | + async () => { | ||
| 377 | + await tap('.preset[data-minutes="60"]', { pause: READ }); | ||
| 378 | + }, | ||
| 379 | + ]; | ||
| 380 | + const categoriesSaisies = ['Cuisine', 'Ménage', 'Vaisselle', 'Administratif']; | ||
| 381 | + for (const [i, nom] of categoriesSaisies.entries()) { | ||
| 382 | + await tap('.choice-manual', { nav: true, pause: i === 0 ? SCENE : READ }); | ||
| 383 | + await tap('.category-pick a', { text: nom, nav: true, pause: READ }); | ||
| 384 | + await saisies[i](); | ||
| 385 | + await tap('form[data-duration] button[type="submit"]', { nav: true, pause: TOAST }); | ||
| 386 | + } | ||
| 387 | + | ||
| 388 | + // ─── 5. Les statistiques ──────────────────────────────────────────────── | ||
| 389 | + scene('Statistiques'); | ||
| 390 | + await tab('stats'); | ||
| 391 | + await scrollThrough({ pause: 1400 }); // jour, aujourd'hui, semaine, mois, année, tout | ||
| 392 | + await sleep(READ); | ||
| 393 | + await scrollTop({ pause: 1000 }); | ||
| 394 | + | ||
| 395 | + // Détail d'un segment : bottom sheet en :target, zéro JS. On vise le plus | ||
| 396 | + // gros segment visible — le petit ne se touche pas confortablement. | ||
| 397 | + const segment = ( | ||
| 398 | + await page.evaluateHandle(() => { | ||
| 399 | + let best = null; | ||
| 400 | + let area = 0; | ||
| 401 | + for (const s of document.querySelectorAll('.col-bar a')) { | ||
| 402 | + const r = s.getBoundingClientRect(); | ||
| 403 | + if (r.top < 96 || r.bottom > window.innerHeight - 132) continue; // hors de la zone confortable | ||
| 404 | + if (r.width * r.height > area) { | ||
| 405 | + area = r.width * r.height; | ||
| 406 | + best = s; | ||
| 407 | + } | ||
| 408 | + } | ||
| 409 | + return best; | ||
| 410 | + }) | ||
| 411 | + ).asElement(); | ||
| 412 | + if (segment) { | ||
| 413 | + const box = await segment.boundingBox(); | ||
| 414 | + await moveTo(Math.round(box.x + box.width / 2), Math.round(box.y + box.height / 2)); | ||
| 415 | + await sleep(200); | ||
| 416 | + await ripple(); | ||
| 417 | + await page.mouse.click(cursor.x, cursor.y, { delay: 55 }); | ||
| 418 | + await sleep(SCENE); | ||
| 419 | + if (await page.$('.sheet-wrap:target')) { | ||
| 420 | + await tap('.sheet-wrap:target .close', { fixed: true, pause: READ }); | ||
| 421 | + } else { | ||
| 422 | + console.warn('⚠ la feuille de détail ne s’est pas ouverte'); | ||
| 423 | + } | ||
| 424 | + } else { | ||
| 425 | + console.warn('⚠ aucun segment à ouvrir — le détail est sauté'); | ||
| 426 | + } | ||
| 427 | + | ||
| 428 | + // ─── 6. Lancer le minuteur ────────────────────────────────────────────── | ||
| 429 | + scene('Minuteur'); | ||
| 430 | + await tab('add', { pause: READ }); | ||
| 431 | + await tap('.choice-timer', { nav: true, pause: 5000 }); // les secondes défilent | ||
| 432 | + | ||
| 433 | + // ─── 7. Créer des tâches (le minuteur tourne pendant ce temps) ────────── | ||
| 434 | + scene('Tâches'); | ||
| 435 | + await tab('todos'); | ||
| 436 | + | ||
| 437 | + // Toutes datées d'aujourd'hui : les trois restent visibles dans « À faire » | ||
| 438 | + // (une date plus lointaine les enverrait dans le bloc replié « À venir »). | ||
| 439 | + const taches = [ | ||
| 440 | + { nom: 'Vider le lave-vaisselle', cat: 'Vaisselle', assigne: 'alex' }, | ||
| 441 | + { nom: 'Passer l’aspirateur', cat: 'Ménage', assigne: 'lou' }, | ||
| 442 | + { nom: 'Trier le courrier', cat: 'Administratif', assigne: null }, | ||
| 443 | + ]; | ||
| 444 | + // La scène est volontairement posée : c'est elle qui fait tourner le | ||
| 445 | + // minuteur, et la mesure enregistrée à la fin doit être crédible. | ||
| 446 | + for (const t of taches) { | ||
| 447 | + await tap('a[href="/taches/nouvelle"]', { nav: true, pause: SCENE }); | ||
| 448 | + await fill('#todo-nom', t.nom, { pause: DWELL }); | ||
| 449 | + await tap('.chips[role="radiogroup"] .chip', { text: t.cat, pause: DWELL }); | ||
| 450 | + if (t.assigne) await tap('.chips[role="group"] .chip', { text: t.assigne, pause: DWELL }); | ||
| 451 | + await tap('form button[type="submit"]', { text: 'Créer', nav: true, pause: SCENE }); | ||
| 452 | + await sleep(BEAT); // la tâche vient s'ajouter à la liste | ||
| 453 | + } | ||
| 454 | + | ||
| 455 | + // Le filtre « Mes tâches » du bloc À faire, aller-retour : « Passer | ||
| 456 | + // l'aspirateur », assignée à lou, disparaît puis revient. | ||
| 457 | + await tap('.blk-toggle', { nav: true, pause: 3600 }); | ||
| 458 | + await tap('.blk-toggle', { nav: true, pause: 3600 }); | ||
| 459 | + | ||
| 460 | + // ─── 8. Arrêter le minuteur depuis la pill ────────────────────────────── | ||
| 461 | + scene('Arrêt du minuteur'); | ||
| 462 | + await tap('.timer-banner.timer-running', { nav: true, fixed: true, pause: SCENE }); | ||
| 463 | + | ||
| 464 | + // ─── 9. Ranger la mesure dans une catégorie ───────────────────────────── | ||
| 465 | + scene('Enregistrement de la mesure'); | ||
| 466 | + await tap('.category-pick a', { text: 'Ménage', nav: true, pause: SCENE }); | ||
| 467 | + await tap('form[data-duration] button[type="submit"]', { nav: true, pause: TOAST }); | ||
| 468 | + | ||
| 469 | + // ─── 10. Plan de fin sur les stats ────────────────────────────────────── | ||
| 470 | + scene('Plan de fin'); | ||
| 471 | + await tab('stats', { pause: SCENE }); | ||
| 472 | + await moveTo(195, 880, 700); // le curseur sort du cadre | ||
| 473 | + await sleep(1600); | ||
| 474 | +} finally { | ||
| 475 | + // Le screencast ne comble un blanc qu'à l'arrivée de l'image suivante : | ||
| 476 | + // un dernier repaint garantit que la fin de plan est bien dans le fichier. | ||
| 477 | + await page.evaluate(() => window.__demo && window.__demo.tap()).catch(() => {}); | ||
| 478 | + await sleep(600); | ||
| 479 | + await recorder.stop(); | ||
| 480 | + await browser.close(); | ||
| 481 | +} | ||
| 482 | + | ||
| 483 | +// ─────────────────────────────────────────────────────── mp4 partageable | ||
| 484 | + | ||
| 485 | +// Le .webm sort en VP9 ; le H.264 passe partout (montage, Slack, keynote). | ||
| 486 | +const webm = path.join(OUT, 'demo.webm'); | ||
| 487 | +const mp4 = path.join(OUT, 'demo.mp4'); | ||
| 488 | +const conv = spawnSync( | ||
| 489 | + FFMPEG, | ||
| 490 | + ['-y', '-loglevel', 'error', '-i', webm, '-c:v', 'libx264', '-preset', 'slow', '-crf', '20', | ||
| 491 | + '-pix_fmt', 'yuv420p', '-movflags', '+faststart', mp4], | ||
| 492 | + { stdio: 'inherit' }, | ||
| 493 | +); | ||
| 494 | +if (conv.status !== 0) { | ||
| 495 | + console.warn(`⚠ conversion mp4 échouée (code ${conv.status}) — le .webm est utilisable tel quel`); | ||
| 496 | +} else { | ||
| 497 | + console.log(`✓ ${webm}\n✓ ${mp4}`); | ||
| 498 | +} | ||
modified
docs/slides/slides.pdf +66 -62 | @@ -3870,14 +3870,18 @@ endobj | ||
| 3870 | 3870 | |
| 3871 | 3871 | 325 0 obj |
| 3872 | 3872 | << |
| 3873 | - /Length 729 | |
| 3873 | + /Length 728 | |
| 3874 | 3874 | /Filter /FlateDecode |
| 3875 | 3875 | >> |
| 3876 | 3876 | stream |
| 3877 | -x��UKo1��W���:��J�q ��J(�2ҶH�l���Gy�kٶt.��g������v�mu�����R�TޤE<k���Z4ͣB� ���N<:@ģ ��٨_� F� ���yl��Uj�>��K����ngg | |
| 3878 | -`qs�� | |
| 3879 | -P����UJ���Q$�`����gj���Ӄ��6��6b�Tҽ�G��� H�uZ=iF�<e34�X��g�pBҁF �����5:i�/ΛZ��2�G�T�_W���M���p*p:���e��O���D"����`�d����V���J���NbU]�:X��"�N6��,r�H���8M &ZEQs�&ȏ�I��{�M�� �ɍ8̝��`��a�����#/.̲>�^/)�5��TJ�lz!K|���.+�)qK]��b �(b� �L 9L%�.��u>+��g�����(���?$2�-��[<��/_I��Y��(Qv_+T�<�2�o��?2�!�fk���3d%"�4 ��I4����b��[�X!� ���狰\��'��x�h�Ju�=��՞~��{u�|� `�f6���Cʃ��A��� �,>r | |
| 3880 | -'{�����~�����ݔSvV{�~���O�7E��~�I8�ڟ�J����T��<�cZy��2�Cf�a����K&����9���47��_O>t�B��1�K���Oc�Or��1�M@�J�Q�v.��\���4%���f� | |
| 3877 | +x��U�n1��S�E��< | |