Documento de contexto enraizado en el código. Úsalo antes de tocar cualquier archivo para no romper convenciones. Si una regla entra en conflicto con lo que ves, gana este archivo.
Eres el agente IA trabajando en este repo. Tu bloc de notas personal vive en ./.agents/ — está gitignored (# Agents internal logs) pero es convención del proyecto. Cinco ficheros con propósito distinto:
Antes de modificar código, vuelca aquí lo que descubras. Lo durable promueve a
AGENTS.mdoarquitectura.md. El resto se queda enmemoria.mdoprogreso.md.
| Fichero | Para qué |
|---|---|
arquitectura.md |
Espejo técnico quick-ref. Constantes clave (versiones, sentinels, contratos audio). Reflejar AGENTS.md §4 sin duplicarlo. |
especificacion.md |
Espejo de producto. Features, variables AppConfig, out-of-scope, known issues. |
especificaciones.md |
Índice de docs tipo spec, fuente-de-verdad-por-pregunta, contratos cross-doc. |
memoria.md |
Bitácora de descubrimientos no triviales por sesión (bugs, patrones, decisiones, anti-patrones). Append-only. |
progreso.md |
Tareas: en curso / completado / pendiente. Valiosa entre sesiones. |
Reglas entre sesiones:
- Antes de escribir, mira si ya hay una entrada previa.
- Append-only. No reescribas la historia.
- Durable → promueve a
AGENTS.mdoarquitectura.md. Efímero → solomemoria.mdoprogreso.md. - Acepta que
.agents/es RAM de sesión (git clean -fdXlo borra). Lo importante vive en ficheros versionados.
Ahora sigue con §1 para entender el proyecto.
Bubble Breathing es una PWA 100% client-side y offline-first que guía al usuario en ciclos de respiración tipo Wim Hof (65 % inhalación / 35 % exhalación) y entrenamiento de apnea (retención + recuperación guiada).
Arquitectura básica:
- Máquina de estados estricta de 6 fases controlada en
SessionContext:idle→breathing→retention→recovery→finished/stats. - App.tsx mapea 1 componente a 1 fase (
{phase === 'breathing' && <ExerciseScreen />}). - Estado global vía React Context API (3 providers apilados). Sin Redux/Zustand.
- Timer "headless" desacoplado de la UI:
useBreathingTimercorre dentro deMainAppy muta el contexto; los componentes sólo leen estado y aplican CSS transforms inline. - Persistencia en
localStoragecon dos claves:bubbleBreathingConfig(settings) ybubbleBreathingHistory(sesiones + streaks). - Despliegue a GitHub Pages en subpath
/Bubble-Breathing/(configurado envite.config.ts → base). - i18n custom vía
window.translations(NO usai18next). 7 idiomas: en, es, fr, it, de, pt, zh. - Audio y vibración nativos (Web Audio API + Vibration API), sin librerías externas.
| Capa | Tecnología | Versión | Notas |
|---|---|---|---|
| Runtime | React + ReactDOM | ^19.2.7 |
<StrictMode> activo en main.tsx |
| Lenguaje | TypeScript | ~6.0.2 |
verbatimModuleSyntax, erasableSyntaxOnly, noUnusedParameters, noFallthroughCasesInSwitch; noUnusedLocals está false en tsconfig.app.json (intencional) y true en node.json |
| Bundler / dev | Vite | ^5.4.21 |
base: '/Bubble-Breathing/' — NO CAMBIAR |
| PWA | vite-plugin-pwa |
^1.3.0 |
registerType: 'autoUpdate', workbox.mode: 'development' |
| Iconos UI | lucide-react |
^1.24.0 |
única dependencia de UI |
| Audio UI | Web Audio API nativa | — | useBreathingTimer sintetiza tonos con un singleton AudioContext (lazy init) |
| Vibración | Vibration API nativa | — | navigator.vibrate(pattern); el sistema operativo filtra por modo de dispositivo (esto es deliberado, ver §5) |
| Linter | oxlint (Rust) |
^1.71.0 |
// eslint-disable no funcionan |
| Tests | ninguno | — | |
| Audio ambiente | MP3 en /public/assets/ |
— | WAV originales se generan con scripts/generate_sounds.py (Python) |
| CI/CD | GitHub Actions | — | npm ci && npm run build → Pages (ubuntu-latest, Node 20) |
Lockfiles presentes: package-lock.json, pnpm-lock.yaml, bun.lock. El repo soporta los tres package managers, pero el CI usa npm (npm ci). Para evitar drift, usa npm en CI/CD-consistent runs y pnpm/bun localmente sólo si lo prefieres.
# Instalar dependencias
npm install # alineado con CI
# o: pnpm install / bun install
# Desarrollo (Vite dev server con HMR + SW en dev)
npm run dev
# Build de producción — typecheck estricto + Vite build
npm run build # equivalente a: tsc -b && vite build
# Preview del build local
npm run preview
# Lint (oxlint, NO ESLint)
npm run lint # ejecuta: oxlint
# Typecheck rápido sin build
npx tsc -b
# Regenerar sonidos ambiente (opcional, Python puro)
python scripts/generate_sounds.pynpm test, npm run format, npm run typecheck suelto. Si los necesitas, instala primero (ver §5 "Cómo añadir tests").
src/
├── App.tsx ← Shell: providers apilados + ruteo por fase
├── App.css ← ⚠️ LEGACY (plantilla inicial Vite, casi sin uso)
├── main.tsx ← Bootstrap + registro PWA + i18n side-effect
├── style.css ← ⭐ HOJA DE ESTILOS CANÓNICA (única que el bundle importa)
├── translations.js ← Diccionario i18n (side-effect: window.translations)
│
├── contexts/ ← Estado global
│ ├── SettingsContext.tsx # Config persistente (localStorage) + data-theme
│ ├── SessionContext.tsx # Máquina de fases + timer state
│ └── HistoryContext.tsx # Sesiones + streaks (current/longest)
│
├── hooks/ ← Lógica reutilizable / headless
│ ├── useBreathingTimer.ts # ⭐ Timer + playTone + vibrate (singleton)
│ └── useTranslation.ts # Hook de i18n (lee window.translations)
│
├── components/ ← Una pantalla por fase
│ ├── Header.tsx # Idioma + tema + progreso + finish
│ ├── ConfigScreen.tsx # phase=idle — sliders + preview en vivo
│ ├── ExerciseScreen.tsx # phase=breathing — hexágono animado
│ ├── RetentionScreen.tsx # phase=retention — timer apnea + tap
│ ├── RecoveryScreen.tsx # phase=recovery — inhale/hold/exhale
│ ├── ResultsScreen.tsx # phase=finished — resumen + addSession
│ ├── StatsScreen.tsx # phase=stats — dashboard histórico
│ └── SoundscapeManager.tsx # Sin UI; maneja el Audio de ambiente
│
└── utils/
└── timeFormat.ts # formatTime(seconds): "Xs" o "Xs (Mm Ss)"
css/ ← ⚠️ COPIA MANUAL desincronizada de src/style.css
public/assets/ ← Iconos SVG, sonidos MP3 (rain/whitenoise/ocean)
scripts/generate_sounds.py ← Generador Python de WAV fuentes
.github/workflows/deploy.yml← Deploy a GitHub Pages
- Componentes:
PascalCase+ named export (export const Header = () => …). Nuncaexport default. - Hooks:
camelCasecon prefijouse. - Contextos: sufijo
<Name>Context(valor) +<Name>Provider(componente). - Tipos / interfaces:
PascalCase, exportados con el nombre del concepto (AppConfig,SessionHistory,RoundResult). - Enums de fase: literales lower-case (
'breathing','retention'). - CSS classes:
kebab-case(.speed-btn,.stats-card,.hexagon-container). - localStorage keys:
camelCaseplano (bubbleBreathingConfig,bubbleBreathingHistory).
| Context | Valores |
|---|---|
BreathSubPhase |
'inhale' · 'exhale' · 'idle' ← IMPERATIVO |
RecoverySubPhase |
'inhaling' · 'holding' · 'exhaling' · 'idle' ← GERUNDIO |
Antes de usar cualquier valor de subfase, lee su definition en src/contexts/SessionContext.tsx. No asumas formas verbales equivalentes.
console.warn→ cosas esperadas (audio no disponible, vibración bloqueada por modo silencio del OS, autoplay bloqueado, parse de localStorage fallido).console.error→ errores reales (registro de Service Worker fallido).- Los
try/catchusan degradación elegante, no throw al usuario:playTonesilencia conconsole.warnsi el AudioContext falla.vibrateignora devices sin la API sin warning.- Carga de localStorage: si JSON.parse falla, vuelve a defaults con
console.error.
- No hay librería de logging (Sentry, Winston, etc.) — es
console.*directo.
src/translations.jsmutawindow.translations[lang][key]por side-effect.src/App.tsxlo carga por side-effect:import './translations.js'.useTranslation.tsleewindow.translations[config.language]con fallback a inglés si falta clave/idioma.- API:
t(key)→ string del diccionario, o el key crudo si no existe.t(key, { placeholder })→ sustituye{placeholder}dentro del string (el string del diccionario DEBE contener{placeholder}literal).t(key, { defaultValue: 'X' })→⚠️ NO es un fallback real. El hook no lo trata especialmente: si la key existe, intenta sustituir{defaultValue}literal (que no estará en el string, así que es inerte); si la key NO existe, devuelve la key cruda y eldefaultValuese ignora. Muchos componentes del codebase creen que es un fallback y muestran'soundscapeLabel'cuando se elimina esa clave del diccionario.
⚠️ Regla dura: añade TODAS las keys asrc/translations.jsen los 7 idiomas. No confíes en{ defaultValue: ... }como red de seguridad: no funciona.⚠️ NO hay type-safety: cualquiert('typoXyz')compila y devuelve la string cruda'typoXyz'. El compilador no detecta typos i18n.⚠️ Fragilidad de side-effect:src/translations.jsse importa desdeApp.tsxporimport './translations.js'únicamente por su side-effect. NO intentes "modernizarlo" como módulo ES conexport—useTranslation.tsleewindow.translationsy un módulo ES refactorizado se ejecutaría perowindow.translationsquedaríaundefined.- Al añadir claves nuevas, tradúcelas a los 7 idiomas. Si añades un idioma, sigue el checklist del comentario al final de
src/translations.js.
- Tema vía atributo
html[data-theme="dark"|"light"]. El providerSettingsContextlo aplica condocument.documentElement.setAttribute('data-theme', config.theme)cuando cambiaconfig.theme. - Glassmorphism con variables CSS
--color-*(definidas en:rootyhtml[data-theme="light"]). No hardcoded colors. - Tamaños fluidos:
clamp(),dvh,vmin,env(safe-area-inset-*). - Preferencias de usuario respetadas:
overflow: hidden,-webkit-tap-highlight-color: transparent. ⚠️ css/style.cssestá duplicado a mano y se desincroniza con frecuencia. El bundle solo importasrc/style.css(víamain.tsx). SOLO editasrc/style.css. Si necesitas sincronizar la copia legacy, hazlo manualmente y menciónalo en el commit.
- Sin librerías (no
framer-motion, noreact-spring). - Cada componente de fase construye un objeto inline
{ transform: 'scale(...)', transition: 'transform Xms ease' }que React aplica al hexágono. - Patrón crítico:
requestAnimationFrame(() => requestAnimationFrame(startInhale))se usa una vez al iniciar la fase breathing para garantizar la transición CSS desde 1.0 → 1.3. Si lo quitas, la primera inhalación "salta" sin animar. useBreathingTimerusasetTimeoutrecursivo (nosetInterval) para fases; ordénalo porphaseTimerRef.current.
useBreathingTimer.ts exporta dos funciones:
playTone(frequency: number, duration: number, volume: number): void
vibrate(pattern: number | number[]): voidReglas de uso (NO negociables):
playTone(freq, ms, vol)respetavol === 0para mudo real (return temprano).vibrate(pattern)NO se gates con la configuración de volumen de la app. Es deliberado: Android filtra por modo de dispositivo (silencio = bloqueado por el OS; vibración/sonido = pasa). Si lo gateas conconfig.volume, rompes la semántica intencional del proyecto (verRetentionScreen— per-minute cue; yuseBreathingTimer— last-breath).- Tonos distinguibles (ver
PROJECT.md):220 Hz / 200ms— respiración normal180 Hz / 350ms— per-minute cue durante apnea (volumen suave 0.75x)150 Hz / 600ms— última inhalación (× 2.5 volumen, cap a 1.0)120 Hz / 800ms— última exhalación (× 2.5 volumen, cap a 1.0)
AudioContextes singleton, lazy:audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)()solo en la primera llamada. Trata comolet audioCtx: AudioContext | null = null(fuera del hook, por eso no se reinicia entre fases).
- Los 3 providers están apilados en
App.tsxen este orden:SettingsProvider └─ SessionProvider └─ HistoryProvider └─ MainApp (usa useBreathingTimer + lee phase para rutear) useBreathingTimer()se monta dentro de MainApp (debajo de los providers) para que el timer corra incluso cuando los componentes UI se desmontan/montan entre fases.- Cada componente verifica
if (phase !== '<expectedPhase>') return null;al inicio para no renderizar cuando la fase no coincide (defensivo contra renders previos en cambios de fase). - En
Header.tsx, el cálculo del progreso está hardcoded para 11 rounds (if (config.rounds !== 11) { progressWidth = ... }). 11 es el valor centinela para "∞" (modo infinito). Si lo refactorizas, respeta este sentinel. - En Round counts, cualquier UI que muestre rondas debe respetar el sentinel:
config.rounds === 11 ? '∞' : config.rounds. - Doble naturaleza del
11:11es a la vez (a) elmaxdel slider de rounds enConfigScreen.tsx(min="1" max="11"), y (b) el sentinel de "modo ∞" enHeader.progressWidth,ConfigScreen.getEstimatedTime(),ExerciseScreenyRetentionScreen. Si refactorizas el sentinel, mantén los chequeos=== 11(o muévelos a un constanteINFINITE_ROUNDS = 11, declarándola enSessionContextoSettingsContext). - Progress bar se oculta en modo ∞:
Header.tsxenvuelve toda la lógica de cálculo enif (config.rounds !== 11) { progressWidth = ... }. En modo infinito la barra queda en 0 % — es decisión UX, no bug. Si lo cambias, respeta el check.
-
NO añadir dependencias nuevas para features ya implementadas nativamente:
- i18n → no
i18next,react-intl,formatjs. UsauseTranslation. - Animación → no
framer-motion,react-spring,gsap. Usa CSS transitions ligadas a state. - Audio UI → no
Howler.js,tone.js. UsaplayToneo sonidos ambiente MP3. - Estado global → no Redux, Zustand, Jotai. Context API.
- Routing → no React Router. La navegación es por cambio de
phase.
- i18n → no
-
NO editar
css/style.css— está duplicado y desincronizado. Edita SOLOsrc/style.css. -
NO usar comentarios
// eslint-disable*— el linter esoxlinty no los lee. Si reporta un falso positivo, refactoriza el código. -
NO crear componentes
export default— la convención es named export (export const Foo = …). -
NO asumir nombres de subfase. Lee siempre
BreathSubPhaseyRecoverySubPhaseenSessionContext.tsx.inhale ≠ inhaling. -
NO introducir
anysin justificación. El único existente está en(window as any).webkitAudioContext(Safari legacy) — déjalo o coméntalo. -
NO cambiar la
basede Vite (/Bubble-Breathing/). Romperás GitHub Pages. -
NO ejecutar
npm test/npm run test— no existe. Si necesitas tests, instala Vitest PRIMERO (ver abajo). -
NO añadir nuevos
.cssfiles. Todo va asrc/style.css.App.csses boilerplate legacy del template de Vite y está prácticamente sin uso; NO metas nuevas reglas ahí. -
NO usar
React.memoReact.lazySuspense` sin justificación fuerte — el proyecto no los usa y el bundle es deliberadamente simple. -
NO cambiar
StrictModeenmain.tsxa menos que entiendas las implicaciones (los efectos deuseBreathingTimerya están protegidos constopTimercleanup).
- Ubicación:
src/components/<Nombre>.tsxpara utility components,src/components/<Nombre>Screen.tsxpara pantallas mapeadas a una fase. - Named export, nunca
export default. - Verificación defensiva al inicio:
const { phase } = useSession(); if (phase !== '<expectedPhase>') return null;
- Lectura de estado vía hooks contextuales:
useSession,useSettings,useHistory,useTranslation. - Texto de UI SIEMPRE vía
t('key'). Para placeholders:t('key', { var: value }). Para degradación segura:t('key', { defaultValue: 'X' }). - Si añades keys i18n nuevas, tradúcelas a los 7 idiomas en
src/translations.js(en, es, fr, it, de, pt, zh). Si añades idioma, también actualiza el checklist al pie del archivo + la(LANGUAGES)enHeader.tsx. - Feedback sensorial → usa
playTone(freq, ms, vol)yvibrate(pattern)de'../hooks/useBreathingTimer'. NO importaruseBreathingTimerdirectamente sólo para esto (es headless y se monta una vez enMainApp). - Assets en
/public/: usaimport.meta.env.BASE_URL + 'assets/...'. (BASE_URL es/Bubble-Breathing/en prod,/en dev.) - No inline-style salvo para
transform/transitiondinámico del hexágono. Para todo lo demás, usa clases ensrc/style.css. - Commit messages estilo convencional:
feat: …,fix: …,style: …,chore: …,docs: …(ver historial de git).
- Instala:
npm install --save-dev vitest @testing-library/react @testing-library/jest-dom jsdom. - Añade
"test": "vitest"apackage.jsonscripts. - Si usas jest-dom: crea
src/test/setup.tsy referéncialo envitest.config.ts. - Los hooks que manejan timers (
useBreathingTimer,RetentionScreen,RecoveryScreen) requierenvi.useFakeTimers(). - Para testear componentes que usan contextos, monta con los 3 Providers apilados en un wrapper.
- No mofifiques la lógica de side-effects en
main.tsx(registro SW, listener decontrollerchange) — son difíciles de testear y son correctos según el contrato PWA.
- Edita
src/translations.js: añade un nuevo bloque al objetowindow.translations = { … }. - Edita
src/components/Header.tsx: añade la entrada al arrayLANGUAGES = [...]con{ code, name, flag }. - Verifica que
src/translations.jsysrc/components/Header.tsxsiguen sincronizados (el comment al final detranslations.jslo advierte).
npm run lintdebe pasar (oxlint, sin warnings).npm run builddebe compilar sin errores de TS.- Si modificas estilos, verifica también
css/style.cssy sincronízalo si está muy desincronizado (manual). - Si modificas sonidos WAV fuente (Python), regenera con
python scripts/generate_sounds.pyy commitea los WAV resultantes. - Si modificas la lógica de fases, lee
PROJECT.mdpara entender las features existentes (Last Breath Amplified, Per-Minute Cue, Wim Hof 65/35) y no las rompas silenciosamente.
- CSS duplicado:
src/style.csses la canónica (importada enmain.tsx).css/style.csses legado y se desincroniza con frecuencia. SOLO editasrc/style.css. Si la copiacss/está muy desincronizada y quieres sincronizarla, hazlo manualmente y menciónalo en el commit. Regla completa en §4.5 y §5. window.translationsse carga por side-effect desdeApp.tsx(import './translations.js'). Si lo refactorizas a un módulo ES conexport, perderás la disponibilidad global parauseTranslation.ts.config.rounds === 11es el sentinel de "∞ / modo infinito". Condiciones que aparecen enHeader.tsx,ConfigScreen.getEstimatedTime()(devuelve'∞'),ExerciseScreen,RetentionScreen,RecoveryScreen. Verifica antes de cambiar límites de slider.- Service Worker se actualiza agresivamente (cada 30 min + al volver foco, ver
main.tsx). Si modificas lógica crítica, fuerza una recarga dura o espera 30 min en testing. - El progress bar está hardcoded para 11 rounds en
Header.tsx. Si modificas el cálculo, respetaif (config.rounds !== 11) { … }. - No tests = TS strict + tu única red de seguridad.
verbatimModuleSyntax: trueyerasableSyntaxOnly: truete forzarán a ser explícito con imports y tipos. tsBuildInfoFileconfigura incremental builds; si algo parece "raro", borranode_modules/.tmp/y reintenta.pnpm-workspace.yamlexiste pero el repo no es un workspace real — sólo tiene un flagallowBuilds. No agregues paquetes al "workspace" sin entender para qué sirve.App.cssexiste pero casi no se usa (es boilerplate del template inicial de Vite). Si quieres añadir CSS, ve asrc/style.css.Header.tsxtiene IDs hardcoded (id="langToggle",id="exerciseHexagon", etc.) que sobreviven de la versión vanilla JS. Mantén la compatibilidad si vas a tocar IDs (no los necesitas para nada en React, son reliquia).
- Last breath: la respiración N-ésima (=
config.breaths) inmediatamente antes de la apnea; recibe feedback sensorial amplificado. Condición:breathRef.current === config.breathsenuseBreathingTimer. - Wim Hof ratio: 65 % inhalación, 35 % exhalación (ratio deliberado del proyecto, no bug; un comentarío en el código lo justifica).
- Sentinel
11: doble rol —maxdel slider de rounds y valor que activa el "modo ∞" en cálculos de progreso/tiempo.
Si dudas entre dos aproximaciones, prioriza: simpleza → reutilización (helpers existentes) → consistencia con código cercano → solo entonces innovación. Mantén el bundle pequeño, el código legible y la lógica de fases intacta.