From d31b769ab80b0d6804d94fabc7f7d54f01daea17 Mon Sep 17 00:00:00 2001 From: Millian Lamiaux Date: Thu, 21 May 2026 10:21:57 +0200 Subject: [PATCH] chore: update docs and remove stale skill files --- .claude/skills/audio/SKILL.md | 369 ----------- .claude/skills/building-native-ui | 1 - .claude/skills/design_system/SKILL.md | 576 ------------------ .claude/skills/exercises/SKILL.md | 428 ------------- .claude/skills/gitnexus/gitnexus-cli/SKILL.md | 3 +- .claude/skills/timer/SKILL.md | 349 ----------- .claude/skills/workflow/SKILL.md | 233 ------- AGENTS.md | 60 +- CLAUDE.md | 60 +- 9 files changed, 4 insertions(+), 2075 deletions(-) delete mode 100644 .claude/skills/audio/SKILL.md delete mode 120000 .claude/skills/building-native-ui delete mode 100644 .claude/skills/design_system/SKILL.md delete mode 100644 .claude/skills/exercises/SKILL.md delete mode 100644 .claude/skills/timer/SKILL.md delete mode 100644 .claude/skills/workflow/SKILL.md diff --git a/.claude/skills/audio/SKILL.md b/.claude/skills/audio/SKILL.md deleted file mode 100644 index 2540ec8..0000000 --- a/.claude/skills/audio/SKILL.md +++ /dev/null @@ -1,369 +0,0 @@ -# Skill — Audio Engine (useAudioEngine) -> Lis ce skill AVANT d'implémenter quoi que ce soit lié à l'audio. - -## Responsabilité de ce module -L'audio engine gère 4 couches sonores indépendantes qui s'activent -en réponse aux événements du timer. Il ne connaît pas le timer -directement — il s'abonne à ses événements via useTimerSync. - -**Objectif principal : zéro latence audio sur les transitions de phase.** -Les sons de phase doivent jouer dans les 50ms suivant l'événement. - ---- - -## Architecture du module audio - -``` -src/features/audio/ - types.ts - hooks/ - useAudioEngine.ts ← moteur central expo-av - useAudioSettings.ts ← préférences utilisateur persistées - data/ - tracks.ts ← catalogue musique (offline, paths locaux) - sounds.ts ← catalogue signaux de phase - components/ - MusicPicker.tsx ← sélecteur d'ambiance musicale - SoundPicker.tsx ← sélecteur de signal de phase - AudioSettingsPanel.tsx ← panneau complet des réglages - index.ts -``` - ---- - -## Types - -```typescript -// src/features/audio/types.ts - -export type MusicAmbiance = 'ELECTRO' | 'HIP_HOP' | 'ROCK' | 'SILENCE' -export type MusicIntensity = 'LOW' | 'MEDIUM' | 'HIGH' -export type PhaseSound = 'beep' | 'whistle' | 'voice_go' | 'air_horn' | 'bell' | 'silence' - -export interface AudioTrack { - id: string - ambiance: MusicAmbiance - intensity: MusicIntensity - asset: number // require('../assets/audio/...') — offline - bpm: number - durationMs: number -} - -export interface AudioSettings { - musicEnabled: boolean - ambiance: MusicAmbiance - musicVolume: number // 0.0 → 1.0 - soundsEnabled: boolean - soundsVolume: number // 0.0 → 1.0 - hapticsEnabled: boolean - voiceEnabled: boolean // annonces vocales - overrideMuteSwitch: boolean // iOS : jouer même en mode silencieux - workPhaseSound: PhaseSound - restPhaseSound: PhaseSound - countdownSound: PhaseSound - completionSound: PhaseSound -} - -export interface AudioState { - isLoaded: boolean - isMusicPlaying: boolean - currentAmbiance: MusicAmbiance - currentIntensity: MusicIntensity - error: string | null -} - -export interface AudioActions { - preloadAll: () => Promise - startMusic: (ambiance: MusicAmbiance, intensity: MusicIntensity) => Promise - switchIntensity: (intensity: MusicIntensity, fadeMs?: number) => Promise - stopMusic: (fadeMs?: number) => Promise - playPhaseSound: (sound: PhaseSound) => Promise - playCountdown: (seconds: number) => Promise // 3, 2, 1 - unloadAll: () => Promise -} -``` - ---- - -## Implémentation — useAudioEngine - -### Configuration de la session audio iOS/Android - -**Critique à faire en premier, avant tout chargement de son.** - -```typescript -import { Audio } from 'expo-av' - -// À appeler une seule fois au démarrage de l'app (dans _layout.tsx) -export async function configureAudioSession(): Promise { - await Audio.setAudioModeAsync({ - // iOS : jouer par-dessus la musique de l'utilisateur - playsInSilentModeIOS: true, // ignore le switch mute (si overrideMuteSwitch) - allowsRecordingIOS: false, - staysActiveInBackground: true, // CRITIQUE — timer continue en background - - // Android - shouldDuckAndroid: true, // baisser le volume autres apps sur nos signaux - playThroughEarpieceAndroid: false, - interruptionModeIOS: 1, // DO_NOT_MIX = 1 (nos sons ont priorité sur signaux) - interruptionModeAndroid: 1, - }) -} -``` - -### Preloading — charger AVANT la séance - -Tous les sons doivent être chargés en mémoire AVANT que la séance commence. -Si on charge pendant la séance → latence inacceptable. - -```typescript -const soundRefs = useRef>({}) - -async function preloadAll(settings: AudioSettings): Promise { - // 1. Charger la track musicale correspondante à l'ambiance + les 2 intensités - const trackWork = getTrack(settings.ambiance, 'HIGH') - const trackRest = getTrack(settings.ambiance, 'LOW') - - soundRefs.current['music_work'] = await loadSound(trackWork.asset) - soundRefs.current['music_rest'] = await loadSound(trackRest.asset) - - // 2. Charger tous les signaux de phase - const soundAssets = getSoundAssets(settings) - for (const [key, asset] of Object.entries(soundAssets)) { - soundRefs.current[key] = await loadSound(asset) - } - - // 3. Mettre en loop les tracks musicales - await soundRefs.current['music_work'].setIsLoopingAsync(true) - await soundRefs.current['music_rest'].setIsLoopingAsync(true) -} - -async function loadSound(asset: number): Promise { - const { sound } = await Audio.Sound.createAsync(asset, { - shouldPlay: false, - volume: 1.0, - }) - return sound -} -``` - -### Crossfade entre phases - -```typescript -async function switchIntensity( - to: MusicIntensity, - fadeMs: number = 500 -): Promise { - const from = currentIntensityRef.current - if (from === to) return - - const outKey = from === 'HIGH' ? 'music_work' : 'music_rest' - const inKey = to === 'HIGH' ? 'music_work' : 'music_rest' - - const outSound = soundRefs.current[outKey] - const inSound = soundRefs.current[inKey] - - if (!outSound || !inSound) return - - // Démarrer la track entrante depuis le début (ou depuis la position actuelle ?) - await inSound.setPositionAsync(0) - await inSound.setVolumeAsync(0) - await inSound.playAsync() - - // Fade simultané - const steps = 10 - const stepMs = fadeMs / steps - const volumeStep = settings.musicVolume / steps - - for (let i = 0; i <= steps; i++) { - await Promise.all([ - outSound.setVolumeAsync(Math.max(0, settings.musicVolume - i * volumeStep)), - inSound.setVolumeAsync(Math.min(settings.musicVolume, i * volumeStep)), - ]) - await delay(stepMs) - } - - await outSound.stopAsync() - currentIntensityRef.current = to -} - -const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) -``` - -### Répondre aux événements timer - -```typescript -// Dans useTimerSync — pont entre timer et audio - -import { useTimerEngine } from '../timer' -import { useAudioEngine } from '../audio' - -export function useTimerSync() { - const timer = useTimerEngine() - const audio = useAudioEngine() - - useEffect(() => { - const unsubscribe = timer.addEventListener((event) => { - switch (event.type) { - case 'PHASE_CHANGED': - handlePhaseChange(event.from, event.to) - break - case 'COUNTDOWN_TICK': - audio.playCountdown(event.secondsLeft) - break - case 'SESSION_COMPLETE': - audio.playPhaseSound('completion') - audio.stopMusic(1000) - break - } - }) - return unsubscribe - }, []) - - async function handlePhaseChange(from: TimerPhase, to: TimerPhase) { - switch (to) { - case 'GET_READY': - await audio.startMusic(settings.ambiance, 'MEDIUM') - break - case 'WORK': - await audio.playPhaseSound(settings.workPhaseSound) - await audio.switchIntensity('HIGH', 500) - break - case 'REST': - await audio.playPhaseSound(settings.restPhaseSound) - await audio.switchIntensity('LOW', 500) - break - } - } -} -``` - ---- - -## Catalogue des tracks (offline) - -```typescript -// src/features/audio/data/tracks.ts -import { MusicAmbiance, MusicIntensity, AudioTrack } from '../types' - -export const TRACKS: AudioTrack[] = [ - { - id: 'electro_high', - ambiance: 'ELECTRO', - intensity: 'HIGH', - asset: require('../../../assets/audio/music/electro_high.mp3'), - bpm: 140, - durationMs: 180000, - }, - { - id: 'electro_low', - ambiance: 'ELECTRO', - intensity: 'LOW', - asset: require('../../../assets/audio/music/electro_low.mp3'), - bpm: 90, - durationMs: 180000, - }, - // ... (9 tracks au total) -] - -export function getTrack(ambiance: MusicAmbiance, intensity: MusicIntensity): AudioTrack { - const track = TRACKS.find(t => t.ambiance === ambiance && t.intensity === intensity) - if (!track) throw new Error(`Track non trouvée : ${ambiance} ${intensity}`) - return track -} -``` - -## Catalogue des sons de phase - -```typescript -// src/features/audio/data/sounds.ts -export const PHASE_SOUNDS = { - beep: require('../../../assets/audio/sounds/beep_long.mp3'), - whistle: require('../../../assets/audio/sounds/whistle.mp3'), - voice_go: require('../../../assets/audio/sounds/voice_go.mp3'), - air_horn: require('../../../assets/audio/sounds/air_horn.mp3'), - bell: require('../../../assets/audio/sounds/bell.mp3'), - double_beep: require('../../../assets/audio/sounds/beep_double.mp3'), - countdown_3: require('../../../assets/audio/sounds/count_3.mp3'), - countdown_2: require('../../../assets/audio/sounds/count_2.mp3'), - countdown_1: require('../../../assets/audio/sounds/count_1.mp3'), - fanfare: require('../../../assets/audio/sounds/fanfare.mp3'), - clap: require('../../../assets/audio/sounds/applause.mp3'), -} -``` - ---- - -## Gestion du switch silencieux iOS - -```typescript -// Si overrideMuteSwitch = true dans les settings -await Audio.setAudioModeAsync({ - playsInSilentModeIOS: settings.overrideMuteSwitch, -}) -``` - -**Règle UX :** Les signaux de phase ont l'override activé par défaut -(l'utilisateur a besoin de savoir quand changer d'exercice même en mode silencieux). -La musique d'ambiance respecte le switch silencieux par défaut. - ---- - -## Cleanup obligatoire - -```typescript -// Dans useAudioEngine — cleanup au unmount ou fin de séance -async function unloadAll(): Promise { - await Promise.all( - Object.values(soundRefs.current).map(sound => sound.unloadAsync()) - ) - soundRefs.current = {} -} - -useEffect(() => { - return () => { - // Cleanup synchrone au unmount - Object.values(soundRefs.current).forEach(sound => { - sound.unloadAsync().catch(console.error) - }) - } -}, []) -``` - ---- - -## Tests minimaux - -```typescript -describe('useAudioEngine', () => { - it('configure la session audio au mount', () => {}) - it('preloadAll charge les sons sans erreur', () => {}) - it('playPhaseSound joue le bon son dans les 50ms', () => {}) - it('switchIntensity effectue un crossfade', () => {}) - it('stopMusic fait un fade-out', () => {}) - it('unloadAll libère toutes les ressources', () => {}) - it('ne plante pas si un asset est manquant (fallback)', () => {}) -}) -``` - ---- - -## Erreurs classiques à éviter - -```typescript -// ❌ Charger les sons à la demande → latence -case 'PHASE_CHANGED': - const { sound } = await Audio.Sound.createAsync(asset) // ← trop lent ! - await sound.playAsync() - -// ✅ Utiliser les sons préchargés -case 'PHASE_CHANGED': - await soundRefs.current['work_start'].replayAsync() // ← instantané - -// ❌ Oublier de configurer la session audio → muet sur iOS background -await sound.playAsync() // sans Audio.setAudioModeAsync() → silencieux en background - -// ❌ Pas de gestion du duck Android → nos sons se mélangent avec d'autres apps -interruptionModeAndroid: 2 // DUCK_OTHERS — OK pour la musique d'ambiance -interruptionModeAndroid: 1 // DO_NOT_MIX — pour les signaux de phase -``` diff --git a/.claude/skills/building-native-ui b/.claude/skills/building-native-ui deleted file mode 120000 index 1fa2f01..0000000 --- a/.claude/skills/building-native-ui +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/building-native-ui \ No newline at end of file diff --git a/.claude/skills/design_system/SKILL.md b/.claude/skills/design_system/SKILL.md deleted file mode 100644 index 1ef0513..0000000 --- a/.claude/skills/design_system/SKILL.md +++ /dev/null @@ -1,576 +0,0 @@ ---- -name: tabata-kine-design-system -description: > - Design system complet pour l'application Tabata Kiné. Utilise ce skill pour - toute tâche liée au design, aux composants UI, aux écrans, aux couleurs, à la - typographie ou aux décisions d'interface de l'app Tabata Kiné. Déclenche ce - skill dès que l'utilisateur mentionne : un écran de l'app (onboarding, séance, - dashboard, paywall, programmes), un composant (bouton, carte, timer, badge, - input), une couleur, une typographie, une animation, un espacement, ou demande - à coder un élément UI. Ce skill contient les règles non négociables du design - "Dark Medical" — le style qui différencie l'app de tous les concurrents fitness. ---- - -# Design System — Tabata Kiné - -## Principe directeur : Dark Medical - -L'app Tabata Kiné n'est **pas** une app fitness classique. C'est une app médicale -qui utilise le format tabata. Le style "Dark Medical" traduit visuellement ce -positionnement : fond sombre professionnel, vert santé comme seule couleur d'action, -expertise kiné visible à chaque écran. - -**Règles absolues :** -- Pas de mode clair. Dark only, sans exception. -- Le vert (#00C896) ne sert qu'aux actions et à la validation. -- L'orange (#FF8A5C) ne sert qu'aux conseils kiné et alertes positives. -- Le rouge (#FF4444) est réservé au timer en phase d'urgence (<10s). -- Touch target minimum : 44×44px pour tous les éléments interactifs. - ---- - -## 1. Tokens de couleur - -### Fonds — Navy - -| Token | Valeur | Usage | -|-------|--------|-------| -| `navy-900` | `#0D1B2A` | Fond principal de l'app | -| `navy-800` | `#112240` | Surface 1 — cartes par défaut | -| `navy-700` | `#1A3050` | Surface 2 — cartes surélevées | -| `navy-600` | `#243C5E` | Bordures actives | - -### Vert Kiné — action & santé - -| Token | Valeur | Usage | -|-------|--------|-------| -| `green-500` | `#00C896` | CTA principal, timer effort, progress | -| `green-600` | `#00A67C` | État hover / pressed | -| `green-700` | `#00875F` | État active deep | -| `green-dim` | `rgba(0,200,150,0.12)` | Fond badge, chip, card accent | -| `green-border` | `rgba(0,200,150,0.35)` | Bordure card accent | - -### Texte & bordures - -| Token | Valeur | Usage | -|-------|--------|-------| -| `white-100` | `#E6F1FF` | Texte primaire | -| `slate-300` | `#A8B2D8` | Texte secondaire | -| `slate-400` | `#8892B0` | Texte tertiaire, placeholders | -| `border-dim` | `rgba(168,178,216,0.15)` | Bordure par défaut | -| `border-hover` | `rgba(168,178,216,0.25)` | Bordure hover | - -### Orange — conseils kiné uniquement - -| Token | Valeur | Usage | -|-------|--------|-------| -| `orange-500` | `#FF8A5C` | Tip card border, badge Kiné+ | -| `orange-600` | `#E06A3C` | Hover orange | -| `orange-dim` | `rgba(255,138,92,0.12)` | Fond tip card | - -### Sémantique - -| Token | Valeur | Usage | -|-------|--------|-------| -| `red-500` | `#FF4444` | Timer urgence <10s UNIQUEMENT | - ---- - -## 2. Typographie - -### Familles - -| Rôle | Famille | Notes | -|------|---------|-------| -| Titres émotionnels | Serif italique (ex: DM Serif Display, Georgia) | Célébration, fin de séance, accroches | -| Interface & corps | Sans-serif géométrique (ex: Outfit, DM Sans) | Navigation, descriptions, labels | -| Données & timer | Monospace (ex: DM Mono, JetBrains Mono) | Timer, stats, codes, metadata | - -### Échelle - -| Style | Famille | Taille | Poids | Usage | -|-------|---------|--------|-------|-------| -| `display` | Serif italic | 28–32px | 400 | Fin de séance, titres forts | -| `heading-1` | Serif | 22–24px | 500 | Titres de section | -| `heading-2` | Sans | 18px | 500 | Titre exercice, carte programme | -| `body` | Sans | 15–16px | 400 | Corps, conseil kiné | -| `label` | Mono | 11–13px | 500 | Tags, metadata, uppercase tracking | -| `timer` | Mono | **80–100px** | 500 | Timer séance — lisible à 2 mètres | -| `caption` | Sans | 12px | 400 | Sous-labels, hints | - -**Règle typographie :** La taille du timer est la décision de design la plus -importante de l'écran séance. Tout se dimensionne autour de lui. - ---- - -## 3. Espacement - -Base : **4px** - -| Token | Valeur | Usage | -|-------|--------|-------| -| `space-1` | 4px | Gap minimal entre éléments liés | -| `space-2` | 8px | Gap interne composant | -| `space-3` | 12px | Gap entre composants proches | -| `space-4` | 16px | Padding carte, gap standard | -| `space-6` | 24px | Espacement sections | -| `space-8` | 32px | Padding écran horizontal | -| `space-12` | 48px | Espacement majeur | -| `space-16` | 64px | Espacement entre blocs screens | - ---- - -## 4. Border Radius - -| Token | Valeur | Usage | -|-------|--------|-------| -| `radius-sm` | 4px | Badge, chip, tag | -| `radius-md` | 8px | Bouton, input, tip card | -| `radius-lg` | 12px | Carte programme standard | -| `radius-xl` | 16px | Carte large, modal | -| `radius-pill` | 9999px | Pill, toggle, progress bar | -| `radius-circle` | 50% | Icon button, avatar, streak dot | - ---- - -## 5. Système d'élévation (surfaces) - -``` -Fond (navy-900) - └── Surface 1 (navy-800) — cartes par défaut - └── Surface 2 (navy-700) — cartes surélevées / hover - └── Surface active (navy-800 + border green-500 1.5px) -``` - -Différencier les surfaces **uniquement par la couleur de fond**, jamais par des -ombres portées (box-shadow : non). La bordure active verte est le seul signal -d'état sélectionné. - ---- - -## 6. Composants - -### Boutons - -``` -PrimaryButton - background: green-500 - color: navy-900 - padding: 14px 24px - height: 52–56px - border-radius: radius-md - font: sans 15px 500 - width: 100% (full-width dans les screens) - hover: background green-600 - active: background green-700 + scale(0.98) - -SecondaryButton - background: transparent - color: green-500 - border: 1.5px solid green-500 - padding: 13px 24px - hover: background green-dim - -GhostButton - background: transparent - color: slate-300 - no border - usage: actions secondaires (Passer, Annuler) - -DangerButton - background: rgba(255,68,68,0.12) - color: #FF6B6B - border: 1px solid rgba(255,68,68,0.3) - usage: Quitter la séance UNIQUEMENT - -IconButton - width: 44px - height: 44px - border-radius: 50% - background: rgba(168,178,216,0.10) - color: slate-300 - JAMAIS en dessous de 44×44px (accessibilité) -``` - -### Inputs - -``` -TextField - background: navy-800 - border: 1px solid border-dim - border-radius: radius-md - padding: 12px 16px - color: white-100 - font: sans 15px 400 - focus: border green-500 - error: border red-500 - height: 48px -``` - -### Badges & Pills - -``` -Badge (tier) - font: mono 11px 500 - padding: 3px 10px - border-radius: radius-sm - UPPERCASE + letter-spacing: 0.08em - - .free: background green-dim, color green-500 - .premium: background orange-dim, color orange-500 - .kine: background rgba(168,178,216,0.12), color slate-300 - -Pill (metadata) - font: sans 12px 400 - padding: 4px 12px - border-radius: radius-pill - border: 1px solid (couleur correspondante à 0.3 opacity) -``` - -### Cartes - -``` -CardDefault - background: navy-800 - border: 1px solid border-dim - border-radius: radius-lg - padding: 16px - -CardAccent (CTA, prochaine séance) - background: rgba(0,200,150,0.05) - border: 1.5px solid green-border - border-radius: radius-lg - -CardTip (conseil kiné) - background: orange-dim - border-left: 3px solid orange-500 - border-radius: 0 radius-lg radius-lg 0 - NE PAS arrondir le côté gauche (border-left unique) - Structure: icône 💡 + texte + signature "— Prénom, kiné" - -CardProgram - border-radius: radius-xl - overflow: hidden - Thumbnail: 120px height, gradient navy-700→navy-600 - Body: padding 14px - Toujours afficher: progression bar + "X/12 séances" -``` - -### Timer - -``` -Timer (composant le plus critique de l'app) - font: mono 80–100px 500 - text-align: center - - État effort normal (>10s): - color: green-500 - - État urgence (<10s): - color: red-500 - animation: pulse subtil (scale 1→1.02→1, 1s infinite) - - Label sous le chiffre: - font: mono 14px 400 - color: slate-400 - letter-spacing: 0.1em - text: "SECONDES" - - Contexte repos: - color: slate-300 (pas de vert, signal visuel de repos) -``` - -### Progress Bar - -``` -ProgressBar - track: background rgba(168,178,216,0.12), height 4px, border-radius pill - fill: background green-500, border-radius pill - - Variante séance (épaisseur réduite): - height: 3px - - Variante programme: - height: 4px - Afficher le % à droite en mono 11px green-500 - - Animation: transition width 300ms ease -``` - -### Feedback ressenti - -``` -FeedbackButton - width: flex (3 boutons égaux) - height: 72px - border-radius: radius-lg - background: navy-800 - border: 1px solid border-dim - flex-direction: column - gap: 4px - - Emoji: 28px - Label: sans 12px slate-400 - - État sélectionné: - border: 1.5px solid green-500 - background: green-dim -``` - -### Streak hebdomadaire - -``` -StreakDot - width: 32px - height: 32px - border-radius: 50% - - .done: background rgba(0,200,150,0.15) → afficher ✓ - .today: background green-500 → afficher ✓ - .empty: background rgba(168,178,216,0.06), border 1px border-dim - -Label jour: mono 10px slate-400, centré sous chaque dot -``` - ---- - -## 7. Écran séance — règles spéciales - -L'écran séance est le plus critique de l'app. Il doit être utilisable **les mains -sur les genoux, en sueur, à 2 mètres de l'écran**. Chaque décision de design doit -passer ce test. - -### Architecture visuelle - -``` -[Vidéo plein écran en boucle — fond de tout l'écran] - ↓ Gradient top navy→transparent (40% opacité, 100px height) - → Contrôles pause/audio en overlay - → Indicateur exercice X/8 centré - ↓ Zone centrale nette (pas de gradient — l'utilisateur voit le mouvement) - ↓ Gradient bottom transparent→navy (70% opacité, 220px height) - → Timer géant centré - → Progress bar - → Tip card conseil kiné -``` - -### Transitions séance - -``` -Effort → Repos: - Fond passe de vidéo plein écran → navy-800 uni - Transition: fade 300ms - Vibration haptique légère (si disponible) - Le repos a une identité visuelle différente (pas de vidéo, couleur unie) - -Repos → Effort: - Countdown audio "3... 2... 1..." - Vibration haptique + transition fade - -Exercice suivant pendant le repos: - Afficher un thumbnail 56×56px du prochain exercice - Nom en sans 14px 500 - Label "PROCHAIN EXERCICE" en mono 11px slate-400 -``` - -### Phase repos - -L'écran repos doit être **visuellement différent** de l'écran effort. - -- Fond : `navy-800` uni (plus de vidéo plein écran) -- Timer couleur : `slate-300` (pas de vert — c'est le repos) -- Mot "REPOS" en mono 13px slate-400, letter-spacing 0.15em -- Aperçu prochain exercice centré - ---- - -## 8. Navigation - -``` -Tab Bar (5 onglets, fixé en bas) - height: 56px + safe area inset - background: navy-800 - border-top: 1px solid border-dim - - Onglets: - - Accueil (home icon) - - Programmes (grid icon) - - Minuteur (timer icon) - - Progression (chart icon) - - Profil (person icon) - - Onglet actif: icône green-500 + label green-500 - Onglet inactif: icône slate-400 + label slate-400 - - Font label: sans 11px 400 - Icon size: 22×22px - Touch target: 44×44px minimum -``` - ---- - -## 9. Animations & micro-interactions - -``` -FadeIn: - opacity: 0 → 1 - duration: 300ms - easing: ease - -SlideUp (bottom sheet, modal): - translateY(100%) → 0 - duration: 400ms - easing: cubic-bezier(0.4, 0, 0.2, 1) - -Pulse (CTA bouton, timer urgence): - scale: 1 → 1.02 → 1 - duration: 2s - infinite, ease-in-out - -Bounce (célébration fin de séance): - scale: 0.5 → 1.05 → 0.98 → 1 - duration: 600ms - easing: spring - -StaggerList (items qui apparaissent en séquence): - Délai: 100ms entre chaque item - Chaque item: FadeIn + translateY(12px→0) - -ScalePress (tous les boutons): - active: scale(0.97) - duration: 100ms -``` - -**Règle d'or animations :** Une animation bien exécutée au chargement d'écran -vaut mieux que des micro-interactions dispersées partout. - ---- - -## 10. Paywall — règles de design conversion - -``` -Structure obligatoire du paywall: - 1. Célébration des accomplissements (TOUJOURS en premier) - → Font serif italic, emoji, stats concrètes - 2. Valeur du contenu débloqué (liste concrète) - 3. Pricing transparent (pas de dark patterns) - → "Essai gratuit 7 jours · puis 24,99€/an · soit 2,08€/mois" - 4. CTA principal (PrimaryButton full-width) - 5. Réassurance ("Annulation facile à tout moment") - 6. Alternative gratuite visible (GhostButton ou lien) - - Couleur encadré pricing: CardAccent (vert) - Bouton fermeture: TOUJOURS visible en haut à gauche - Pas de compte à rebours fictif, pas de stock limité : anti dark patterns -``` - ---- - -## 11. Accessibilité - -``` -Contraste texte: - Texte primaire (#E6F1FF) sur navy-900 → ratio 15:1 ✓ - Texte secondaire (#A8B2D8) sur navy-900 → ratio 7:1 ✓ - Vert (#00C896) sur navy-900 → ratio 8:1 ✓ - Tous conformes WCAG AA (4.5:1 minimum requis) - -Touch targets: - Minimum 44×44px pour TOUS les éléments interactifs - Espacement minimum 8px entre deux éléments interactifs adjacents - -Timer: - La couleur n'est pas le seul signal d'urgence - Ajouter aussi: pulse animation + vibration haptique + signal audio - -Audio: - Toujours proposer une alternative visuelle à chaque signal audio - Le toggle audio est accessible en 1 tap depuis l'écran séance -``` - ---- - -## 12. Tokens React Native / Expo - -```typescript -// design-tokens.ts -export const colors = { - // Navy - navy900: '#0D1B2A', - navy800: '#112240', - navy700: '#1A3050', - navy600: '#243C5E', - - // Green - green500: '#00C896', - green600: '#00A67C', - green700: '#00875F', - greenDim: 'rgba(0,200,150,0.12)', - greenBorder: 'rgba(0,200,150,0.35)', - - // Text - white100: '#E6F1FF', - slate300: '#A8B2D8', - slate400: '#8892B0', - - // Borders - borderDim: 'rgba(168,178,216,0.15)', - borderHover: 'rgba(168,178,216,0.25)', - - // Orange (tip/kine only) - orange500: '#FF8A5C', - orange600: '#E06A3C', - orangeDim: 'rgba(255,138,92,0.12)', - - // Semantic - red500: '#FF4444', // timer urgence ONLY -} as const - -export const spacing = { - 1: 4, - 2: 8, - 3: 12, - 4: 16, - 6: 24, - 8: 32, - 12: 48, - 16: 64, -} as const - -export const radius = { - sm: 4, - md: 8, - lg: 12, - xl: 16, - pill: 9999, -} as const - -export const fontSizes = { - caption: 12, - label: 13, - body: 15, - heading2: 18, - heading1: 22, - display: 28, - timer: 88, // taille par défaut du timer -} as const - -export const timerThreshold = 10 // secondes — passage vert → rouge -``` - ---- - -## Checklist avant livraison d'un écran - -- [ ] Fond `navy-900` utilisé comme base -- [ ] Aucun shadow/élévation — différenciation par couleur uniquement -- [ ] Tous les touch targets ≥ 44×44px -- [ ] Le vert n'est utilisé que pour des actions ou validations -- [ ] L'orange n'est utilisé que pour des conseils kiné ou alertes positives -- [ ] Le rouge n'apparaît que sur le timer en urgence -- [ ] Timer ≥ 80px de haut sur l'écran séance -- [ ] Vidéo plein écran sur l'écran séance (pas un bloc vidéo) -- [ ] Gradients top + bottom sur l'écran séance pour la lisibilité -- [ ] Phase repos visuellement différente de la phase effort -- [ ] Paywall : célébration en premier, alternative gratuite visible -- [ ] Typographie : serif pour les moments émotionnels, mono pour les données diff --git a/.claude/skills/exercises/SKILL.md b/.claude/skills/exercises/SKILL.md deleted file mode 100644 index 0602ee5..0000000 --- a/.claude/skills/exercises/SKILL.md +++ /dev/null @@ -1,428 +0,0 @@ -# Skill — Exercices (useExercise + ExerciseDisplay) -> Lis ce skill AVANT d'implémenter quoi que ce soit lié aux exercices. - -## Responsabilité de ce module -Gérer la bibliothèque d'exercices, la sélection selon le programme, -et l'affichage contextuel selon la phase du timer. -**Offline-first** : toutes les données et les GIFs sont dans le bundle. - ---- - -## Architecture du module exercices - -``` -src/features/exercises/ - types.ts - hooks/ - useExercise.ts ← sélection + navigation entre exercices - useExerciseLibrary.ts ← recherche, filtrage, favoris - components/ - ExerciseDisplay.tsx ← affichage contextuel selon la phase timer - ExerciseGif.tsx ← Image GIF avec fallback + accessibilité - ExerciseCard.tsx ← carte pour la bibliothèque/programme - ExerciseCues.tsx ← affichage des cues de forme - data/ - exercises.ts ← les 38 exercices (source de vérité) - categories.ts ← définition des catégories - index.ts -``` - ---- - -## Types - -```typescript -// src/features/exercises/types.ts - -export type ExerciseCategory = - | 'CARDIO' - | 'LOWER_BODY' - | 'UPPER_BODY' - | 'CORE' - | 'LOW_IMPACT' - | 'EQUIPMENT' - -export type ExerciseDifficulty = 'BEGINNER' | 'INTERMEDIATE' | 'ADVANCED' - -export interface Exercise { - id: string - name: Record // clés = codes langue : { fr, en, es, de, pt } - category: ExerciseCategory - difficulty: ExerciseDifficulty - musclesTargeted: string[] - description: Record // max 80 caractères par langue - cues: Record // 2-3 points clés de forme par langue - gifAsset: number | null // require(...) ou null si pas de GIF - thumbnailAsset: number | null - hasModification: boolean // variante plus facile disponible - modificationId: string | null // id de l'exercice modifié - equipmentNeeded: string[] // [] = aucun matériel - estimatedCaloriesPerMinute: number // approximatif -} - -export type ProgramMode = 'SINGLE' | 'DUO' | 'CIRCUIT_4' | 'FREE' - -export interface ExerciseProgram { - id: string - name: Record - description: Record - mode: ProgramMode - // Pour SINGLE : [exerciseId] — même exercice répété - // Pour DUO : [exerciseIdA, exerciseIdB] — alternance - // Pour CIRCUIT_4 : [a, b, c, d] — 4 exercices × 2 rounds - // Pour FREE : exerciseId par round [r1, r2, r3, r4, r5, r6, r7, r8] - exerciseIds: string[] - difficulty: ExerciseDifficulty - totalRounds: number - isDefault: boolean // programmes pré-définis vs créés par user -} - -// Résout l'exercice pour un round donné selon le mode du programme -export interface ExerciseState { - current: Exercise - next: Exercise | null // null sur le dernier round - roundIndex: number // 0-indexé -} -``` - ---- - -## Données — les 38 exercices V1 - -Structure à respecter pour chaque exercice : - -```typescript -// src/features/exercises/data/exercises.ts -import { Exercise } from '../types' - -export const EXERCISES: Exercise[] = [ - { - id: 'burpee_classic', - name: { fr: 'Burpee', en: 'Burpee', es: 'Burpee', de: 'Burpee', pt: 'Burpee' }, - category: 'CARDIO', - difficulty: 'INTERMEDIATE', - musclesTargeted: ['quadriceps', 'pectoraux', 'épaules', 'cardio'], - description: { - fr: 'Position debout → squat → planche → pompe → saut vertical', - en: 'Stand → squat → plank → push-up → jump', - es: 'De pie → sentadilla → plancha → flexión → salto', - de: 'Stehen → Hocke → Plank → Liegestütz → Sprung', - pt: 'Em pé → agachamento → prancha → flexão → salto', - }, - cues: { - fr: ['Atterris doucement sur les orteils', 'Garde le dos droit en planche', 'Explose vers le haut'], - en: ['Land softly on toes', 'Keep back flat in plank', 'Explode upward'], - es: ['Aterriza suavemente', 'Espalda recta en plancha', 'Explota hacia arriba'], - de: ['Sanft auf den Zehen landen', 'Rücken gerade halten', 'Nach oben explodieren'], - pt: ['Aterrissar suavemente', 'Costas retas na prancha', 'Explodir para cima'], - }, - gifAsset: require('../../../assets/exercises/burpee_classic.gif'), - thumbnailAsset: require('../../../assets/exercises/thumbs/burpee_classic.jpg'), - hasModification: true, - modificationId: 'burpee_modified', - equipmentNeeded: [], - estimatedCaloriesPerMinute: 12, - }, - // ... 37 autres exercices -] - -// Accès rapide par ID -export const EXERCISES_MAP = Object.fromEntries( - EXERCISES.map(ex => [ex.id, ex]) -) as Record - -export function getExerciseById(id: string): Exercise { - const ex = EXERCISES_MAP[id] - if (!ex) throw new Error(`Exercice non trouvé : ${id}`) - return ex -} -``` - -**Liste des 38 exercices V1 à implémenter :** -``` -CARDIO (8) : burpee_classic, burpee_modified, jumping_jacks, - mountain_climbers, high_knees, jump_rope_sim, - box_jump_sim, lateral_shuffles - -LOWER_BODY (8) : squat_classic, squat_jump, lunge_alternating, - lunge_jump, glute_bridge, wall_sit, calf_raises, - sumo_squat - -UPPER_BODY (6) : pushup_classic, pushup_modified, pike_pushup, - tricep_dip, shoulder_taps, inchworm - -CORE (6) : crunch_classic, plank_hold, russian_twist, - bicycle_crunch, leg_raises, dead_bug - -LOW_IMPACT (6) : march_in_place, step_touch, modified_squat, - standing_oblique, slow_pushup, chair_stand - -EQUIPMENT (4) : kb_swing, db_thruster, resistance_band_row, - medicine_ball_slam -``` - ---- - -## Hook useExercise - -```typescript -// src/features/exercises/hooks/useExercise.ts - -export function useExercise(program: ExerciseProgram): { - getExerciseForRound: (round: number) => ExerciseState - currentExercise: Exercise - nextExercise: Exercise | null -} { - function getExerciseForRound(round: number): ExerciseState { - // round est 1-indexé - const index = round - 1 - - let currentId: string - let nextId: string | null - - switch (program.mode) { - case 'SINGLE': - currentId = program.exerciseIds[0] - nextId = null // même exercice → pas de "suivant" différent - break - - case 'DUO': - currentId = program.exerciseIds[index % 2] - nextId = program.exerciseIds[(index + 1) % 2] - break - - case 'CIRCUIT_4': - currentId = program.exerciseIds[index % 4] - nextId = program.exerciseIds[(index + 1) % 4] - break - - case 'FREE': - currentId = program.exerciseIds[index] ?? program.exerciseIds[0] - nextId = program.exerciseIds[index + 1] ?? null - break - } - - return { - current: getExerciseById(currentId), - next: nextId ? getExerciseById(nextId) : null, - roundIndex: index, - } - } - - return { getExerciseForRound, /* ... */ } -} -``` - ---- - -## Composant ExerciseDisplay — affichage contextuel - -Le composant adapte son layout selon la phase du timer. - -```typescript -interface ExerciseDisplayProps { - phase: TimerPhase - exercise: Exercise - nextExercise: Exercise | null - lang: string // code langue actuel -} - -export function ExerciseDisplay({ phase, exercise, nextExercise, lang }: ExerciseDisplayProps) { - switch (phase) { - case 'GET_READY': - return - // ↑ GIF grand format + nom + "Prépare-toi !" - - case 'WORK': - return - // ↑ Nom en haut + 2 cues + GIF petit coin bas-droit - - case 'REST': - return - // ↑ "Repos" + si nextExercise : "Prochain : [nom]" + vignette - - default: - return null - } -} -``` - -### GetReadyView -``` -┌─────────────────────────────┐ -│ │ -│ ┌─────────────────┐ │ -│ │ [GIF 200x200] │ │ ← Grand GIF centré -│ └─────────────────┘ │ -│ │ -│ Burpees │ ← Nom, police bold 28px -│ ← Dos droit en planche │ ← Cue #1 -│ ← Explose vers le haut │ ← Cue #2 -│ │ -└─────────────────────────────┘ -``` - -### WorkView -``` -┌─────────────────────────────┐ -│ Burpees │ ← Nom, 22px, coin haut-gauche -│ ← Dos droit │ ← Cue #1, 16px -│ ← Explose vers le haut │ ← Cue #2, 16px -│ │ -│ [Timer central] │ -│ │ -│ ┌────┐ │ -│ │GIF │ │ ← GIF 80x80, coin bas-droit -│ └────┘ │ -└─────────────────────────────┘ -``` - -### RestView -``` -┌─────────────────────────────┐ -│ │ -│ REPOS │ ← "REPOS" centré, grand -│ │ -│ Prochain : Mountain │ ← Si nextExercise != null -│ Climbers │ -│ ┌──────┐ │ -│ │ GIF │ │ ← Vignette 60x60 de nextExercise -│ └──────┘ │ -└─────────────────────────────┘ -``` - ---- - -## Composant ExerciseGif — robuste et accessible - -```typescript -interface ExerciseGifProps { - exercise: Exercise - size: 'small' | 'medium' | 'large' - lang: string -} - -const GIF_SIZES = { small: 80, medium: 120, large: 200 } - -export function ExerciseGif({ exercise, size, lang }: ExerciseGifProps) { - const [hasError, setHasError] = useState(false) - const dimension = GIF_SIZES[size] - - if (!exercise.gifAsset || hasError) { - // Fallback : icône + initiales de l'exercice - return ( - - 💪 - {getInitials(exercise.name[lang])} - - ) - } - - return ( - setHasError(true)} - accessible={true} - accessibilityLabel={`Démonstration de ${exercise.name[lang]}`} - /> - ) -} -``` - ---- - -## Localisation des exercices - -```typescript -// Utilitaire pour extraire la bonne langue avec fallback -export function getLocalizedText( - record: Record, - lang: string -): string { - return record[lang] ?? record['en'] ?? Object.values(record)[0] ?? '' -} - -// Utilisation -const name = getLocalizedText(exercise.name, userLang) -const cues = (exercise.cues[userLang] ?? exercise.cues['en'] ?? []) -``` - ---- - -## Programmes pré-définis V1 - -```typescript -// src/features/exercises/data/defaultPrograms.ts - -export const DEFAULT_PROGRAMS: ExerciseProgram[] = [ - { - id: 'beginner_classic', - name: { fr: 'Débutant Classic', en: 'Beginner Classic' }, - description: { fr: 'Parfait pour commencer le Tabata', en: 'Perfect to start Tabata' }, - mode: 'SINGLE', - exerciseIds: ['squat_classic'], - difficulty: 'BEGINNER', - totalRounds: 8, - isDefault: true, - }, - { - id: 'full_body_duo', - name: { fr: 'Full Body Duo', en: 'Full Body Duo' }, - description: { fr: 'Squats + Pompes — le duo parfait', en: 'Squats + Push-ups — the perfect duo' }, - mode: 'DUO', - exerciseIds: ['squat_jump', 'pushup_classic'], - difficulty: 'INTERMEDIATE', - totalRounds: 8, - isDefault: true, - }, - { - id: 'cardio_blast', - name: { fr: 'Cardio Blast', en: 'Cardio Blast' }, - description: { fr: 'Circuit intense 4 exercices cardio', en: '4-exercise intense cardio circuit' }, - mode: 'CIRCUIT_4', - exerciseIds: ['burpee_classic', 'high_knees', 'mountain_climbers', 'jumping_jacks'], - difficulty: 'ADVANCED', - totalRounds: 8, - isDefault: true, - }, - // + programmes par objectif (perte de poids, force, low impact, etc.) -] -``` - ---- - -## Tests minimaux - -```typescript -describe('useExercise', () => { - it('MODE SINGLE : retourne le même exercice à chaque round', () => {}) - it('MODE DUO : alterne A/B correctement sur 8 rounds', () => {}) - it('MODE CIRCUIT_4 : cycle sur 4 exercices', () => {}) - it('retourne null pour nextExercise sur le dernier round en FREE', () => {}) - it('getExerciseById lance une erreur si id inconnu', () => {}) -}) - -describe('ExerciseDisplay', () => { - it('affiche le GIF grand format en GET_READY', () => {}) - it('affiche les cues en WORK', () => {}) - it('affiche nextExercise en REST si disponible', () => {}) - it('affiche le fallback si gifAsset est null', () => {}) - it('est accessible (accessibilityLabel présent)', () => {}) -}) -``` - ---- - -## Checklist assets exercices - -Avant de lancer en production, vérifier : -- [ ] 38 GIFs présents dans `assets/exercises/` (200×200px, loop, < 200KB chacun) -- [ ] 38 thumbnails présents dans `assets/exercises/thumbs/` (< 30KB chacun) -- [ ] Toutes les traductions présentes pour EN, FR, ES, DE, PT -- [ ] Tous les `modificationId` pointent vers un exercice existant -- [ ] Bundle total des assets exercices < 10MB diff --git a/.claude/skills/gitnexus/gitnexus-cli/SKILL.md b/.claude/skills/gitnexus/gitnexus-cli/SKILL.md index c9e0af3..cd9a83b 100644 --- a/.claude/skills/gitnexus/gitnexus-cli/SKILL.md +++ b/.claude/skills/gitnexus/gitnexus-cli/SKILL.md @@ -21,8 +21,9 @@ Run from the project root. This parses all source files, builds the knowledge gr | -------------- | ---------------------------------------------------------------- | | `--force` | Force full re-index even if up to date | | `--embeddings` | Enable embedding generation for semantic search (off by default) | +| `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. | -**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook runs `analyze` automatically after `git commit` and `git merge`, preserving embeddings if previously generated. +**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook detects staleness after `git commit` and `git merge` and notifies the agent to run `analyze` — the hook does not run analyze itself, to avoid blocking the agent for up to 120s and risking KuzuDB corruption on timeout. ### status — Check index freshness diff --git a/.claude/skills/timer/SKILL.md b/.claude/skills/timer/SKILL.md deleted file mode 100644 index 03f8e29..0000000 --- a/.claude/skills/timer/SKILL.md +++ /dev/null @@ -1,349 +0,0 @@ -# Skill — Timer Engine (useTimerEngine) -> Lis ce skill AVANT d'implémenter quoi que ce soit lié au timer. - -## Responsabilité de ce module -Le timer est le cœur battant de l'app. Il doit être : -- **Précis** : drift < 50ms sur une séance complète (8 rounds) -- **Résilient** : continue en background, survit aux appels téléphoniques -- **Découplé** : aucune dépendance vers l'audio ou les exercices -- **Testable** : logique pure, facile à tester sans simulateur - ---- - -## Architecture du module timer - -``` -src/features/timer/ - types.ts ← Commencer ICI - hooks/ - useTimerEngine.ts ← Moteur central (logique pure) - useTimerSync.ts ← Pont vers audio + exercices - useTimerPersistence.ts ← Sauvegarde config dans AsyncStorage - components/ - TimerDisplay.tsx ← Écran plein écran (props-only) - TimerControls.tsx ← Boutons start/pause/stop/skip - TimerPhaseIndicator.tsx ← Barre de progression des rounds - TimerRing.tsx ← Anneau circulaire animé - index.ts ← Barrel export -``` - ---- - -## Types — commencer par ici - -```typescript -// src/features/timer/types.ts - -export type TimerPhase = 'IDLE' | 'GET_READY' | 'WORK' | 'REST' | 'COMPLETE' - -export interface TimerConfig { - workDuration: number // défaut : 20s - restDuration: number // défaut : 10s - rounds: number // défaut : 8 - getReadyDuration: number // défaut : 10s - cycles: number // défaut : 1 (Premium : jusqu'à 10) - cyclePauseDuration: number // défaut : 60s (pause entre cycles, Premium) -} - -export interface TimerState { - phase: TimerPhase - secondsLeft: number - currentRound: number // 1-indexé - totalRounds: number - currentCycle: number // 1-indexé - totalCycles: number - isRunning: boolean - isPaused: boolean - totalElapsedSeconds: number // pour le résumé de séance -} - -export interface TimerActions { - start: (config: TimerConfig) => void - pause: () => void - resume: () => void - stop: () => void - skip: () => void // passer à la phase suivante immédiatement -} - -// Événements émis par le timer — consommés par useTimerSync -export type TimerEvent = - | { type: 'PHASE_CHANGED'; from: TimerPhase; to: TimerPhase } - | { type: 'ROUND_COMPLETED'; round: number } - | { type: 'SESSION_COMPLETE'; totalSeconds: number } - | { type: 'COUNTDOWN_TICK'; secondsLeft: number } // pour les dernières 3s - -export type TimerEventListener = (event: TimerEvent) => void -``` - ---- - -## Implémentation — useTimerEngine - -### Principe de précision : Date.now() delta - -**JAMAIS** se fier uniquement à setInterval pour le compte à rebours. -setInterval drift de 10-100ms par tick sur mobile. Sur 8 rounds -de 20s + 10s, ça peut représenter plusieurs secondes d'écart. - -```typescript -// ✅ Approche correcte — delta sur Date.now() -const tickRef = useRef(null) -const targetEndTimeRef = useRef(0) - -function tick() { - const now = Date.now() - const remaining = Math.max(0, targetEndTimeRef.current - now) - const secondsLeft = Math.ceil(remaining / 1000) - - setSecondsLeft(secondsLeft) - - if (remaining <= 0) { - advancePhase() - } else { - // Planifier le prochain tick dans ~100ms (pas 1000ms) - // pour une réactivité maximale sur les transitions - tickRef.current = setTimeout(tick, 100) - } -} - -function startPhase(duration: number) { - targetEndTimeRef.current = Date.now() + duration * 1000 - tickRef.current = setTimeout(tick, 100) -} -``` - -### Gestion du background (AppState) - -```typescript -useEffect(() => { - const subscription = AppState.addEventListener('change', (nextState) => { - if (nextState === 'background' || nextState === 'inactive') { - // Sauvegarder le timestamp cible — pas l'état courant - // Quand on revient en foreground, on recalcule - saveBackgroundTimestamp(targetEndTimeRef.current) - } - - if (nextState === 'active') { - const savedTarget = loadBackgroundTimestamp() - if (savedTarget && isRunningRef.current) { - // Recalculer le temps restant depuis le timestamp sauvegardé - targetEndTimeRef.current = savedTarget - // Si le temps est dépassé, avancer aux phases manquées - reconcilePhaseAfterBackground(savedTarget, Date.now()) - } - } - }) - - return () => subscription.remove() -}, []) -``` - -### Notification sticky pendant la séance - -Utiliser expo-notifications pour afficher le décompte en background : - -```typescript -async function updateBackgroundNotification(phase: TimerPhase, secondsLeft: number) { - await Notifications.scheduleNotificationAsync({ - identifier: 'tabata-timer-sticky', - content: { - title: phase === 'WORK' ? '🔥 Travail !' : '💨 Repos', - body: `${secondsLeft}s — Round ${currentRound}/${totalRounds}`, - sticky: true, - autoDismiss: false, - }, - trigger: null, - }) -} -``` - -### Ordre des transitions de phase - -``` -IDLE - ↓ start() -GET_READY (getReadyDuration) - ↓ temps écoulé -WORK (workDuration) - ↓ temps écoulé -REST (restDuration) - ↓ temps écoulé - → si currentRound < totalRounds : retour à WORK - → si currentRound === totalRounds ET currentCycle < totalCycles : - PAUSE_BETWEEN_CYCLES → WORK (round 1 du cycle suivant) - → si currentRound === totalRounds ET currentCycle === totalCycles : -COMPLETE - ↓ stop() ou auto-reset après 3s -IDLE -``` - -### Événements à émettre aux abonnés - -```typescript -// À chaque changement de phase -emitEvent({ type: 'PHASE_CHANGED', from: prevPhase, to: nextPhase }) - -// À chaque fin de round -emitEvent({ type: 'ROUND_COMPLETED', round: currentRound }) - -// Décompte final (3, 2, 1) -if (secondsLeft <= 3) { - emitEvent({ type: 'COUNTDOWN_TICK', secondsLeft }) -} - -// Fin de séance -emitEvent({ type: 'SESSION_COMPLETE', totalSeconds: totalElapsedSeconds }) -``` - ---- - -## Interface publique du hook - -```typescript -export function useTimerEngine(): TimerState & TimerActions & { - addEventListener: (listener: TimerEventListener) => () => void - config: TimerConfig -} -``` - -Le retour doit permettre : -```typescript -const { - phase, secondsLeft, currentRound, totalRounds, isRunning, isPaused, - start, pause, resume, stop, skip, - addEventListener, - config, -} = useTimerEngine() -``` - ---- - -## Composant TimerDisplay — layout plein écran - -``` -┌─────────────────────────────┐ ← StatusBar cachée -│ [Exercice] Round 3/8 ●○○ │ ← Zone haute (20%) — fond semi-transparent -├─────────────────────────────┤ -│ │ -│ │ -│ ┌───────┐ │ -│ │ :17 │ │ ← Anneau + chiffre (50% de l'écran) -│ └───────┘ │ ← Police monospace, taille ~120px -│ │ -│ ████████████░░░░░░░░░░░░░ │ ← Barre de progression totale (15%) -│ │ -│ ⏸ ■ ⏭ │ ← Boutons discrets (15%) — Pause/Stop/Skip -└─────────────────────────────┘ -``` - -Props obligatoires du composant : -```typescript -interface TimerDisplayProps { - state: TimerState - exerciseName: string - nextExerciseName: string - onPause: () => void - onStop: () => void - onSkip: () => void -} -``` - -### Animations requises -- **Fond** : transition de couleur animée entre phases (600ms ease-in-out) - - `Animated.timing` sur la backgroundColor avec interpolation de couleurs -- **Chiffre** : légère pulsation à chaque seconde (`Animated.sequence` scale 1→1.05→1) -- **Anneau** : `Animated.Value` sur stroke-dashoffset (SVG) ou rotation -- **Décompte final** : flash rouge sur les 3 dernières secondes - -```typescript -// Interpolation de couleur entre phases -const backgroundAnim = useRef(new Animated.Value(0)).current - -const backgroundColor = backgroundAnim.interpolate({ - inputRange: [0, 1, 2, 3], - outputRange: [ - PHASE_COLORS.IDLE, - PHASE_COLORS.GET_READY, - PHASE_COLORS.WORK, - PHASE_COLORS.REST, - ], -}) - -// Déclencher quand la phase change -useEffect(() => { - const phaseIndex = { IDLE: 0, GET_READY: 1, WORK: 2, REST: 3, COMPLETE: 0 } - Animated.timing(backgroundAnim, { - toValue: phaseIndex[phase], - duration: 600, - useNativeDriver: false, // obligatoire pour backgroundColor - }).start() -}, [phase]) -``` - ---- - -## expo-keep-awake — écran allumé pendant la séance - -```typescript -import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake' - -// Dans useTimerEngine -useEffect(() => { - if (isRunning) { - activateKeepAwakeAsync('tabata-session') - } else { - deactivateKeepAwake('tabata-session') - } -}, [isRunning]) -``` - ---- - -## Tests minimaux à écrire - -```typescript -describe('useTimerEngine', () => { - it('démarre en phase IDLE avec la config par défaut', () => {}) - it('passe à GET_READY quand start() est appelé', () => {}) - it('passe à WORK après la fin de GET_READY', () => {}) - it('passe à REST après la fin de WORK', () => {}) - it('incrémente currentRound après chaque REST', () => {}) - it('passe à COMPLETE après le dernier round', () => {}) - it('retourne à IDLE après stop()', () => {}) - it('skip() avance immédiatement à la phase suivante', () => {}) - it('émet PHASE_CHANGED à chaque transition', () => {}) - it('émet COUNTDOWN_TICK pour 3, 2, 1', () => {}) - it('cleanup useEffect enlève tous les listeners', () => {}) -}) -``` - ---- - -## Erreurs classiques à éviter - -```typescript -// ❌ Drift — setInterval seul -setInterval(() => setSecondsLeft(prev => prev - 1), 1000) - -// ✅ Delta sur Date.now() -const remaining = Math.max(0, targetEndTimeRef.current - Date.now()) -setSecondsLeft(Math.ceil(remaining / 1000)) - -// ❌ State React pour les valeurs temps-critique -const [targetEndTime, setTargetEndTime] = useState(0) - -// ✅ Ref pour les valeurs utilisées dans les closures de timeout -const targetEndTimeRef = useRef(0) - -// ❌ Pas de cleanup — memory leak -useEffect(() => { - setTimeout(tick, 100) -}) - -// ✅ Cleanup systématique -useEffect(() => { - return () => { - if (tickRef.current) clearTimeout(tickRef.current) - } -}, []) -``` diff --git a/.claude/skills/workflow/SKILL.md b/.claude/skills/workflow/SKILL.md deleted file mode 100644 index 6b78621..0000000 --- a/.claude/skills/workflow/SKILL.md +++ /dev/null @@ -1,233 +0,0 @@ -# Skill — Workflow de Développement TabataGo -> Lis ce skill en début de session ou quand tu commences une nouvelle feature. - -## Principe fondamental -**Plan d'abord, code ensuite.** Pour toute feature non triviale : -1. Analyse les specs dans le PRD (`docs/PRD.md`) -2. Liste les edge cases et les dépendances -3. Propose l'architecture (types, hooks, composants) -4. Attends validation avant de générer du code - -Ne jamais écrire de code avant d'avoir une architecture approuvée -sur les features cœur (timer, audio, exercices). - ---- - -## Cycle de développement par feature - -### Étape 1 — Lire les specs -Avant toute implémentation, lis la section PRD correspondante. -Identifie exactement : -- Les entrées/sorties du système -- Les états possibles -- Les cas limites (interruptions, background, erreurs) - -### Étape 2 — Typer en premier -Commence TOUJOURS par le fichier `types.ts` de la feature. -Les types sont le contrat — ils précèdent l'implémentation. - -```typescript -// Exemple pour le timer — types.ts -export type TimerPhase = 'IDLE' | 'GET_READY' | 'WORK' | 'REST' | 'COMPLETE' - -export interface TimerState { - phase: TimerPhase - secondsLeft: number - currentRound: number - totalRounds: number - isRunning: boolean -} - -export interface TimerConfig { - workDuration: number // secondes, défaut 20 - restDuration: number // secondes, défaut 10 - rounds: number // défaut 8 - getReadyDuration: number // secondes, défaut 10 -} - -export interface TimerActions { - start: () => void - pause: () => void - resume: () => void - stop: () => void - skip: () => void -} -``` - -**Ne passe à l'étape 3 que si les types semblent corrects.** - -### Étape 3 — Implémenter le hook (logique métier) -Implémenter le hook central de la feature. -Règles : -- Aucun import depuis React Native UI dans les hooks métier -- Chaque hook expose un état + des actions (pattern Zustand-like) -- Les side effects sont dans useEffect avec cleanup systématique -- Logger les transitions d'état en dev (`__DEV__ && console.log(...)`) - -### Étape 4 — Implémenter les composants -Les composants reçoivent tout par props — ils ne fetchent rien, -ne font aucune logique métier. - -```typescript -// ✅ Correct -function TimerDisplay({ phase, secondsLeft, currentRound }: TimerDisplayProps) { - return {secondsLeft} -} - -// ❌ Interdit -function TimerDisplay() { - const timer = useTimerEngine() // logique dans le composant - return {timer.secondsLeft} -} -``` - -### Étape 5 — Tester avant de continuer -Avant de passer à la feature suivante, écrire les tests minimaux : -- État initial correct -- Transitions de phase correctes -- Cleanup des side effects (pas de memory leak) - ---- - -## Patterns obligatoires - -### Gestion d'état avec state machine explicite -Toujours utiliser une state machine pour les flows complexes. -Jamais de booléens en cascade (`isRunning && !isPaused && !isComplete`). - -```typescript -// ✅ State machine explicite -type TimerPhase = 'IDLE' | 'GET_READY' | 'WORK' | 'REST' | 'COMPLETE' - -const VALID_TRANSITIONS: Record = { - IDLE: ['GET_READY'], - GET_READY: ['WORK', 'IDLE'], - WORK: ['REST', 'IDLE'], - REST: ['WORK', 'COMPLETE', 'IDLE'], - COMPLETE: ['IDLE'], -} - -function canTransition(from: TimerPhase, to: TimerPhase): boolean { - return VALID_TRANSITIONS[from].includes(to) -} -``` - -### Cleanup systématique des useEffect -```typescript -useEffect(() => { - const subscription = AppState.addEventListener('change', handleAppState) - return () => subscription.remove() // TOUJOURS un cleanup -}, []) -``` - -### AsyncStorage — wrapper typé uniquement -Ne jamais appeler AsyncStorage directement dans les composants. -Passer par un hook ou un service : - -```typescript -// shared/utils/storage.ts -export async function saveTimerConfig(config: TimerConfig): Promise { - await AsyncStorage.setItem('timer_config', JSON.stringify(config)) -} - -export async function loadTimerConfig(): Promise { - const raw = await AsyncStorage.getItem('timer_config') - return raw ? JSON.parse(raw) : null -} -``` - -### Constantes centralisées -```typescript -// shared/constants/timer.ts -export const TIMER_DEFAULTS = { - WORK_DURATION: 20, - REST_DURATION: 10, - ROUNDS: 8, - GET_READY_DURATION: 10, -} as const - -// shared/constants/colors.ts -export const PHASE_COLORS = { - GET_READY: '#EAB308', // Jaune - WORK: '#F97316', // Orange - REST: '#3B82F6', // Bleu - COMPLETE: '#22C55E', // Vert - IDLE: '#1E1E2E', // Dark -} as const -``` - ---- - -## Gestion des erreurs - -### Pattern try/catch dans les hooks async -```typescript -const [error, setError] = useState(null) -const [isLoading, setIsLoading] = useState(false) - -async function loadData() { - try { - setIsLoading(true) - setError(null) - const data = await fetchSomething() - setData(data) - } catch (e) { - setError(e instanceof Error ? e.message : 'Erreur inconnue') - } finally { - setIsLoading(false) - } -} -``` - -### Fallback dans les composants -Tout composant qui charge des ressources (GIF, audio) doit avoir -un état de fallback visible et non-bloquant. - ---- - -## Checklist avant de soumettre du code - -Avant de considérer une feature terminée, vérifier : - -- [ ] Les types sont définis dans `types.ts` -- [ ] La logique est dans des hooks, pas dans des composants -- [ ] Tous les `useEffect` ont un cleanup -- [ ] Aucun `console.log` en dehors de `if (__DEV__)` -- [ ] Les constantes sont dans `shared/constants/` -- [ ] Les fonctions async ont un try/catch -- [ ] Un test minimal existe pour les cas principaux -- [ ] Le barrel export `index.ts` est mis à jour - ---- - -## Workflow de debugging avec les logs Expo - -Pour surveiller les logs en temps réel et donner du contexte à Claude : - -```bash -# Terminal 1 — démarrer expo avec logs dans un fichier -npx expo start 2>&1 | tee .expo-logs/dev.log - -# Ensuite dans Claude Code -> Surveille .expo-logs/dev.log et identifie les warnings - liés au module [nom du module en cours] -``` - -Ajouter des logs structurés dans les hooks critiques : -```typescript -if (__DEV__) { - console.log('[TimerEngine]', { phase, secondsLeft, currentRound }) -} -``` - ---- - -## Commandes Claude Code utiles - -| Situation | Commande | -|---|---| -| Début de projet / onboarding | `/init` | -| Session longue (>45 min) | `/compact` — focus sur la feature en cours | -| Avant chaque commit | `/review` | -| Quand Claude part en vrille | Donner un fichier précis : `@src/features/timer/hooks/useTimerEngine.ts` | -| Architecture à valider | Demander explicitement "ne génère pas de code, propose seulement l'architecture" | diff --git a/AGENTS.md b/AGENTS.md index c829c72..580d37f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -449,7 +449,7 @@ Search results can flood context. Use `context-mode_ctx_execute(language: "shell # GitNexus — Code Intelligence -This project is indexed by GitNexus as **tabatago** (1839 symbols, 3401 relationships, 52 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **tabatago** (3362 symbols, 9407 relationships, 129 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. @@ -461,19 +461,6 @@ This project is indexed by GitNexus as **tabatago** (1839 symbols, 3401 relation - When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. - When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. -## When Debugging - -1. `gitnexus_query({query: ""})` — find execution flows related to the issue -2. `gitnexus_context({name: ""})` — see all callers, callees, and process participation -3. `READ gitnexus://repo/tabatago/process/{processName}` — trace the full execution flow step by step -4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed - -## When Refactoring - -- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`. -- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code. -- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed. - ## Never Do - NEVER edit a function, class, or method without first running `gitnexus_impact` on it. @@ -481,25 +468,6 @@ This project is indexed by GitNexus as **tabatago** (1839 symbols, 3401 relation - NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. - NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. -## Tools Quick Reference - -| Tool | When to use | Command | -|------|-------------|---------| -| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` | -| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` | -| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` | -| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` | -| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` | -| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` | - -## Impact Risk Levels - -| Depth | Meaning | Action | -|-------|---------|--------| -| d=1 | WILL BREAK — direct callers/importers | MUST update these | -| d=2 | LIKELY AFFECTED — indirect deps | Should test | -| d=3 | MAY NEED TESTING — transitive | Test if critical path | - ## Resources | Resource | Use for | @@ -509,32 +477,6 @@ This project is indexed by GitNexus as **tabatago** (1839 symbols, 3401 relation | `gitnexus://repo/tabatago/processes` | All execution flows | | `gitnexus://repo/tabatago/process/{name}` | Step-by-step execution trace | -## Self-Check Before Finishing - -Before completing any code modification task, verify: -1. `gitnexus_impact` was run for all modified symbols -2. No HIGH/CRITICAL risk warnings were ignored -3. `gitnexus_detect_changes()` confirms changes match expected scope -4. All d=1 (WILL BREAK) dependents were updated - -## Keeping the Index Fresh - -After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it: - -```bash -npx gitnexus analyze -``` - -If the index previously included embeddings, preserve them by adding `--embeddings`: - -```bash -npx gitnexus analyze --embeddings -``` - -To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.** - -> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`. - ## CLI | Task | Read this skill file | diff --git a/CLAUDE.md b/CLAUDE.md index ef0cd03..a34c7d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -217,7 +217,7 @@ Voir `.claude/skills/` pour les guides spécialisés. # GitNexus — Code Intelligence -This project is indexed by GitNexus as **tabatago** (1839 symbols, 3401 relationships, 52 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **tabatago** (3362 symbols, 9407 relationships, 129 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. @@ -229,19 +229,6 @@ This project is indexed by GitNexus as **tabatago** (1839 symbols, 3401 relation - When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. - When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. -## When Debugging - -1. `gitnexus_query({query: ""})` — find execution flows related to the issue -2. `gitnexus_context({name: ""})` — see all callers, callees, and process participation -3. `READ gitnexus://repo/tabatago/process/{processName}` — trace the full execution flow step by step -4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed - -## When Refactoring - -- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`. -- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code. -- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed. - ## Never Do - NEVER edit a function, class, or method without first running `gitnexus_impact` on it. @@ -249,25 +236,6 @@ This project is indexed by GitNexus as **tabatago** (1839 symbols, 3401 relation - NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. - NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. -## Tools Quick Reference - -| Tool | When to use | Command | -|------|-------------|---------| -| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` | -| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` | -| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` | -| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` | -| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` | -| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` | - -## Impact Risk Levels - -| Depth | Meaning | Action | -|-------|---------|--------| -| d=1 | WILL BREAK — direct callers/importers | MUST update these | -| d=2 | LIKELY AFFECTED — indirect deps | Should test | -| d=3 | MAY NEED TESTING — transitive | Test if critical path | - ## Resources | Resource | Use for | @@ -277,32 +245,6 @@ This project is indexed by GitNexus as **tabatago** (1839 symbols, 3401 relation | `gitnexus://repo/tabatago/processes` | All execution flows | | `gitnexus://repo/tabatago/process/{name}` | Step-by-step execution trace | -## Self-Check Before Finishing - -Before completing any code modification task, verify: -1. `gitnexus_impact` was run for all modified symbols -2. No HIGH/CRITICAL risk warnings were ignored -3. `gitnexus_detect_changes()` confirms changes match expected scope -4. All d=1 (WILL BREAK) dependents were updated - -## Keeping the Index Fresh - -After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it: - -```bash -npx gitnexus analyze -``` - -If the index previously included embeddings, preserve them by adding `--embeddings`: - -```bash -npx gitnexus analyze --embeddings -``` - -To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.** - -> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`. - ## CLI | Task | Read this skill file |