diff --git a/OBSERVATIONS.md b/OBSERVATIONS.md
index 4bd0953..fd3a67b 100644
--- a/OBSERVATIONS.md
+++ b/OBSERVATIONS.md
@@ -18,7 +18,12 @@
## Hallazgos activos
-*(vacío al inicio del sprint)*
+### [RESOLVED] Etapa 1 — Workspace loading fix + carry-over skeleton
+
+**Estado:** OK formal del director — 2026-06-19.
+**Fix principal:** `process-store.ts` y `useReportData.ts` usaban `getSession()` sin timeout; reemplazados por `getSessionSafe()`. Error Boundary + estado `error` inline con retry button agregados.
+**Carry-over a Etapa 2:** `WorkspaceSkeleton.tsx` fue creado pero el spinner del router-level guard (TanStack Router) intercepta antes de que el componente monte. El skeleton no aparece en la práctica. Corregir en Etapa 2 como Fix 0 (bajo esfuerzo).
+**SupabaseAuthService.ts:** su `getSession()` directo documentado como excepción intencional (necesita el objeto sesión real, no es barrera descartable). Sin callers activos. Migrar a `getSessionSafe()` cuando tenga callers — pendiente registrar en TECH_DEBT.
---
diff --git a/sprints/sprint-5/ETAPA_2_PROMPT.md b/sprints/sprint-5/ETAPA_2_PROMPT.md
new file mode 100644
index 0000000..a61eb11
--- /dev/null
+++ b/sprints/sprint-5/ETAPA_2_PROMPT.md
@@ -0,0 +1,156 @@
+# Sprint 5 — Etapa 2: Fix zoom BPMN + skeleton carry-over
+
+## Contexto
+
+InQ ROI — React 18 + Vite + TypeScript + TanStack Router + Zustand + shadcn/ui + Tailwind + Supabase.
+Etapa 1 completada con OK formal. Workspace carga correctamente. Dos ítems pendientes de esta etapa:
+
+1. **Carry-over de Etapa 1:** `WorkspaceSkeleton.tsx` fue creado y está bien implementado, pero no aparece en la práctica — el spinner del router intercepta antes. Corregir la condición de render en `WorkspacePage.tsx`.
+2. **Fix + mejora del canvas BPMN:** botón fit-viewport con bug (clip al borde superior) y ausencia de controles zoom-in/zoom-out.
+
+---
+
+## Fix 0 — Skeleton carry-over (Etapa 1)
+
+### Diagnóstico
+
+`WorkspaceSkeleton.tsx` existe en `src/features/workspace/` y está bien implementado (layout completo con `animate-pulse`). El problema está en `WorkspacePage.tsx`: el skeleton no se renderiza cuando `isLoading === true` en el store, porque TanStack Router muestra su propio indicador de carga antes de que `WorkspacePage` monte y evalúe su estado interno.
+
+### Fix
+
+Abrir `WorkspacePage.tsx` (o `WorkspacePageWithBoundary` si así quedó tras Etapa 1) y localizar la condición de loading. Debe quedar así:
+
+```tsx
+// Al inicio del componente, después de leer el store:
+const { isLoading, error, process } = useProcessStore()
+
+if (isLoading) return
+if (error) return
+if (!process) return // guard adicional para estado transitorio
+```
+
+El objetivo es que el skeleton aparezca mientras `isLoading === true` **desde el componente**, no depender del loader de TanStack Router para mostrar feedback visual al usuario.
+
+Si el router-level loader también muestra algo, no hay conflicto — el router puede tener su indicador mínimo (barra de progreso en el top si la hay) y el componente muestra el skeleton completo una vez montado. No eliminar el router loader si existe, solo asegurarse de que el componente también maneje su propio estado de carga con el skeleton.
+
+**Verificación:** con DevTools → Network → Slow 3G, navegar desde biblioteca a proceso. Debe aparecer el skeleton (bloques grises animados preservando el layout) durante la carga, no una rueda giratoria sobre fondo en blanco.
+
+---
+
+## Fix 1 — Fit-viewport: botón sin argumento 'auto'
+
+### Diagnóstico
+
+En `BpmnCanvas.tsx`, el botón de encuadre llama:
+
+```ts
+canvas.zoom('fit-viewport') // ← sin segundo argumento
+```
+
+El load inicial correcto usa:
+
+```ts
+canvas.zoom('fit-viewport', 'auto') // ← con 'auto', centra el diagrama
+```
+
+Sin `'auto'`, bpmn-js hace fit pero sin centrar — el diagrama queda pegado al borde superior izquierdo del viewport. Con `'auto'`, centra el diagrama en el viewport con el zoom justo para que entre.
+
+### Fix
+
+En el handler del botón de encuadre (botón con ícono `Maximize2` en `BpmnCanvas.tsx`), agregar el argumento `'auto'`:
+
+```ts
+// Antes:
+canvas.zoom('fit-viewport')
+
+// Después:
+canvas.zoom('fit-viewport', 'auto')
+```
+
+Una línea. No tocar el resto del componente.
+
+---
+
+## Fix 2 — Controles de zoom-in y zoom-out
+
+### Contexto del layout
+
+`BpmnCanvas.tsx` tiene:
+- Wrapper externo: `position: relative, width: 100%, height: 100%`
+- `containerRef` (donde monta bpmn-js): `width: 100%, height: 100%`
+- Botón fit-viewport existente: `position: absolute, top: 12, right: 12`
+
+Los botones nuevos se posicionan sobre el canvas usando `position: absolute`.
+
+### Implementación
+
+Agregar dos botones: zoom-in (+) y zoom-out (−). Junto al botón fit existente, formando un grupo de 3 controles.
+
+**Posición:** esquina inferior derecha del canvas (no esquina superior — para no interferir con el botón fit existente que ya está arriba). Usar `bottom: 12, right: 12`.
+
+**Lógica:**
+```ts
+const handleZoomIn = () => {
+ if (!viewerRef.current) return
+ const canvas = viewerRef.current.get('canvas') as any
+ const currentZoom = canvas.zoom()
+ canvas.zoom(Math.min(currentZoom * 1.2, 4)) // máximo zoom: 4x
+}
+
+const handleZoomOut = () => {
+ if (!viewerRef.current) return
+ const canvas = viewerRef.current.get('canvas') as any
+ const currentZoom = canvas.zoom()
+ canvas.zoom(Math.max(currentZoom / 1.2, 0.1)) // mínimo zoom: 0.1x
+}
+```
+
+Los límites `min: 0.1` y `max: 4` previenen que el usuario llegue a un estado de zoom irrecuperable.
+
+**Estilo de los botones** — consistente con el botón fit existente:
+- Tamaño: 28×28px o 32×32px (el que use el botón fit actual)
+- Fondo: blanco semitransparente con borde sutil
+- Ícono Tabler: `ti-zoom-in` para +, `ti-zoom-out` para −
+- Separación entre botones: 4px (grupo compacto)
+- Disposición: verticalmente apilados (+ arriba, − abajo) o en fila horizontal — lo que quede más compacto sin solapar el diagrama
+
+**No reemplazar el zoom por scroll/pinch** — esos eventos de bpmn-js siguen funcionando igual. Estos botones son controles adicionales para usuarios que no tienen scroll o prefieren click explícito.
+
+**Extraer en subcomponente si mejora la legibilidad** — si el bloque de JSX del canvas empieza a crecer, considerar un `` separado. Solo si la extracción es natural; no forzar si el componente ya es compacto.
+
+---
+
+## Qué NO entra en esta etapa
+
+- Cambios en la lógica de simulación o cálculos
+- Cambios en paneles laterales (ActivityPanel, ResourcesPanel)
+- Cambios en el reporte o PDF
+- Nuevas features de producto
+- Cambios en auth o Supabase
+
+---
+
+## Orden de ejecución
+
+1. Fix 0 — `WorkspacePage.tsx`: renderizar `WorkspaceSkeleton` cuando `isLoading === true`
+2. Fix 1 — `BpmnCanvas.tsx`: agregar `'auto'` al botón fit-viewport
+3. Fix 2 — `BpmnCanvas.tsx`: agregar botones zoom-in y zoom-out en esquina inferior derecha
+4. `npm run build` — 0 errores TypeScript
+5. `npm run lint` — 0 warnings nuevos
+6. `npm run test` — mantener 521+ tests Vitest verdes
+7. `npx playwright test tests/e2e/sprint-4-smoke.spec.ts` — 9/9 verdes
+8. Commit: `fix(sprint-5/etapa-2): skeleton workspace + fit-viewport center + zoom controls`
+
+---
+
+## Criterios de validación
+
+- [ ] Con Slow 3G: navegar desde biblioteca a proceso muestra skeleton (no spinner genérico) durante la carga
+- [ ] Botón fit-viewport centra el diagrama con padding visible en todos los bordes (no clip al borde superior)
+- [ ] Botones zoom-in y zoom-out visibles en el canvas
+- [ ] Zoom-in hace la imagen más grande (factor ~1.2x por click)
+- [ ] Zoom-out hace la imagen más pequeña (factor ~1.2x por click)
+- [ ] Zoom por scroll/pinch sigue funcionando sin interferencia
+- [ ] Los controles no tapan el diagrama ni los paneles laterales
+- [ ] Build limpio, lint sin warnings nuevos, 521+ tests Vitest verdes
+- [ ] **Validación visual del director** antes del OK formal
diff --git a/src/features/workspace/BpmnCanvas.tsx b/src/features/workspace/BpmnCanvas.tsx
index 131c3d8..146474d 100644
--- a/src/features/workspace/BpmnCanvas.tsx
+++ b/src/features/workspace/BpmnCanvas.tsx
@@ -1,6 +1,10 @@
-import { useEffect, useRef, useCallback } from 'react'
+import { useEffect, useRef, useCallback, type CSSProperties } from 'react'
import BpmnViewer from 'bpmn-js/lib/Viewer'
-import { Maximize2 } from 'lucide-react'
+import { Maximize2, ZoomIn, ZoomOut } from 'lucide-react'
+
+const ZOOM_STEP = 1.2
+const ZOOM_MIN = 0.1
+const ZOOM_MAX = 4
export type BpmnElementCategory = 'activity' | 'gateway' | 'other'
@@ -158,34 +162,56 @@ export function BpmnCanvas({
}
}, [selectedElementId])
+ function handleZoomIn() {
+ const canvas = viewerRef.current?.get('canvas') as any
+ if (!canvas) return
+ canvas.zoom(Math.min(canvas.zoom() * ZOOM_STEP, ZOOM_MAX))
+ }
+
+ function handleZoomOut() {
+ const canvas = viewerRef.current?.get('canvas') as any
+ if (!canvas) return
+ canvas.zoom(Math.max(canvas.zoom() / ZOOM_STEP, ZOOM_MIN))
+ }
+
+ const controlButtonStyle: CSSProperties = {
+ width: 32,
+ height: 32,
+ borderRadius: 6,
+ background: 'hsl(var(--background))',
+ border: '1px solid hsl(var(--border))',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ cursor: 'pointer',
+ boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
+ }
+
return (
+
+ {/* Encuadrar — esquina superior derecha */}
+
+ {/* Zoom in/out — esquina inferior derecha, no interfiere con el botón de encuadre */}
+
+
+
+
)
}
diff --git a/src/features/workspace/WorkspacePage.tsx b/src/features/workspace/WorkspacePage.tsx
index d16e667..31e00cb 100644
--- a/src/features/workspace/WorkspacePage.tsx
+++ b/src/features/workspace/WorkspacePage.tsx
@@ -151,7 +151,9 @@ export function WorkspacePage() {
- if (isLoading && !currentProcess) {
+ // isLoading manda siempre, incluso si currentProcess todavía tiene el proceso
+ // anterior en el store (navegación directa entre dos workspaces distintos).
+ if (isLoading) {
return
}