Nexo desktop-first redesign + demo restructure

Move servers/dating → demos/dating/server and the dating route's
co-located _components/_lib/_design assets → demos/dating/web. Single
`npm run dating:dev` boots both server and web via concurrently.

Replace the warm-paper / raspberry-pink Claude Design adaptation with
a sober desktop-first system:

- Tokens: slate neutrals + violet accent, Inter family, dark mode via
  prefers-color-scheme + explicit data-theme.
- Inline SVG icon set (Icon.svelte, Feather-style) — no Unicode glyph
  placeholders.
- Layout shell: 240px rail nav on desktop, bottom tab bar on mobile;
  auth + onboarding own their own shell.
- Discover: square photo carousel (dots + chevrons), name/age/location
  overlay, action buttons floating off the photo edge, sticky context
  panel on desktop.
- Matches: clean conversation list with unread dot + chevron-on-hover.
- Chat: 3-column on desktop (threads + thread + match context), 2-col
  on tablet, single thread on mobile. Day separators, retry/dismiss
  on failed messages, autogrow composer with Enter-to-send.
- Profile: sticky hero card + 3-section form, dirty/saved indicator.
- Photos: dropzone + grid with hover-only cell toolbar (set primary,
  reorder, delete).
- Onboarding: sidebar stepper (320px) + content panel.
- Auth: split layout (form left, brand quote right) on ≥900px.

Server boot: env loader's repo-root resolution corrected after the
move (../../.. instead of ../..) so .env.local at the repo root is
picked up again.

Hoist BRAND.md and SECURITY.md into docs/, drop superseded audit
notes that were already closed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
master
dev 5 months ago
parent a001bc3b2d
commit 3ca1945dd9

2
.gitignore vendored

@ -24,6 +24,8 @@ vite.config.ts.timestamp-*
# Local docs/dev logs
tmp-active-docs-*.log
demos/dating/server/*.log
demos/dating/server/tmp/
# IDE / tool state (personal, not shared)
.claude/

@ -1,549 +0,0 @@
# Auditoria Codex - ecosistema Active + Orca
Fecha: 2026-05-04
Workspace: `G:\dev\svelte\active`
## Alcance
Se audito el estado actual del ecosistema con foco en el nuevo modulo
`src/arts/orca` y sus puntos de acoplamiento con:
- `src/arts/active-app`
- `src/arts/bus`
- `src/arts/session`
- `src/arts/cache`
- `src/arts/perm`
- `src/arts/connection`
- documentacion publica en `src/arts/*/README.md` y pagina activa generada.
No se modifico codigo existente. El arbol ya tenia cambios previos en:
- `src/arts/orca/consts.ts`
- `src/arts/orca/result.ts`
- `src/arts/orca/types.ts`
Esos cambios parecen corregir parte de la deriva de comentarios `v0.1+` respecto a
timeout/fatal/gates/compensation, pero no cierran todos los problemas.
## Veredicto
`orca` esta bien orientado arquitectonicamente. La decision fuerte, y la que
realmente eleva el ecosistema, es mover las reacciones inter-modulo a una capa de
orquestacion declarativa situada en `active-app`, no dentro de `cache`, `perm`,
`session` o `connection`. Eso reduce acoplamiento y permite testear flujos
compuestos como objetos de ejecucion (`OrcaRunResult`).
El modulo, sin embargo, ya ha crecido mas rapido que su contrato. El motor actual
implementa gates, timeouts de accion, compensacion, transacciones, payloads en
tokens, fan-in y politicas de cola. La documentacion mezcla tres versiones a la
vez: boceto conceptual, v0 inicial y estado real. Antes de seguir expandiendo
`orca`, conviene congelar el contrato real y eliminar ambiguedades.
Estado de pruebas focalizadas:
```txt
npx vitest run src/arts/orca/test src/arts/active-app/test/presets.test.ts
Test Files 4 passed
Tests 169 passed
```
## Hallazgos P1
### 1. Liberacion de trace no es segura con runs paralelos
Referencia:
- `src/arts/orca/engine-orca.ts:928`
- `src/arts/orca/engine-orca.ts:931`
- `src/arts/orca/engine-orca.ts:935`
`maybeReleaseTrace()` borra `traceStates` si no quedan eventos en cola. El propio
comentario reconoce que no sabe si hay runs en vuelo del mismo trace. Eso era
defendible con ejecucion estrictamente secuencial, pero ya existe
`ORCA_QUEUE_PARALLEL`. Si dos eventos derivados del mismo trace corren en
paralelo, el primero que termine puede borrar el estado de trace mientras el otro
sigue vivo.
Impacto:
- Los contadores `maxDepth`, `maxEventsPerTrace`, `repeatedEventLimit` y
`dedupeKey` pueden resetearse antes de tiempo.
- Un loop por eventos derivados puede saltarse la proteccion si el trace se borra
mientras quedan runs paralelos activos.
- La trazabilidad de un flujo enterprise deja de ser confiable justo en el modo
mas peligroso: paralelo.
Recomendacion:
- Mantener `traceInFlightCount` por `traceId`.
- Incrementarlo en `spawnRun()`.
- Decrementarlo en el `finally` de `executeRun()`.
- Liberar el trace solo cuando `queuedCount(traceId) === 0` e
`inFlightCount(traceId) === 0`.
- Anadir test: dos eventos derivados con `ORCA_QUEUE_PARALLEL` en el mismo trace;
el primero termina, el segundo emite otro evento repetido y el guard sigue
aplicando.
### 2. `provides` no limita `emits`, asi que la validacion puede mentir
Referencia:
- `src/arts/orca/types.ts:421`
- `src/arts/orca/types.ts:424`
- `src/arts/orca/types.ts:425`
- `src/arts/orca/engine-orca.ts:1117`
- `src/arts/orca/engine-orca.ts:1124`
El contrato dice que `provides` es lo que una accion puede producir, y
`validate()` usa `provides` para detectar tokens imposibles. Pero el runtime
acepta cualquier token que venga en `result.emits`, aunque no este declarado en
`provides`.
Impacto:
- `validate()` no es una garantia fuerte; es una estimacion basada en intencion.
- Una accion puede desbloquear otra con un token que el grafo no declaraba.
- Un typo en `emits` puede cambiar el pipeline sin que `validate()` lo vea.
- El futuro `setupOrca()` tipado pierde parte de su sentido si el runtime permite
emisiones fuera de esquema.
Recomendacion:
- En modo dev, lanzar o registrar `CONFIGURATION_INVALID` cuando una accion emite
un token no declarado en `provides`.
- En modo prod, al menos registrar diagnostic `orca.configuration.invalid` o
descartar el token no declarado segun opcion.
- Documentar excepcion solo si se decide permitir tokens dinamicos, pero entonces
`validate()` debe llamarse "best effort", no "static graph validation".
### 3. `ORCA_QUEUE_REPLACE` no es `replace-current`, solo reemplaza queued
Referencia:
- `src/arts/orca/consts.ts:73`
- `src/arts/orca/engine-orca.ts:420`
- `src/arts/orca/engine-orca.ts:421`
- `src/arts/orca/engine-orca.ts:422`
- `src/arts/orca/README.md:439`
- `src/arts/orca/README.md:1444`
Hay dos semanticas distintas usando el mismo nombre mental:
- Una seccion del README describe `replace` como "aborta el run activo y empieza
uno nuevo", estilo `takeLatest`.
- El motor implementa "reemplaza solo entradas queued; no aborta in-flight".
- Otra seccion posterior del README ya reconoce esta realidad.
Impacto:
- Para formularios, busquedas, navegacion o cambio de identidad, un usuario puede
esperar "ultima intencion gana", pero el run anterior seguira ejecutandose.
- Si la accion vieja muta cache/conexiones despues de la nueva, puede pisar estado.
- El nombre `replace` es ambiguo para usuarios que vienen de saga/Rx/RTK.
Recomendacion:
- Renombrar la politica actual a algo explicito: `ORCA_QUEUE_REPLACE_QUEUED` o
`ORCA_QUEUE_LATEST_QUEUED`.
- Reservar `ORCA_QUEUE_REPLACE_CURRENT` / `ORCA_QUEUE_TAKE_LATEST` para la
variante fuerte que aborta in-flight.
- Si se mantiene el nombre actual, borrar del README cualquier promesa de abortar
el run activo.
### 4. IDs de run/event/trace usan `Date.now()` y `Math.random()` fuera de `timr`
Referencia:
- `src/arts/orca/engine-orca.ts:1513`
- `src/arts/orca/engine-orca.ts:1514`
- `src/arts/orca/engine-orca.ts:1517`
- `src/arts/orca/engine-orca.ts:1518`
- `src/arts/orca/engine-orca.ts:1521`
- `src/arts/orca/engine-orca.ts:1522`
- `src/arts/orca/README.md:841`
El README establece que `orca` no debe usar `Date.now()` ni `setTimeout()`
directamente. Los timestamps del envelope ya pasan por `timers.clock.now()`, pero
los IDs de run/event/trace no.
Impacto:
- Tests y replay no son plenamente deterministas.
- En SSR/hydration o tests con clocks controlados, los IDs no siguen el tiempo
simulado.
- La disciplina "todo tiempo via timr" queda rota justo en el modulo que quiere
ser trazable.
Recomendacion:
- Anadir `idFactory` a `EngineOrcaOptions` o tres factories separadas:
`runIdFactory`, `eventIdFactory`, `traceIdFactory`.
- Default: contador monotono con prefijo, alimentado por `timers.clock.now()` si
se quiere incluir tiempo.
- Tests: factory determinista para snapshots.
### 5. `setupOrca()` esta documentado como API recomendada pero no existe
Referencia:
- `src/arts/orca/README.md:235`
- `src/arts/orca/README.md:241`
- `src/arts/orca/README.md:271`
- `src/arts/orca/README.md:284`
- `src/arts/orca/README.md:1271`
- `src/arts/orca/index.ts`
El README recomienda `setupOrca()` para apps grandes y muestra ejemplos, pero no
hay export ni implementacion en `src/arts/orca`.
Impacto:
- La documentacion invita a usar una API inexistente.
- El usuario no sabe si debe usar `App.Orca.onEvent()` directo, presets o un
setup tipado futuro.
- La promesa de validacion fuerte de tokens/eventos queda sin soporte.
Recomendacion:
- O implementar un `setupOrca()` minimo, aunque solo envuelva `onEvent` con
declaracion de eventos/tokens/actions.
- O mover esa seccion a "Futuro / v0.1" y dejar claro que v0 real es
`createEngineOrca()` + `App.Orca.onEvent()`.
### 6. La integracion con `connection` no esta cerrada y la documentacion promete mas de lo que existe
Referencia:
- `src/arts/active-app/presets/standard.ts:28`
- `src/arts/active-app/presets/index.ts:5`
- `src/arts/active-app/service-factories/connections.ts:9`
- `src/arts/connection/README.md:83`
- `src/arts/connection/README.md:93`
- `src/arts/connection/README.md:100`
- `src/arts/connection/session-wiring.ts:27`
`applyStandardOrca()` solo registra presets de cache y perm. No hay preset para
`connections.reauthenticateAll()` ni para `connections.closeAll()` en cambios de
identidad/revoke/expire. A la vez, la documentacion de `connection` habla de
`autoReauthOn: 'standard'` y de escuchar `SESSION_EVENT_IDENTITY_CHANGED`.
Ademas, `defineActiveConnections()` ya no inyecta `bus` ni `session`; el comentario
dice que identidad debe venir por orca preset o por una `session` manual, pero ese
preset no existe.
Impacto:
- Una app que siga la documentacion puede creer que las conexiones se reautentican
al cambiar de usuario, pero con `applyStandardOrca(App)` no ocurre.
- El caso critico que motivo `orca` ("chat conectado con credenciales del usuario
anterior") sigue sin preset estandar.
- `connection` conserva un mecanismo interno de `session-wiring` que compite
conceptualmente con la nueva regla: los artefactos publican/reciben eventos; la
app orquesta reacciones.
Recomendacion:
- Crear preset en `active-app/presets`:
`applyConnectionsReauthOnIdentityChange(App)`.
- Definir si el estandar hace `reauthenticateAll()` o `closeAll()` cuando no hay
credencial nueva.
- Actualizar `applyStandardOrca()` para incluir conexiones cuando `App.connections`
exista.
- Decidir si `connection.session-wiring` queda como modo local/manual o se depreca
a favor del preset de `orca`.
## Hallazgos P2
### 7. `runActionWithTimeout()` aborta de forma cooperativa, pero la accion puede seguir mutando estado
Referencia:
- `src/arts/orca/engine-orca.ts:1042`
- `src/arts/orca/engine-orca.ts:1052`
- `src/arts/orca/engine-orca.ts:1056`
- `src/arts/orca/engine-orca.ts:1058`
- `src/arts/orca/engine-orca.ts:1070`
El timeout hace `actionController.abort()` y resuelve `orcaTimeout(timeoutMs)`, pero
la promesa original de la accion sigue viva si la accion no respeta `ctx.signal`.
Esto es normal en JS, pero debe tratarse como contrato de seguridad.
Impacto:
- Una accion timeout puede mutar cache, conexiones o permisos despues de que el run
ya haya tomado otra decision.
- Los tests pueden pasar porque el resultado es `timeout`, pero el side-effect
tardio queda fuera del trace.
Recomendacion:
- Documentar que toda accion async debe comprobar `ctx.signal.aborted` antes y
despues de awaits relevantes.
- Anadir helper `ctx.throwIfAborted()` o `orcaAbortIfSignaled(ctx)`.
- Anadir test: accion con timeout que intenta mutar despues; demostrar que el
patron recomendado lo evita.
### 8. La cola es global para eventos no-parallel, aunque el contrato se lee como per-event
Referencia:
- `src/arts/orca/engine-orca.ts:526`
- `src/arts/orca/engine-orca.ts:527`
- `src/arts/orca/engine-orca.ts:529`
- `src/arts/orca/engine-orca.ts:531`
`canStartRun()` serializa globalmente todos los eventos no-parallel:
`nonParallelInFlight === 0`. Eso significa que un evento lento de `cache` puede
bloquear un evento no relacionado de `perm`, `connection` o cualquier feature.
Impacto:
- Semantica mas conservadora y lenta de lo que sugiere "queue policy per event".
- Posible cuello de botella en apps grandes con flujos independientes.
- Si esta decision es intencional, falta nombrarla como "single lane".
Recomendacion:
- Decidir explicitamente:
- Modelo A: serializacion global por defecto, documentada como garantia simple.
- Modelo B: serializacion por evento, con concurrencia entre eventos distintos.
- Si se mantiene A, renombrar/comentar como `globalSerialLane`.
- Si se pasa a B, usar `inFlightByEvent` para bloquear solo el mismo evento.
### 9. `commit()` congela el grafo; eso choca con features lazy si no se documenta como modo prod
Referencia:
- `src/arts/orca/types.ts:711`
- `src/arts/orca/types.ts:723`
- `src/arts/orca/engine-orca.ts:1275`
- `src/arts/orca/test/engine-orca.test.ts:4013`
El comportamiento actual esta claro en codigo y tests: despues de `commit()`,
`onEvent()` y `configureEvent()` lanzan `OrcaFrozenError`. Esto no es un bug por
si mismo, pero tensiona el objetivo de registrar acciones desde features
lazy-loaded.
Impacto:
- Si una app llama `App.Orca.commit()` durante bootstrap, una ruta lazy ya no puede
registrar sus acciones al cargar.
- HMR/devtools pueden quedar bloqueados si no se separa "validar" de "sellar".
Recomendacion:
- Documentar `commit()` como opcion production/seal, no como paso obligatorio.
- Considerar `validate()` para dev y `commit()` solo para builds donde el grafo es
completo al arrancar.
- Si se quiere lazy + freeze, introducir versionado por evento: cada run usa un
snapshot, pero el registro global puede crecer entre runs.
### 10. `ctx.emit()` es correcto, pero la atribucion via `bus.publish()` depende de AsyncLocalStorage
Referencia:
- `src/arts/orca/engine-orca.ts:133`
- `src/arts/orca/engine-orca.ts:204`
- `src/arts/orca/engine-orca.ts:864`
- `src/arts/orca/als.ts`
- `src/arts/orca/README.md:606`
- `src/arts/orca/README.md:621`
- `src/arts/orca/README.md:1470`
El diseno correcto es claro: modulo -> `bus.publish()`, accion -> `ctx.emit()`.
El motor intenta interceptar `bus.publish()` dentro de acciones mediante
AsyncLocalStorage cuando esta disponible. En navegador puede no estar disponible,
por lo que el mismo `bus.publish()` dentro de una accion puede ser hijo en Node y
root event en browser.
Impacto:
- Trazas distintas entre SSR/tests Node y browser.
- Reentry guards no aplican igual si un evento derivado sale como root.
Recomendacion:
- Mantener la regla dura: las acciones usan `ctx.emit()` siempre.
- Tratar la interceptacion ALS como bonus diagnostico, no como contrato.
- Anadir lint/documentacion: no usar `App.Bus.publish()` dentro de una accion de
`orca` salvo que se quiera crear root event explicito.
### 11. `engine-orca.ts` ya es un monolito de responsabilidades
Referencia:
- `src/arts/orca/engine-orca.ts` (~58 KB)
El engine concentra registro, cola, trace state, reentry, ejecucion de waves,
timeouts, compensation, validation, ID generation, diagnostics y public API.
Impacto:
- Dificulta revisar invariantes delicadas como "trace cleanup + parallel".
- Hace mas probable que futuras features (`runTimeoutMs`, `setupOrca`, inspector)
entren sin frontera clara.
- Ya contradice la direccion del ecosistema de reducir monolitos (`connection.ts`
se venia refactorizando por el mismo motivo).
Recomendacion de split:
- `queue.ts`: enqueue, policies, drain, in-flight counters.
- `trace.ts`: envelope, trace state, reentry guards, release.
- `runner.ts`: stages, waves, action execution, status precedence.
- `timeouts.ts`: action timeout helpers y timer keys.
- `compensation.ts`: LIFO compensation / transaction compensation.
- `validation.ts`: validate/commit helpers.
- `ids.ts`: factories deterministas.
- `engine-orca.ts`: composicion publica.
### 12. Los presets usan action ids y tokens inline/locales
Referencia:
- `src/arts/active-app/presets/cache-clear-on-identity-change.ts:6`
- `src/arts/active-app/presets/cache-clear-on-identity-change.ts:7`
- `src/arts/active-app/presets/cache-clear-on-revoke.ts:6`
- `src/arts/active-app/presets/cache-clear-on-revoke.ts:7`
- `src/arts/active-app/presets/perm-invalidate-on-identity-change.ts:6`
- `src/arts/active-app/presets/perm-invalidate-on-identity-change.ts:7`
Los eventos del ecosistema ya estan centralizados como constantes, pero los
`ACTION_ID` y `TOKEN_*` de presets quedan como strings locales no exportados.
Impacto:
- No se pueden reutilizar en tests de integracion, docs, inspector `/test/orca`
o setup tipado.
- Rompe la regla emergente: "eventos, tokens y action ids como constantes".
- El inspector no puede mostrar nombres canonicos importables.
Recomendacion:
- Crear `src/arts/active-app/presets/consts.ts` o exportar desde cada preset:
`APP_ORCA_ACTION_CACHE_CLEAR_ON_IDENTITY_CHANGE`,
`APP_ORCA_TOKEN_CACHE_CLEARED_ON_IDENTITY_CHANGE`, etc.
- Usar nombres de constantes en README y pagina docs.
## Hallazgos P3
### 13. Header de `engine-orca.ts` esta obsoleto
Referencia:
- `src/arts/orca/engine-orca.ts:24`
- `src/arts/orca/engine-orca.ts:25`
- `src/arts/orca/engine-orca.ts:26`
El comentario inicial dice que `after`, `unless`, `abortOn`,
`actionTimeoutMs` y `compensate` se aceptan pero se ignoran. El motor ya los
implementa.
Impacto:
- La siguiente persona que lea el archivo empieza con un mapa mental falso.
- Ya se corrigieron comentarios en `types.ts`, `consts.ts` y `result.ts`, pero el
comentario mas importante del motor sigue atrasado.
Recomendacion:
- Actualizar el bloque de cabecera para describir el estado real.
- Evitar version tags contradictorios dentro del codigo; mover roadmap al README.
### 14. El README de `orca` mezcla contrato actual, boceto y roadmap en un solo flujo
Referencia:
- `src/arts/orca/README.md:511`
- `src/arts/orca/README.md:1411`
- `src/arts/orca/README.md:1506`
- `src/arts/orca/README.md:1522`
Ejemplo claro: una seccion dice que en v0 los tokens son flags sin payload; otra
seccion posterior dice que los tokens con payload ya estan implementados. Esto no
es solo estetico: afecta al modo en que un desarrollador modela acciones.
Impacto:
- La documentacion no sirve como contrato normativo.
- Los lectores no saben si estan viendo la version deseada o la version real.
Recomendacion:
- Reestructurar README en tres bloques cerrados:
- "Contrato actual implementado"
- "Patrones recomendados"
- "Roadmap / no implementado"
- Eliminar del contrato actual cualquier API no exportada (`setupOrca`) o moverla
a roadmap.
- Mantener una tabla "Feature -> estado -> archivo/test".
### 15. Falta una prueba compuesta del caso que motivo `orca`: cambio de usuario con cache/perm/connection
Referencias:
- `src/arts/active-app/test/presets.test.ts`
- `src/arts/orca/test/engine-orca.test.ts`
Hay buena cobertura unitaria de `orca` y presets basicos. Falta el escenario
ecosistema que debe probar el valor real:
1. Usuario A abre sesion.
2. Chat/conexion usa credencial A.
3. Cache actor-scoped guarda datos de A.
4. Perm calcula snapshot de A.
5. Cambia a usuario B.
6. Orca ejecuta clear cache, invalidate perm y reauth/close connection.
7. Ningun dato/credencial de A queda observable.
Impacto:
- El sistema puede estar correcto por modulo pero fallar justo en composicion.
- El caso "chat con credenciales antiguas" sigue sin prueba de regresion.
Recomendacion:
- Crear test de integracion en `src/arts/active-app/test/ecosystem-orca.test.ts`
o equivalente.
- Usar fakes de cache/perm/connection con counters y credenciales capturadas.
- Verificar orden por `OrcaRunResult`: cache -> perm -> connection si se decide
dependencia via tokens.
## Observaciones positivas
- `App.Bus`, `App.Timers` y `App.Orca` ya son core siempre presentes en
`createActiveApp()`, y `Orca` es inerte hasta registrar acciones. Esa decision
encaja con el diseno enterprise sin obligar a cada app a crear singletons
manuales.
- Los presets viven en `active-app/presets`, no dentro de los artefactos. Esto es
correcto: `cache` no debe conocer `session`; `perm` no debe conocer `auth`;
`orca` no debe conocer modulos.
- `ActiveOrca` es un wrapper fino y razonable: estado reactivo, sin meter logica
del engine en Svelte.
- La suite focalizada de `orca` ya es grande: 169 tests entre engine/active/result
y presets. Para un modulo recien nacido, eso es muy buena base.
- `ctx.signal` existe en `OrcaActionContext`; eso habilita cancelacion cooperativa
y es la direccion correcta.
## Recomendacion de orden de trabajo
1. Corregir P1.1: trace cleanup con runs paralelos.
2. Cerrar P1.2: `provides` debe ser contrato real o `validate()` debe declararse
best-effort.
3. Resolver P1.3: renombrar/split de `replace` para evitar semantica ambigua.
4. Sustituir `Date.now()`/`Math.random()` por factories deterministas.
5. Decidir `setupOrca()`: implementarlo minimo o moverlo fuera de v0.
6. Crear preset de `connection` y test compuesto usuario A -> usuario B.
7. Reordenar README para separar implementado/recomendado/roadmap.
8. Refactorizar `engine-orca.ts` por responsabilidades antes de anadir run/stage
timeout, inspector visual o setup tipado.
## Conclusion
`orca` merece seguir. No es "otro bus": es el sitio correcto para convertir
flujos inter-modulo en artefactos trazables y testeables. Pero precisamente por
eso no puede permitirse ambiguedad en nombres, timeouts, tokens y concurrencia.
El ecosistema esta cruzando una frontera importante: de librerias coherentes a
runtime de aplicacion. La prioridad ahora no es meter mas features, sino hacer
que el contrato de `orca` sea tan fiable como su idea.

@ -1,566 +0,0 @@
# Auditoria Codex del ecosistema hacia 1.0
Fecha: 2026-05-04
Alcance: `lang`, `cache`, `session`, `perm`, `auth`, `http`.
Excluido: `orca`, ya auditado por separado.
## Veredicto ejecutivo
El ecosistema esta bastante por encima de una 0.x normal: hay contratos tipados, separacion real entre `libs`, `arts` y `svrs`, factories declarativas en `createActiveApp()`, y una base de tests que hoy pasa para los modulos auditados.
La brecha hacia 1.0 no esta tanto en "reescribir" modulos, sino en cerrar tres frentes:
1. **Contratos publicos exactos**: hay documentacion y ejemplos que todavia prometen aliases, nombres o inyecciones que el codigo ya no hace.
2. **Determinismo operativo**: `timr`/`Timers` ya existe, pero `http`, `session`, `cache` y `perm` aun conservan defaults con `Date.now()`, `Math.random()` o timers nativos cuando se usan fuera del wiring ideal.
3. **Pruebas compuestas**: los tests unitarios estan verdes, pero faltan historias de ecosistema donde `auth`, `session`, `perm`, `cache` y `http` fallan o se invalidan juntos.
Verificacion ejecutada:
```txt
npx vitest run src/arts/lang/test src/arts/cache/test src/libs/cache/test src/svrs/cache/test src/arts/session/test src/arts/perm/test src/svrs/perm/test src/arts/auth/test src/svrs/auth/test src/arts/http/test
35 test files passed
426 tests passed
```
## Hallazgos transversales
### P1 - La documentacion debe dejar de prometer APIs antiguas
Hay restos de la etapa de aliases de 4 letras y de nombres capitalizados: `$cach`, `$sess`, `App.Cache`, `App.Sess`, ejemplos sin `services: { ... }`, y textos que dicen que `cache` esta siempre presente. El codigo actual va por `createActiveApp({ services })` y expone servicios lazy en minuscula (`App.cache`, `App.session`, `App.perm`, `App.auth`, `App.http`).
Impacto: un desarrollador nuevo no distingue que es API real y que es historia del framework. Para 1.0, esto no puede quedar en "se entiende mirando codigo"; la documentacion es parte del contrato.
Accion recomendada:
- Hacer una pasada de docs con una regla mecanica: ningun README ni pagina `src/web/routes/active/docs/**` puede usar alias o propiedades que no existan en `svelte.config.js` y en `src/arts/active-app/service-factories/**`.
- Crear tests de snippets o al menos un script que busque `$cach`, `$sess`, `App.Cache`, `App.Sess`, `App.Http`, `App.Perm`, etc.
### P1 - La inyeccion entre servicios no esta igual de clara que el discurso
Las factories actuales inyectan principalmente `logger`, y solo `session` recibe tambien `bus`:
- `defineActiveAuth(...)` recibe `logger`; el `http` debe venir en `options`.
- `defineActivePerm(...)` recibe `logger`; aunque `PermClientOptions` acepta `http?: EngineHttp`, la factory no declara dependencia de `http`.
- `defineActiveCache(...)` recibe `logger`; no recibe `timers` por defecto.
- `defineActiveSession(...)` recibe `logger` y `bus`; no recibe `timers` para auto-refresh.
- `defineEngineHttp(...)` recibe `logger`; no recibe `timers`.
Esto es coherente si el principio es "pasivo por defecto, opt-in explicito". Pero varias docs ya hablan como si `Http`, `Cache`, `Bus` y `Timers` se cablearan automaticamente entre servicios.
Accion recomendada:
- Decidir para 1.0 una regla unica: o las factories solo reciben core deps minimas, o pueden declarar `serviceDependencies`.
- Si se mantiene el modo minimalista, documentarlo sin ambiguedad: `auth` necesita `http` explicito, `perm` necesita `endpoint` o `http` explicito, `cache` no se invalida sola, `session` no refresca con Timers salvo que se le pase.
- Si se quiere ergonomia enterprise, evolucionar factories para dependencias opcionales: `defineActivePerm` puede consumir `http` si existe; `defineActiveCache` y `defineActiveSession` pueden consumir `timers`.
### P1 - Falta una suite compuesta de identidad, permisos y cache
La base verde actual no prueba suficientemente los casos que mas preocupan en una aplicacion real:
- Usuario A abre sesion, cachea datos privados, cambia a usuario B, y B no ve cache ni permisos de A.
- Backend comunica cambio de permisos y el cliente invalida decision cacheada antes de permitir acciones.
- `http` recibe 401/403, dispara refresh/auth state, `session` adopta o revoca, `perm` y `cache` reaccionan.
- Logout global revoca sesion, limpia cache actor-scoped y deja `perm` sin actor.
- Password reset revoca sesiones y el cliente queda en `anonymous` sin datos privados residuales.
Accion recomendada:
- Crear `src/routes/test/ecosystem` como harness de estas historias o moverlas a tests headless en `src/arts/active-app/test/ecosystem-*.test.ts`.
- Estos tests deben usar `App.Bus`/presets/orquestacion cuando toque, pero el criterio es observable: snapshots finales y ausencia de datos cruzados.
### P2 - Tiempo y aleatoriedad aun no son uniformes
Puntos concretos:
- `src/arts/http/retry.ts:48`, `:52`, `:64` usa `Date.now()`, `Math.random()` y `setTimeout`.
- `src/arts/http/timeout.ts:43`, `:63` usa `setTimeout`.
- `src/arts/cache/active-cache.svelte.ts:195` usa `Date.now()` si no se pasa clock.
- `src/arts/session/auto-refresh.ts:44`, `:45`, `:72` usa `Date.now()`, `Math.random()` y `setInterval` si no se pasa `timers`.
- `src/arts/perm/client.ts:291` usa `Date.now()` si no se pasa clock.
No es necesariamente un bug para uso aislado, pero para 1.0 la composicion `createActiveApp()` deberia poder inyectar `Timers.clock` y scheduler por defecto en los servicios que lo aceptan. Esa es una de las diferencias entre "librerias utiles" y "framework determinista".
## Modulo `lang`
### Estado
`lang` esta entre los modulos mas maduros. Tiene engine puro, wrapper active, resolucion de referencias `#?path|fallback`, extension de schema, pluralizacion, JSON helpers y una suite de tests amplia.
### Refactorizaciones recomendadas
1. **Cambiar `SvelteSet` por `Set` en listeners.** En `src/arts/lang/active-lang.svelte.ts:34` `localeListeners` no alimenta templates ni estado derivado; solo se itera manualmente. Igual que se hizo en otros modulos, `Set` plano reduce reactividad innecesaria.
2. **Separar mutacion y composicion de schemas con nombres mas explicitos.** Hoy `extend(namespace, module)` muta el engine activo y `register(namespace, module)` devuelve un engine hijo. Es potente, pero el naming puede confundir. Para 1.0 documentaria una tabla estricta: `extend` muta, `register` compone hijo.
3. **Hacer `SupportedLocale` menos cerrado.** `src/libs/lang/types.ts` limita locales a una lista base. Para producto 1.0, conviene permitir cualquier BCP47 tipado como branded string o una registry generica por app.
4. **Modo estricto de interpolacion.** `interpolateTemplate()` resuelve placeholders, pero no hay modo que falle si falta un parametro. Para 1.0 deberia existir `strictInterpolation` con diagnostico/log.
5. **Unificar docs de inyeccion.** La factory `defineActiveLang` si inyecta logger via `setLogger(core.logger)`. La documentacion debe mostrar claramente `services: { lang: defineActiveLang({ schema }) }` y no dar a entender que `createActiveApp()` siempre trae un schema real.
### Ampliaciones 1.0
- Loader asincrono de packs de idioma: `loadLocale(locale)` con cache y fallback.
- `Lang.tCode(code)` si se adopta una capa comun de errores/codigos.
- Integracion con formatos: resolver `{{price | currency}}`, `{{date | datetime}}` usando `format`.
- Dev inspector: namespace registrados, fallback usado, claves faltantes, referencias circulares.
- Script de validacion de schema: claves faltantes por locale, claves muertas y paths duplicados.
## Modulo `cache`
### Estado
`cache` tiene un runtime solido en `libs/cache`, wrapper `svrs/cache` y `arts/cache` reactivo. Hay politicas, scopes, tags, epochs, singleflight, stale-if-error y adapters memory/storage. La arquitectura esta bien orientada.
### Refactorizaciones recomendadas
1. **Actualizar docs antiguas.** Hay ejemplos que aun usan `$cach` o `App.Cache`; el alias real es `$cache` y la app expone `App.cache` solo si se declara `services.cache`.
2. **Inyectar clock desde App.Timers cuando se usa como servicio.** `createActiveCache()` acepta `clock`, pero `defineActiveCache()` solo inyecta `logger`. Para 1.0, el default app-wired deberia usar `core.timers.clock`.
3. **Evitar `console.warn` directo en memory adapter.** `src/libs/cache/adapters/memory.ts` usa `console.warn` si se crea en produccion sin `onProductionWarning`. En 1.0, la advertencia deberia pasar por diagnostics/logger o exigir handler explicito.
4. **Revisar clonacion de valores.** `memoryCacheAdapter` usa `structuredClone` si existe y fallback JSON. El fallback rompe `Date`, `Map`, `Set`, `BigInt`, clases y valores no serializables. Para 1.0: o se documenta "valores serializables" o se expone `clone?: (value) => value`.
5. **Purgado escalable.** La memoria purga expirados escaneando entradas. Es razonable para v0, pero para 1.0 conviene un sweeper opcional con `Timers` o un indice por expiracion si se esperan caches grandes.
### Ampliaciones 1.0
- Adapter L2 remoto opcional: Redis/HTTP/IndexedDB, manteniendo L1 memory.
- Invalidacion por evento app: identity changed, tenant switched, permission changed.
- `cache.queryHttp()` o helper de integracion con `http` para cachear respuestas con schema.
- Metricas: hit rate, stale served, refresh failures, singleflight joins, evictions.
- Politicas de privacy: impedir persistir scopes actor/session en adapters no seguros salvo opt-in.
- Tests de no fuga cross-actor y cross-tenant.
## Modulo `session`
### Estado
`session` es de los modulos mejor testeados. Tiene engine puro, wrapper active, generacion/versionado para evitar carreras, broadcast/storage sync, eventos de bus y auto-refresh opcional. La direccion es buena.
### Refactorizaciones recomendadas
1. **Actualizar naming en docs.** Debe desaparecer `$sess` y `App.Sess`; el alias real es `$session` y el servicio es `App.session`.
2. **Cablear auto-refresh con Timers desde App.** `auto-refresh.ts` ya acepta `timers`, pero `defineActiveSession()` no los inyecta. Para 1.0, si una app usa `createActiveApp()`, el refresh deberia poder ser determinista sin boilerplate manual.
3. **Limitar defaults nativos en modo app-wired.** `Date.now`, `Math.random` y `setInterval` son aceptables como fallback aislado, pero no como camino principal del ecosistema.
4. **Documentar ownership frente a auth.** `session` no debe saber de credenciales ni permisos; solo continuidad, refresh/revoke, snapshot y bus events. `auth` prueba identidad; `perm` decide permisos.
5. **Opciones explicitas para sync multi-tab.** Si ya existen, deben documentarse mejor; si no, conviene `broadcast: false | { channel }` para entornos con privacidad estricta o tests.
### Ampliaciones 1.0
- Preset `defineActiveSession({ autoRefresh: { standard: true } })` que use `Timers`.
- Integracion `http` para refresh por 401 sin acoplar `http` a `session`: hook reusable de aplicacion.
- Eventos canonicos para identity changed, credential refreshed, revoked, expired.
- Tests compuestos con auth/cache/perm.
- Modo SSR documentado: adoptar snapshot server sin doble refresh ni flicker.
## Modulo `perm`
### Estado
`perm` tiene mas base de servidor de la que parecia al inicio: `libs/perm` incluye DSL/evaluator/compiler, `svrs/perm` aporta engine, repository y SQL de referencia, y `arts/perm` aporta cliente activo. Es una buena base para 1.0, pero hay dos puntos que conviene cerrar pronto.
### Refactorizaciones recomendadas
1. **Corregir compilador SQL para paths anidados.** El runtime usa `getPath`, pero `src/libs/perm/compilers/sql.ts:105` y `:110` leen `input.actor[expr.path]` y `input.context?.[expr.path]`. Un path como `risk.mfa` o `profile.department` se evaluara distinto en runtime y en SQL. Para 1.0 esto debe ser P1: usar `getPath()` tambien en actor/context o declarar que SQL solo soporta paths planos.
2. **Preordenar policies una vez.** `src/libs/perm/runtime.ts` ordena por prioridad en cada decision. Para volumen real, ordenar al construir runtime y mantener indices por action/resource reduce coste.
3. **Memoizar providers por decision.** `DefaultPermEvaluator` puede llamar varias veces a relation/attribute providers con la misma key dentro de una decision. Un cache por request reduce latencia y evita multiples consultas a DB.
4. **Eliminar IDs auto-generados no estables en produccion.** `definePolicies`/builders pueden producir IDs por contador. Para 1.0, los policies persistidos deberian exigir `id` estable o generar checksum determinista.
5. **Alinear factory y docs.** `PermClientOptions` acepta `http?: EngineHttp`, pero `defineActivePerm()` no inyecta `App.http`. O se documenta que `fetcher`/`http` son manuales, o se declara dependencia opcional de `http`.
### Ampliaciones 1.0
- Persistencia oficial: migraciones SQL versionadas, repository contract tests y ejemplos Kysely/Drizzle.
- Webhook/evento de cambio de permisos: invalidar cliente y cache de decisiones por actor/tenant.
- `what()` y `explain()` cacheados con invalidacion por version de policies.
- Obligations/advice con enforcement helpers, no solo datos.
- Auditoria de decisiones: escribir `permission_decision_audit` desde server engine con redaccion.
- Tests de cross-actor race: login A -> decision allow -> login B -> misma accion no reutiliza decision.
## Modulo `auth`
### Estado
`auth` ha avanzado mucho: hay `libs/auth` como lenguaje comun, `svrs/auth` con engine server-authoritative, SQL de referencia, password signup/signin, CSRF, recovery, device records, refresh rotation y OAuth base. `arts/auth` es un cliente seguro que refleja `AuthCurrentView` y no intenta ser autoridad.
### Refactorizaciones recomendadas
1. **Completar handlers para rutas ya publicadas.** `src/libs/auth/consts.ts:21-30` declara rutas OAuth, MFA, devices y WebAuthn. `src/arts/auth/active-auth.svelte.ts:154-166` ya llama `DEVICES` y `DEVICE_REVOKE`. Pero `src/svrs/auth/handlers.ts` solo enruta current, csrf, password, logout, email verification y password reset. Resultado: `ActiveAuth.listDevices()` y `revokeDevice()` apuntan a endpoints que el handler generico no sirve. Para 1.0, o se agregan handlers, o se retiran del cliente hasta estar soportados.
2. **No guardar secretos OAuth en metadata de flow.** `oauth-flow.ts` guarda `state` y `verifier` en `metadata` ademas de hashes. Aunque la store sea server-side, el contrato ideal es persistir solo hash/verifier cifrado o recuperar verifier por canal seguro. Para 1.0 debe revisarse porque el documento original era estricto con secretos.
3. **PKCE challenge no debe usar hash token raw si no es base64url SHA-256 estandar.** Si `hashAuthToken()` no produce exactamente `base64url(SHA256(verifier))`, el flujo OAuth no sera interoperable. El test `oauth-pkce.test.ts` existe, pero conviene comprobarlo contra el RFC shape.
4. **Rate-limit esta bien cableado en password/recovery/oauth, pero falta matriz.** `enforceAuthRateLimit()` existe y se usa en flujos principales; para 1.0 hace falta tabla por metodo, key usada y politica recomendada.
5. **`createDbAuthAdapter` es demasiado generic para produccion.** El adapter `db.ts` acepta repositorios con `where` generico basado en records TS, mientras el SQL aplana `actorRef` a `tenant_id/actor_id`. Es valido como referencia, pero 1.0 necesita un adapter/repository probado contra el SQL real, no solo un contrato abstracto.
### Ampliaciones 1.0
- Handlers completos para devices y OAuth; MFA/WebAuthn marcados como experimental si no se implementan.
- Contract tests del SQL auth: credentials, flows, linked accounts, session bindings, refresh families.
- Anti-enumeration tests para recovery/email verification.
- Refresh rotation integrada end-to-end con sesion real, no solo helper unitario.
- Device/session management completo: listar, revocar actual, revocar otro, global logout.
- Security events hacia logger/audit con redaccion obligatoria.
- Integracion con `session`, `cache` y `perm`: signin/logout/password reset invalidan lo necesario sin acoplar modulos directamente.
## Modulo `http`
### Estado
`http` esta muy bien codificado: engine puro, resultados tipados, hooks, retry, timeout, schema de request/response, fetch inyectable y tests amplios. Es una pieza importante para que el ecosistema no dependa de `fetch` crudo.
### Refactorizaciones recomendadas
1. **Port de timers/retry.** `retry.ts` y `timeout.ts` usan timers nativos. Para 1.0 deberia existir `HttpTimerPort` o integracion directa con `Timers`, al menos cuando se crea via `defineEngineHttp()`.
2. **Jitter inyectable.** `computeRetryDelay()` usa `Math.random()` si `policy.jitter` esta activo. Para tests deterministas y produccion controlada, aceptar `random?: () => number`.
3. **Cerrar lifecycle de timeouts.** `attemptTimeoutSignal()` y `totalTimeoutSignal()` crean `setTimeout`; si la request termina antes, el timeout queda pendiente hasta disparar. No siempre es grave, pero en alto volumen deberia poder cancelarse.
4. **Helpers de autenticacion sin acoplar a auth.** La pieza deberia ofrecer patrones genericos para `beforeRequest`/`beforeRetry`/`beforeError` que permitan refresh por 401, pero sin conocer `session` ni `auth`.
5. **Observabilidad de hooks.** Hoy se emiten diagnosticos de retry y errores; para 1.0 conviene medir tiempo por intento, delay real, abort reason y hooks que rescatan respuestas.
### Ampliaciones 1.0
- `http.with({ fetch: event.fetch })` documentado con snippets SSR reales.
- Preset de retry empresarial: idempotentes por defecto, 429/503 con Retry-After, jitter determinista opcional.
- Adapter/cache bridge: convertir `Response` en envelope cacheable con schema.
- Circuit breaker opcional o al menos hooks para implementarlo con `cache/session`.
- Tests de 401 -> refresh -> replay request; 403 -> perm invalidation; offline -> stale cache.
## Roadmap recomendado hacia 1.0
### Sprint 1 - Contrato publico y docs reales
- Corregir aliases y nombres de servicios en READMEs y paginas `active/docs`.
- Documentar factories reales: que inyecta cada una y que debe pasar el desarrollador.
- Crear un test/script de docs que detecte aliases muertos y propiedades antiguas.
- Publicar tabla "core siempre presente vs services declarados".
### Sprint 2 - Determinismo e inyeccion
- Hacer que `defineActiveCache`, `defineActiveSession`, `defineActivePerm` y `defineEngineHttp` puedan consumir `core.timers` cuando aplique.
- Inyectar `random` en retry/session auto-refresh.
- Mantener fallbacks nativos para uso aislado, pero no para el camino App.
### Sprint 3 - Bugs de contrato
- Completar handlers de `auth` para devices/OAuth o retirar esas APIs del cliente hasta estar soportadas.
- Corregir path anidado en compilador SQL de `perm`.
- Revisar OAuth PKCE y persistencia de verifier/state.
- Alinear `defineActivePerm` con `http` real.
### Sprint 4 - Tests compuestos
Escenarios minimos:
1. Login A -> cache privado -> logout -> login B -> B no ve cache/perm de A.
2. Permission webhook -> `perm.invalidate()` -> decision antigua no se reutiliza.
3. HTTP 401 -> refresh sesion -> replay -> cache conserva solo datos validos.
4. Password reset -> revoca sesiones -> active auth anonimo -> cache actor-scope limpia.
5. Logout global -> session revoked -> perm sin actor -> cache limpia -> http protegido falla seguro.
6. Tenant switch -> cache/perm invalidados por tenant.
### Sprint 5 - Server readiness
- `auth` y `perm` ya tienen SQL de referencia; convertirlo en migraciones versionadas o al menos en contract tests ejecutables.
- `cache` necesita historia clara de adapter server: memory solo test/dev, storage/browser, y adapter remoto recomendado.
- `http` no necesita `svrs/http`, pero si necesita ejemplos SSR y edge/runtime.
## Prioridad resumida
| Prioridad | Tema | Modulos | Motivo |
|---|---|---|---|
| P1 | Handlers `auth` incompletos para APIs publicas | auth | Cliente llama endpoints que el handler generico no enruta. |
| P1 | SQL compiler no resuelve paths anidados igual que runtime | perm | Riesgo de decisiones distintas entre filtrado DB y evaluacion memory. |
| P1 | Docs/API antiguas tras rename y service schema | todos | Bloquea adopcion y genera mal uso del framework. |
| P1 | Tests compuestos cross-actor/cross-tenant | auth/session/perm/cache/http | Es donde aparecen fugas reales. |
| P2 | Timers/random no unificados | cache/session/perm/http | Rompe determinismo en tests y trazabilidad. |
| P2 | Persistencia/adapters DB contract-tested | auth/perm/cache | Necesario para apps reales. |
| P2 | Metrics/diagnostics de runtime | cache/http/perm/auth | Necesario para operacion 1.0. |
| P3 | Limpieza micro-reactiva (`SvelteSet` listeners) | lang | Pulido, bajo riesgo. |
## Criterio de cierre para 1.0
Yo no marcaria estos modulos como 1.0 hasta que se cumplan estas condiciones:
- La documentacion publica compila mentalmente y con snippets: ningun alias muerto, ningun servicio inventado.
- El camino `createActiveApp({ services })` inyecta logger, bus, timers y servicios dependientes de forma explicita o documenta que no lo hace.
- `auth`, `session`, `perm`, `cache` y `http` tienen al menos una suite compuesta de identidad completa.
- `auth` no publica rutas/cliente que el server handler no soporte.
- `perm` produce la misma decision en runtime y SQL compiler para paths soportados.
- `http` y auto-refresh son testeables sin timers nativos.
- Los adapters server de `auth` y `perm` tienen contract tests contra el modelo SQL de referencia.
Conclusion: la arquitectura es buena y la base esta verde. Lo que falta para 1.0 es menos glamour y mas cierre contractual: documentacion verdadera, wiring determinista y pruebas de historias completas. Esa es la parte que convierte el ecosistema en plataforma.
---
# Segunda tanda: sium, storage, timer, logger, frontend, format, connection
Fecha: 2026-05-04
Alcance adicional: `sium`, `storage`, `timer`, `logger`, `frontend`, `format`, `connection`.
Finalidad: misma que la primera tanda, buscar refactorizaciones, ampliaciones y criterios de cierre hacia version 1.0.
Verificacion ejecutada:
```txt
npx vitest run src/arts/sium/test src/arts/storage/test src/arts/timer/test src/arts/logger/test src/arts/logger/adapters src/arts/frontend/test src/arts/format/test src/arts/format/currency/test src/arts/format/dates/test src/arts/format/numbers/test src/arts/format/units/test src/arts/connection/test
52 test files passed
686 tests passed
```
## Hallazgos transversales de la segunda tanda
### P1 - Documentacion con aliases y nombres de App obsoletos
La misma deuda aparece con fuerza en esta tanda. El codigo actual usa aliases semanticos:
```txt
$storage, $timer, $logger, $format, $connection
```
Pero varios README siguen usando:
```txt
$stor, $timr, $logr, $fmts, $conn
```
Tambien aparecen ejemplos con `App.Timers`, `App.Format`, `App.Frontend`, `App.Storage`, `App.Sess`, `App.Lang`, `App.createActiveConnections()` y `App.setLocale(...)`. El contrato actual de `createActiveApp({ services })` expone servicios en minuscula (`App.format`, `App.frontend`, `App.storage`, `App.session`, `App.lang`, `App.connections`) y las factories viven en `$active-app/services`.
Esto es P1 de documentacion contractual. Aunque el runtime este verde, una API 1.0 no puede tener docs que ensenan a importar desde aliases que ya no existen.
Accion recomendada:
- Pasada mecanica por README y paginas docs para reemplazar aliases viejos.
- Script de CI que falle si aparecen `$stor`, `$timr`, `$logr`, `$fmts`, `$conn`, `App.Sess`, `App.Storage`, `App.Format`, `App.Frontend`, `App.Timers` en docs publicas salvo en secciones de migracion.
- Tabla unica por modulo: alias real, factory real, propiedad real de `App`.
### P1 - Los servicios no comparten todavia un contrato de lifecycle uniforme
Algunos modulos siguen el contrato `ActiveEngine` o equivalente (`snapshot`, `lastError`, `disposed`, `dispose` idempotente). Otros son utilitarios activos sin `disposed` ni guardas post-dispose.
Casos relevantes:
- `storage` no impide `entry()` ni `clear()` despues de `dispose()`.
- `frontend` mantiene setters operativos tras `dispose()`.
- `format` root llama dispose de submodulos sin flag propio idempotente.
Para 1.0, todo servicio declarado en `createActiveApp({ services })` deberia tener una semantica uniforme:
- `dispose()` idempotente.
- Metodos mutadores despues de dispose: o no-op documentado, o error tipado.
- Snapshot/introspection si el servicio expone estado.
### P2 - El patron `CodeError` esta bien adoptado, pero quedan restos de nomenclatura antigua
`sium`, `storage`, `timer`, `logger` y `connection` ya extienden `CodeError` desde `$libs/errs`, que era una buena direccion. Pero hay comentarios y mensajes que todavia hablan de `stor`, `timr`, `logr` o `conn`. No rompe runtime, pero ensucia la identidad del ecosistema justo ahora que se abandono la regla de 4 letras.
Accion recomendada:
- Mantener `CodeError` como raiz.
- Renombrar comentarios, mensajes y catalogos internos que digan `stor/timr/logr/conn` si el modulo ya se llama `storage/timer/logger/connection`.
- Si `ErrCode` usa seeds de 4 letras por decision historica, documentarlo. Si no, migrarlo antes de 1.0.
## Modulo `sium`
### Estado
`sium` es probablemente el modulo mas completo de esta segunda tanda. Tiene core DSL, Standard Schema, introspection, codecs, lazy, domain types de color/date/time, resolucion de issues, integracion con `lang`, `CodeError` y una suite de tests amplia.
### Refactorizaciones recomendadas
1. **Resolver locale activo, no solo default inicial.** `createEngineSium()` captura `defaultLocale = options.locale ?? lang?.getDefaultLocale() ?? 'es'`. Si se inyecta `ActiveLang` mediante `defineEngineSium` y luego cambia el locale de `App.lang`, `resolveIssue()` sin locale explicito seguira usando el default capturado. Para 1.0, si el `lang` inyectado tiene `getLocale()`, Sium deberia usar el locale actual, o no pasar locale a `lang.t()` para dejar que Lang resuelva su estado actual.
2. **Cerrar la migracion de errores legacy.** `src/arts/sium/errors.ts` mantiene `SIUM_ERRORS` como catalogo legacy para strings que aun no son `CodeError`. La propia nota dice que quedan sitios por migrar. Para 1.0, todos los errores de construccion/encode/decode deberian tener `ErrCode`.
3. **Reducir fragilidad de facade manual.** `EngineSium` lista manualmente decenas de funciones. Hay tests de barrel, pero para 1.0 conviene un snapshot de surface o generacion controlada para evitar que `core` gane funciones que el engine no expone.
4. **Separar issues de errores de programador en docs.** La distincion existe en codigo: `validate` devuelve `Result`, `decode` lanza. La documentacion debe insistir en cuando usar cada una.
### Ampliaciones 1.0
- `setupSium()` o presets de dominio para formularios complejos.
- Bridge oficial con `lang`: `sium.resolveIssue(issue)` siguiendo locale activo.
- Emision opcional de diagnostics por schema path para errores frecuentes.
- Serializacion estable de schema para devtools y documentacion automatica.
- Contract tests con `standard-schema` frente a Zod/Valibot/ArkType adapters.
## Modulo `storage`
### Estado
`storage` esta muy bien planteado: engine sync, active wrapper con `$state`, adapters memory/local/session/cookie/broadcast, envelopes con version/TTL/migration, serializers y tests suficientes. Es una pieza clave para preferencias no secretas y persistencia local.
### Hallazgos y refactorizaciones
1. **P1 - `dispose()` no cierra realmente la superficie publica.** `createEngineStorage()` marca `disposed = true`, pero `entry()`, `clear()` y `entries()` no verifican ese estado. Despues de `dispose()` se puede crear un entry nuevo sobre un bus ya limpiado y registries ya dispuestos. `ActiveStorage.dispose()` hereda el mismo problema porque delega al engine y no guarda flag propio. Para 1.0 debe haber `StorageDisposedError` o no-op documentado.
2. **P2 - TTL usa `Date.now()` sin clock inyectable.** `decodeEnvelope()` y `encodeEnvelope()` aceptan `now`, pero `entry-runtime.ts` llama sin pasar reloj. `EngineStorageOptions` no tiene `clock`. Para tests deterministas y App wiring, conviene `clock?: { now(): number }`, inyectado desde `App.Timers.clock`.
3. **P2 - `dynamicEntry()` depende de `$effect`, pero no hay defensa si se usa fuera de scope.** El comentario lo advierte, pero para 1.0 conviene test y error claro si Svelte lanza fuera de componente/effect root.
4. **P2 - Top-level serializer auto-selection puede sorprender.** Esta documentado: objetos con `Date` anidada caen a JSON y no restauran Date. Para 1.0, anadir recipes de serializer por schema o integracion con Sium.
5. **P2 - Cookies cliente no endurecidas por defecto.** `cookieAdapter()` default `secure: false`, `sameSite: 'lax'`. Es razonable para preferencias no secretas, pero docs deben repetir que no es para secretos y que auth/session cookies no pasan por `storage`.
### Ampliaciones 1.0
- `StorageDisposedError` y guardas post-dispose.
- Clock inyectable desde App.
- Adapter IndexedDB async separado o modulo nuevo, porque el contrato actual es sync.
- Encryption/redaction adapter opt-in para preferencias sensibles, sin prometer seguridad para secretos.
- Schema serializer: `entry('profile', defaults, { schema, serializer: siumSerializer(schema) })`.
- Devtools: entradas vivas, namespace, adapter, defaults conflict, TTL restante.
## Modulo `timer`
### Estado
`timer` esta en buen estado. Es engine puro, clock inyectable, race-safe con version/id/key, abort signal por tarea, active wrapper ligero y tests robustos. Es de las piezas mas solidas del framework.
### Refactorizaciones recomendadas
1. **Actualizar docs antiguas.** README sigue usando `$timr` y `App.Timers`; alias real `$timer`, propiedad real `App.Timers` solo para core si se mantiene capitalizado. En codigo actual `createActiveApp()` si expone core `Timers`, asi aqui la capitalizacion es real, pero el alias no.
2. **Consolidar exports de backoff.** `src/arts/timer/backoff.ts` re-exporta desde `$libs/timer`, y `index.ts` tambien lo expone. No es grave, pero para 1.0 conviene una unica historia: backoff vive en `libs/timer`, `arts/timer` lo reexporta en index por conveniencia.
3. **Exponer random en helpers consumidores.** `computeBackoffDelay()` ya acepta `random`, pero `connection` no lo expone en sus reconnect options. Para 1.0, los consumidores deben poder hacer backoff determinista.
4. **Nombrar mejor `timer` como clock/scheduler del ecosistema.** En docs debe quedar claro que no es una utilidad de UI, sino la fuente temporal para `http`, `session`, `connection`, `cache`, `orca`.
### Ampliaciones 1.0
- Fake clock oficial exportado para tests de ecosistema.
- Metrics: drift, scheduled count, cancelled count, failed count por scope.
- `cancelAll(scope)` documentado como primitive de teardown por modulo.
- Helpers para deadline/timeout con `AbortSignal` para que `http`/`orca` no usen timers nativos.
- Devtools de timers activos por scope.
## Modulo `logger`
### Estado
`logger` es potente: `Logger` comun en `$libs/logger`, `EngineLogger` extiende ese contrato, transports, buffers, batching, failure routing con `deniedFor`, adapters Sentry/Datadog/Logtail/Loki/OTel, vitals y tests grandes. Es un pilar enterprise real.
### Refactorizaciones recomendadas
1. **P1/P2 - Alinear filtro global con la regla de niveles habilitados.** Los transports usan `levels?: LevelConfig`, que permite `{ [LogLevel.WARN]: { enabled: true }, ... }`. Pero el engine global aun usa `state.level` como threshold (`if (lvl < state.level) return`). Si la regla final del framework es "habilitacion por nivel, no threshold tradicional", `LoggerOptions` deberia aceptar `levels` tambien a nivel global, y `level` quedarse como shorthand o deprecated antes de 1.0.
2. **P2 - Reducir dependencia directa de `console` dentro del engine.** `handleFailure()` emite `console.error` ademas de crear synthetic failure entry. En un entorno enterprise puede duplicar salida o saltarse transports. Para 1.0 conviene `onInternalError`, `internalTransport`, o `consoleFallback?: boolean`.
3. **P2 - Inyectar clock/id factory opcional.** IDs fallback usan `Date.now()`/`Math.random()`, failure throttle usa `Date.now()`, timers de buffer usan `setTimeout`. Como Logger se crea antes de `Timers`, no puede depender de `App.Timers`, pero si puede aceptar `clock`, `idFactory` y `setTimeout` opcionales para tests y runtimes especiales.
4. **P2 - `dispose()` de child logger solo advierte en DEV con `console.warn`.** Es correcto como defensa, pero debe estar documentado en API: solo el root owns lifecycle.
### Ampliaciones 1.0
- Global `levels` con shorthand `levelsAtLeast`.
- Redaction pipeline: campos `token`, `password`, `secret`, `authorization`, JWT-like values.
- Correlation helpers: `logger.withTrace(traceId)`, `logger.withActor(actorRef)`.
- Error bridge: si `error instanceof CodeError`, derivar category/module/code automaticamente.
- Async flush result: `flush(): Promise<FlushResult>` para transports remotos.
- Backpressure policy para buffers grandes: drop, block, sample.
## Modulo `frontend`
### Estado
`frontend` es pequeno y util: locale, dir, theme, mode, reduced motion/sound, density y aplicacion DOM via `ActiveDom` o helper de `$libs/dom`. La factory ya integra `dom` y `lang` si existen. Pero esta menos maduro que los demas modulos.
### Refactorizaciones recomendadas
1. **P1 - Docs antiguas tras service schema.** README afirma que `createActiveApp()` construye `Frontend` y usa `App.Lang`/`App.Dom`; ahora `frontend` se declara en `services` y se expone como `App.frontend`.
2. **P2 - Lifecycle incompleto.** Tras `dispose()`, los setters (`setLocale`, `setTheme`, etc.) siguen funcionando y pueden aplicar DOM. Para 1.0 debe haber flag `disposed` y semantica uniforme.
3. **P2 - Persistencia de preferencias quedo fuera.** La factory dice que storage persistence es responsabilidad de la app. Es una buena separacion, pero para 1.0 conviene un preset/helper oficial, porque tema/densidad/dir son caso principal de `storage`.
4. **P2 - Falta snapshot unico.** Hay getters individuales, pero no `snapshot()` con `{ locale, dir, theme, mode, reducedMotion, reducedSound, density }`. Para UI/debug/tests es mucho mas comodo.
5. **P3 - Validacion de valores.** `setDensity`, `setMode`, `setDir` aceptan strings tipados en TS, pero runtime JS podria pasar valores invalidos. Si es API publica, conviene validar o documentar TypeScript-only.
### Ampliaciones 1.0
- `snapshot()` y `onChange(snapshot)`.
- Preset `persistFrontendPreferences(storage)`.
- Eventos de bus opcionales: frontend.preference.changed.
- Media query injector para tests SSR/browser.
- Documentar CSS contract: atributos `data-theme`, `data-mode`, `dir`, density, reduced motion.
## Modulo `format`
### Estado
`format` esta bien organizado: numbers, currency, dates y units como submodulos, engines y active wrappers, locale source comun e integracion con `lang` desde factory. Tests cubren cada subdominio. Es funcional y extensible.
### Refactorizaciones recomendadas
1. **P1 - Docs con `$fmts` y `App.Format`.** Alias real `$format`, servicio real `App.format`. Ademas hay un typo documental: `import { createRates } from '$formats/currency'` cuando el alias real es `$format`.
2. **P2 - `createActiveFormat().dispose()` no tiene guard idempotente propio.** Los submodulos tienen runtime dispose, pero el root deberia seguir la regla general del ecosistema.
3. **P2 - Rates usa `Date.now()` por defecto.** `createRates({ now })` acepta inyeccion, pero `defineActiveFormat` no ofrece wiring con `Timers.clock`. Para 1.0, usar clock de App si se declaran rates con expiracion.
4. **P2 - Cache global de Intl.NumberFormat sin limite.** `engine-currency.ts` mantiene `formatCache` module-global. En apps multi-locale/multi-currency/larga sesion puede crecer indefinidamente. Conviene LRU pequeno o cache por engine con `dispose()`.
5. **P2 - Locale source doble puede duplicar notificaciones.** `createActiveFormat.setLocale()` actualiza localeState y cada submodulo manualmente. Funciona, pero para 1.0 conviene una sola fuente reactiva que notifique y submodulos se sincronicen una vez.
### Ampliaciones 1.0
- Integracion con `lang` interpolation: formatters nombrados para `{{price | currency}}`.
- Formatter registry: `format.register('filesize', fn)`.
- Ranges: date range, number range, relative time, list format, display names.
- Rates provider HTTP/cache bridge con stale-if-error.
- Unit catalog ampliado y aliases por dominio de negocio.
- Tests por locale de alto riesgo: `ar`, `en-US`, `es-AR`, `fr-FR`, `de-DE`.
## Modulo `connection`
### Estado
`connection` mejoro mucho desde el primer audit: ya no es un monolito puro, ahora tiene piezas separadas para acks, heartbeat, reconnect, session wiring, channel registry, transport runtime, sender, serializer y active wrapper. Tambien exige `TimerScheduler`, que es correcto. Aun asi, es el modulo con mas riesgo operacional de esta tanda.
### Hallazgos y refactorizaciones
1. **P1 - `autoReauthOn` existe en tipos pero no se usa.** `EngineConnectionsOptions` declara `autoReauthOn?: ConnectionAutoReauthOn`, pero no aparece en `engine-connections.ts`, `connection.ts` ni presets. Es una opcion publica sin efecto. Para 1.0 hay que implementarla o eliminarla hasta que `orca`/presets la usen.
2. **P1 - README desactualizado.** Usa `$conn`, `App.createActiveConnections()`, `App.Sess`, `App.Bus`, `App.Timers`. El codigo real usa `$connection`, `defineActiveConnections`, `App.connections`, `core.timers`, y el bus no se consume directamente salvo wiring/presets.
3. **P2 - WebSocket coverage es casi inexistente.** `websocket.test.ts` solo valida error cuando WebSocket no existe. Faltan tests de open/message/close/error, binaryType, protocols, URL factory, bufferedAmount y cleanup de listeners.
4. **P2 - `connection.ts` sigue concentrando demasiado wiring.** Aunque bajo de tamano frente al monolito anterior, sigue siendo el composition point de 10 KB con mucho cierre mutable (`disposed`, `intentionalClose`, lifecycle, detachSession, detachBrowserReconnect). Para 1.0 conviene dividir construction runtime en una factory interna que devuelva partes o un `ConnectionRuntimeContext`.
5. **P2 - Reauth concurrente no esta serializada.** `wireConnectionSession()` puede disparar `reauthenticate()` en cambios de sesion sucesivos sin singleflight/cancelacion. Si llega refresh + external changed, pueden salir dos auth frames. Para 1.0, `reauthenticate()` deberia ser singleflight o tener politica.
6. **P2 - Reconnect backoff no expone random determinista.** Usa `computeBackoffDelay()` sin pasar `random`; aunque el helper lo soporta, connection no lo deja configurar.
7. **P2 - Payloads de channels no validan schema.** Tipado TS ayuda en compile-time, pero los frames de red son `unknown`. Para 1.0, una opcion por channel con Sium/StandardSchema reduciria bugs de mensajes malformados.
### Ampliaciones 1.0
- Implementar o retirar `autoReauthOn`.
- `ConnectionContext` para acciones internas: publish diagnostics/events, timers, logger, abort signal.
- Singleflight para connect/reconnect/reauthenticate.
- WebSocket test suite real con mock constructor.
- Channel schemas: `channel('chat', { incoming: { message: schema }, outgoing: { send: schema } })`.
- Backpressure policies mas completas: buffer por topic, drop-oldest/drop-newest, metrics.
- Reconnect policies documentadas: online/visible, queue/drop/replace si hay intento en vuelo.
- Integracion con `orca`: reauth/disconnect como accion opt-in ante identity change, no acoplamiento directo a session/cache/perm.
## Roadmap recomendado para esta segunda tanda
### Sprint A - Docs y naming
- Corregir aliases obsoletos en `sium`, `storage`, `timer`, `logger`, `frontend`, `format`, `connection`.
- Reemplazar ejemplos `App.X` capitalizados por `App.x` servicios, excepto core reales (`App.Logger`, `App.Bus`, `App.Timers`, `App.Orca`) si se mantienen asi.
- Actualizar README de `connection`, `frontend`, `format` y `storage` antes de tocarlos mas: ahora mismo son los que mas pueden confundir.
### Sprint B - Lifecycle uniforme
- `storage`: error/no-op post-dispose.
- `frontend`: flag disposed y snapshot.
- `format`: root dispose idempotente.
- Tests de lifecycle para todos los servicios declarables.
### Sprint C - Determinismo temporal
- `storage`: clock en `EngineStorageOptions`.
- `format`: rates con clock de App.
- `logger`: opciones `clock`, `idFactory`, `timer` o documentar excepcion por ser core bootstrap.
- `connection`: random inyectable para reconnect.
### Sprint D - Riesgos operacionales
- `connection`: implementar/eliminar `autoReauthOn`, singleflight de reauth, WebSocket tests.
- `logger`: global levels por habilitacion si esa es la regla final.
- `sium`: locale activo con Lang.
- `storage`: no operar despues de dispose.
## Prioridad resumida de la segunda tanda
| Prioridad | Tema | Modulos | Motivo |
|---|---|---|---|
| P1 | Aliases/docs obsoletos | sium/storage/timer/logger/frontend/format/connection | API 1.0 no puede ensenar imports inexistentes. |
| P1 | `storage.dispose()` no cierra superficie | storage | Permite crear entradas tras teardown. |
| P1 | `autoReauthOn` sin efecto | connection | Opcion publica enganosa en un modulo critico. |
| P1/P2 | Filtro global por threshold vs habilitacion por nivel | logger | Debe alinearse con la regla final del ecosistema. |
| P2 | Locale activo no seguido por Sium | sium/lang | Validaciones pueden resolver mensajes en locale inicial. |
| P2 | Determinismo de tiempo incompleto | storage/logger/format/connection | Falta clock/random/timer injection en caminos 1.0. |
| P2 | Lifecycle incompleto | frontend/format/storage | Consistencia de servicios declarables. |
| P2 | WebSocket tests escasos | connection | Superficie critica con cobertura baja. |
## Criterio de cierre 1.0 para esta tanda
- Ningun README usa alias viejo ni propiedad App antigua.
- Todo servicio declarable tiene lifecycle post-dispose definido y probado.
- `storage`, `format.rates`, `connection.reconnect` y `logger` tienen historia determinista o excepcion documentada.
- `sium` resuelve issues con el locale activo cuando se integra con `lang`.
- `connection` no expone opciones muertas y tiene tests reales de WebSocket.
- `logger` deja cerrada la decision global: threshold o per-level enable, pero no una mezcla confusa.
- `frontend` tiene snapshot y persistencia oficial opt-in con `storage`.
Conclusion de la segunda tanda: `timer`, `sium`, `logger` y `storage` tienen una base muy fuerte; `format` esta sano pero necesita pulido de cache/locales; `frontend` necesita madurar contrato; `connection` es potente, pero debe cerrar opciones muertas, concurrencia de reauth y cobertura WebSocket antes de poder llamarse 1.0.

@ -0,0 +1,69 @@
# Nexo - demo dating del ecosistema
`Nexo` es una app demo de dating/social matching para probar el ecosistema completo en un producto coherente. La demo no existe para ensenar pantallas bonitas aisladas: existe para forzar integracion real entre modulos, estados, permisos, cache, realtime, formularios, servidor y devtools.
## Objetivo corto
Construir una app de matching segura y privacy-first, con perfiles, onboarding, filtros, matches, chat, bloqueo, reportes, moderacion y panel de diagnostico.
Debe servir para:
- probar que `active-app` compone todos los servicios;
- validar que `uix` puede ser la capa real de componentes;
- usar `sium` para formularios y validacion;
- ejercitar `auth`, `session` y `perm` en flujos reales;
- conectar `http`, `cache`, `storage` y `connection`;
- observar todo con `logger`, `bus`, `timer` y `orca`;
- generar pruebas de producto, integracion y regresion.
## Documentos
- [objetivos.md](objetivos.md): objetivos de producto, ecosistema y validacion.
- [requisitos.md](requisitos.md): requisitos funcionales, no funcionales, roles, permisos y datos.
- [diseno-producto.md](diseno-producto.md): experiencia, pantallas, flujos y componentes esperados.
- [arquitectura-ecosistema.md](arquitectura-ecosistema.md): como participa cada modulo del ecosistema.
- [auth-y-fotos.md](auth-y-fotos.md): paginas de login/registro y subida/gestion de fotos.
- [plan-implementacion.md](plan-implementacion.md): fases de construccion y entregables.
- [matriz-tests.md](matriz-tests.md): pruebas necesarias para cerrar la demo con confianza.
## Principios de la demo
1. Datos ficticios y seed controlado.
2. Usuarios siempre adultos dentro de la demo.
3. No usar rutas `test` o `demo` como libreria publica.
4. No meter logica de negocio dentro de componentes visuales.
5. Todo flujo importante debe dejar traza observable.
6. Toda pantalla debe poder probarse sin depender de servicios externos reales.
7. El servidor de datos vive aislado en `servers/dating`; SvelteKit consume su API.
## Superficie inicial de rutas
- `/dating`: shell principal de la demo.
- `/dating/login`: inicio de sesion.
- `/dating/register`: registro.
- `/dating/reset`: recuperacion de acceso.
- `/dating/mfa`: verificacion MFA simulada.
- `/dating/onboarding`: creacion guiada de perfil.
- `/dating/discover`: descubrimiento y filtros.
- `/dating/matches`: matches y conversaciones.
- `/dating/chat/[matchId]`: chat realtime.
- `/dating/profile`: perfil, fotos, privacidad y preferencias.
- `/dating/profile/photos`: subida, ordenacion y eliminacion de fotos.
- `/dating/safety`: bloqueo, reporte, exportacion y borrado.
- `/dating/admin`: moderacion y decisiones auditadas.
- `/dating/devtools`: inspector de ecosistema para la demo.
## Criterio de exito
La demo esta completa cuando un test puede recorrer este flujo:
1. registrar un usuario;
2. completar onboarding;
3. cambiar idioma, tema y preferencias;
4. descubrir perfiles;
5. hacer like y crear match;
6. enviar mensajes online y offline;
7. bloquear o reportar un perfil;
8. resolver reporte como moderador;
9. comprobar permisos;
10. ver trazas, cache, storage y eventos en devtools.

@ -0,0 +1,390 @@
# Arquitectura de ecosistema para Nexo
## Idea
Nexo debe ser una app de referencia que use el ecosistema como plataforma. La ruta `src/web/routes/dating` contiene pantallas y composicion de demo. La logica reusable debe moverse a modulos publicos.
## Capas
### Rutas
Responsabilidad:
- cargar datos de pagina;
- conectar acciones de usuario;
- renderizar layouts;
- componer componentes especificos de dating.
No deben:
- implementar motores;
- duplicar validacion;
- saltarse `active-app`;
- importar detalles internos de `svrs`.
### uix
Responsabilidad:
- componentes genericos;
- formularios conectados a `sium`;
- componentes de auth/session/perm/cache/logger/devtools;
- accesibilidad y comportamiento visual.
### arts
Responsabilidad:
- motores cliente/runtime;
- servicios activos;
- estado reactivo;
- integracion con `active-app`.
### libs
Responsabilidad:
- tipos compartidos;
- contratos;
- schemas;
- errores;
- helpers puros.
### svrs
Responsabilidad:
- handlers server-side;
- adapters;
- permisos server-side;
- auth/session;
- endpoints de demo.
## ActiveApp de la demo
La demo debe tener un preset canonico:
```ts
createDatingApp({
services: {
logger,
timers,
bus,
orca,
lang,
prefs,
frontend,
storage,
cache,
http,
session,
auth,
perm,
connection,
sium
}
});
```
El tipo resultante debe permitir que los componentes reciban una app tipada, no un `ActiveApp` generico con servicios `unknown`.
## Modulos y responsabilidades
### active-app
- crear `DatingApp`;
- resolver dependencias;
- exponer servicios tipados;
- reportar lifecycle al devtools.
### sium
- schemas de onboarding;
- schemas de perfil;
- schemas de filtros;
- schemas de reporte;
- schemas de decision de moderacion;
- introspeccion para `AutoFields`.
### uix
- renderizar formularios;
- renderizar gates de permisos;
- mostrar inspectores;
- sostener primitives accesibles.
### http
- cliente API demo;
- interceptores de session/auth;
- trace id por request;
- errores normalizados;
- soporte para mock/fixtures.
### cache
- cache de feed;
- cache de perfiles;
- cache de matches;
- invalidacion tras like/pass/report/block;
- exposicion a inspector.
### storage
- draft de onboarding;
- draft de profile editor;
- cola offline de mensajes;
- preferencias locales;
- metadata local de fotos pendientes;
- cache persistente si se habilita.
### prefs
- tema;
- densidad;
- idioma;
- notificaciones;
- preferencias de discover.
### frontend
- tema aplicado;
- density;
- viewport;
- reduced motion;
- direccion LTR/RTL si aplica.
### adom
- focus trap;
- scroll lock;
- portal/layer manager;
- keyboard navigation;
- observers de viewport.
### lang
- mensajes MF2;
- namespaces por pantalla;
- fallback de idioma;
- pseudo-locale para pruebas.
### format
- fechas de mensajes;
- distancia aproximada;
- listas de intereses;
- estado relativo de ultima conexion;
- formatos localizados en admin.
### auth
- registro;
- login;
- MFA simulado;
- recuperacion;
- logout;
- device/session management.
### session
- estado de usuario actual;
- refresh;
- expiracion;
- cross-tab si se habilita;
- session inspector.
### perm
- permisos por rol;
- permisos por estado de usuario;
- bloqueo entre usuarios;
- gates de UI;
- checks server-side.
### connection
- chat realtime simulado;
- presence;
- typing;
- reconnect;
- offline queue.
### bus
- eventos internos:
- `dating.profile.completed`;
- `dating.discover.loaded`;
- `dating.like.sent`;
- `dating.match.created`;
- `dating.message.queued`;
- `dating.message.sent`;
- `dating.report.submitted`;
- `dating.moderation.resolved`.
### logger
- trazas por flujo;
- errores normalizados;
- redaction de datos sensibles;
- audit trail de moderacion.
### timer
- debounce de filtros;
- expiracion de matches;
- retry de mensajes;
- timeout de requests;
- timers visibles en devtools.
### orca
- orquestacion de onboarding finalizado;
- flujo like -> match -> notificacion -> invalidacion cache;
- flujo report -> hide local -> notify moderation -> audit;
- flujo reconnect -> flush offline queue.
### svrs/auth
- endpoints de login/logout/session;
- handlers para register, reset y MFA simulado.
### svrs/perm
- decisiones server-side;
- explicacion de permisos;
- checks para admin/moderacion.
### svrs/cache
- cache server-side si se prueba;
- invalidacion coordinada.
## Servidor demo independiente
Las APIs de Nexo viven fuera de SvelteKit, en `servers/dating`. El cliente Svelte debe consumir este servidor por HTTP, usando cookies con `credentials: "include"`.
Base local por defecto:
```txt
http://127.0.0.1:8787
```
Endpoints:
- `POST /api/auth/register`
- `POST /api/auth/login`
- `POST /api/auth/logout`
- `POST /api/auth/reset`
- `POST /api/auth/mfa/verify`
- `GET /api/session`
- `GET /api/profile/me`
- `PUT /api/profile/me`
- `POST /api/profile/photos`
- `DELETE /api/profile/photos/:filename`
- `PATCH /api/profile/photos/order`
- `PATCH /api/profile/photos/main`
- `GET /api/discover`
- `POST /api/likes`
- `GET /api/matches`
- `GET /api/matches/:id/messages`
- `POST /api/matches/:id/messages`
- `POST /api/safety/block`
- `POST /api/safety/report`
- `GET /api/admin/reports`
- `POST /api/admin/reports/:id/resolve`
- `GET /api/devtools/snapshot`
## Flujos principales
### Onboarding
1. `sium` valida cada paso.
2. `storage` guarda draft.
3. `http` guarda perfil final.
4. `cache` invalida `profile.me`.
5. `bus` emite `dating.profile.completed`.
6. `orca` coordina notificacion y siguiente ruta.
7. `logger` registra trace.
### Login/registro
1. `sium` valida credenciales y confirmaciones.
2. `http` llama a auth.
3. `auth` autentica contra PocketBase o adapter local.
4. `session` guarda estado.
5. `perm` carga rol/estado.
6. `bus` emite `dating.auth.login` o `dating.auth.registered`.
7. `orca` decide redireccion a onboarding, discover o MFA.
8. `logger` traza sin password ni token.
### Fotos de perfil
1. `sium` valida metadata y limites de foto.
2. `perm` valida `profile:photo:add/delete/reorder`.
3. `http` sube archivo con multipart.
4. PocketBase guarda archivos en `dating_profiles.photos`.
5. `cache` invalida `profile.me`, perfil publico y discover.
6. `bus` emite evento de foto.
7. `orca` refresca perfil y feed si procede.
8. `logger` registra tamano/tipo/resultado sin guardar binario.
### Like/match
1. Usuario pulsa like.
2. `perm` valida `match:like`.
3. `http` envia request.
4. `cache` marca perfil como visto.
5. `bus` emite `dating.like.sent`.
6. Si hay match, `orca` dispara flujo de match.
7. `connection` notifica si esta conectado.
### Chat offline
1. Usuario envia mensaje sin conexion.
2. `connection` marca offline.
3. `storage` guarda mensaje con `clientNonce`.
4. UI muestra pending.
5. Al reconectar, `orca` dispara flush.
6. `http` confirma envio.
7. `cache` actualiza thread.
8. `logger` correlaciona intentos.
### Reporte
1. Usuario abre safety menu.
2. `sium` valida report form.
3. `perm` valida `safety:report`.
4. `http` envia reporte.
5. `cache` oculta target localmente.
6. `bus` emite `dating.report.submitted`.
7. `logger` audita sin exponer detalles sensibles.
## Datos de prueba
La demo debe usar seeds:
- usuarios normales;
- usuario limitado;
- moderador;
- admin;
- perfiles con intereses variados;
- matches activos;
- chat con mensajes;
- reportes abiertos y resueltos;
- estados offline/cacheados.
## Contratos publicos a extraer
- `DatingUser`
- `DatingProfile`
- `DatingPreference`
- `DatingLike`
- `DatingMatch`
- `DatingMessage`
- `DatingReport`
- `DatingModerationDecision`
- `DatingPermission`
- `DatingEvent`
Estos contratos deben vivir fuera de la ruta si pasan a ser reutilizables.

@ -0,0 +1,241 @@
# Auth y fotos de perfil
## Objetivo
Definir de forma implementable las paginas de login, registro, recuperacion, MFA simulado y gestion de fotos de perfil. Estos flujos son obligatorios porque prueban `auth`, `session`, `sium`, `http`, `storage`, `cache`, `perm`, `logger`, `bus`, `orca`, `frontend`, `adom`, `lang`, `format` y PocketBase como backend local.
## Rutas de autenticacion
### `/dating/login`
Pantalla dedicada para iniciar sesion.
Campos:
- email;
- password;
- recordarme en este dispositivo;
- idioma;
- tema.
Estados:
- idle;
- submitting;
- invalid credentials;
- account limited;
- session restored;
- mfa required;
- network error;
- offline.
Integraciones:
- `sium`: schema de login.
- `auth`: login.
- `session`: guardar sesion activa.
- `http`: request al endpoint.
- `storage`: preferencia local de dispositivo si aplica.
- `logger`: trace sin password.
- `bus`: evento `dating.auth.login`.
- `orca`: flujo login -> session -> redirect.
### `/dating/register`
Pantalla dedicada para crear cuenta.
Campos:
- email;
- password;
- confirm password;
- nombre visible inicial;
- confirmacion de edad adulta;
- aceptacion de reglas de demo/privacidad local.
Estados:
- idle;
- submitting;
- email already used;
- weak password;
- adult confirmation missing;
- created;
- redirect to onboarding.
Integraciones:
- `sium`: schema de registro y confirmacion de password.
- `auth`: register.
- `session`: iniciar sesion tras registro si procede.
- `perm`: rol inicial `user`.
- `logger`: evento de seguridad redacted.
- `bus`: `dating.auth.registered`.
- `orca`: register -> session -> onboarding draft.
### `/dating/reset`
Recuperacion simulada. La UI no debe revelar si el email existe.
Campos:
- email.
Estados:
- idle;
- submitting;
- sent;
- network error.
### `/dating/mfa`
MFA simulado para probar flujo, aunque la primera version local no tenga MFA real activado.
Campos:
- codigo de 6/8 digitos;
- recuperar acceso;
- confiar en dispositivo si se habilita.
Estados:
- pending;
- invalid code;
- expired code;
- verified;
- locked.
## Componentes de auth
Componentes candidatos a `uix`:
- `AuthLayout`
- `LoginForm`
- `RegisterForm`
- `ResetPasswordForm`
- `MfaChallenge`
- `SessionRestoreGate`
- `AuthError`
Componentes especificos de dating:
- `DatingAuthHeader`
- `DatingPrivacyNotice`
## Fotos de perfil
### Modelo inicial
La demo usa el campo `photos` de `dating_profiles` en PocketBase:
- tipo `file`;
- maximo 6 fotos;
- formatos: JPEG, PNG, WebP;
- tamano maximo: 5 MB por foto;
- thumbnails: `120x120` y `400x600`.
Para v2 avanzada se puede extraer una coleccion `dating_profile_photos` si se necesita moderacion por foto, orden individual persistente, captions o estados por archivo. Para la demo inicial, el campo file multiple es suficiente.
### `/dating/profile/photos`
Pantalla dedicada para gestionar fotos.
Funciones:
- subir fotos por selector;
- drag and drop;
- preview antes de guardar;
- ordenar fotos;
- marcar foto principal;
- eliminar foto;
- reemplazar foto;
- mostrar progreso de subida;
- mostrar errores por archivo;
- guardar cambios;
- cancelar y recuperar estado anterior.
Estados:
- empty;
- local preview;
- uploading;
- uploaded;
- upload failed;
- too many files;
- invalid type;
- file too large;
- reorder pending;
- deleting;
- saved.
### Validaciones
Cliente:
- maximo 6 fotos;
- tipos permitidos;
- tamano maximo;
- no permitir publicar perfil visible sin al menos una foto si esa regla esta activa;
- alt text o descripcion opcional para accesibilidad futura.
Servidor:
- repetir limites de tipo/tamano;
- comprobar que el perfil pertenece al usuario;
- moderador/admin puede ocultar o borrar fotos si se implementa;
- usuario limitado puede tener subida bloqueada segun `perm`.
### Integraciones
- `sium`: schema de metadata de fotos y reglas de perfil.
- `http`: upload multipart al endpoint de perfil.
- `cache`: invalidar `profile.me`, `discover.feed` y perfiles vistos.
- `storage`: guardar previews/draft solo como metadata local, no blobs grandes salvo decision explicita.
- `perm`: `profile:photo:add`, `profile:photo:delete`, `profile:photo:reorder`.
- `logger`: trazas sin incluir contenido binario.
- `bus`: eventos `dating.profile.photo.added`, `dating.profile.photo.removed`, `dating.profile.photo.reordered`.
- `orca`: coordinar upload -> cache invalidation -> profile refresh -> discover refresh.
- `adom`: drag/drop, focus restore, keyboard reordering.
- `frontend`: layout responsive del gestor.
## Endpoints requeridos
Estos endpoints los sirve el servidor independiente `servers/dating`, no SvelteKit:
- `POST /api/auth/register`
- `POST /api/auth/login`
- `POST /api/auth/logout`
- `POST /api/auth/reset`
- `POST /api/auth/mfa/verify`
- `GET /api/session`
- `POST /api/profile/photos`
- `DELETE /api/profile/photos/:filename`
- `PATCH /api/profile/photos/order`
- `PATCH /api/profile/photos/main`
## Reglas de permisos
| Accion | Usuario | Limitado | Moderador | Admin |
| --- | --- | --- | --- | --- |
| `auth:login` | si | si | si | si |
| `auth:logout` | si | si | si | si |
| `profile:photo:add` | si | no | si | si |
| `profile:photo:delete:self` | si | parcial | si | si |
| `profile:photo:moderate` | no | no | si | si |
| `profile:photo:reorder` | si | no | si | si |
## Tests minimos
- login correcto redirige a discover u onboarding.
- login invalido no crea sesion.
- registro crea usuario `dating_users` y redirige a onboarding.
- recuperacion no revela si el email existe.
- MFA invalido muestra error y conserva challenge.
- upload de foto valida actualiza `dating_profiles.photos`.
- upload de tipo invalido falla antes de enviar.
- upload superior a 5 MB falla.
- usuario limitado no puede subir foto.
- eliminar foto invalida cache de perfil/discover.
- reordenar fotos conserva la foto principal.
- devtools muestra eventos/logs sin exponer password ni binarios.

@ -0,0 +1,462 @@
# Diseno de producto de Nexo
## Tono
Nexo debe sentirse como una herramienta social segura, clara y moderna. La demo no debe parecer una landing page ni una coleccion de tarjetas decorativas. Debe ser una app usable desde la primera pantalla.
El foco visual:
- confianza;
- privacidad;
- claridad de estado;
- acciones rapidas;
- buena lectura en movil;
- componentes densos pero limpios para admin/devtools.
## Navegacion principal
### Usuario autenticado
- Discover
- Matches
- Chat
- Profile
- Safety
- Devtools si tiene permiso
### Moderador/Admin
- Moderation
- Audit
- Users
- Devtools
## Pantallas
### Entrada
Objetivo:
- iniciar sesion;
- crear cuenta;
- recuperar acceso;
- cambiar idioma/tema local.
Componentes:
- `AuthPanel`
- `LoginForm`
- `RegisterForm`
- `ThemeToggle`
- `LocaleSelect`
Estados:
- idle;
- submitting;
- invalid credentials;
- session restored;
- mfa required.
### Login
Objetivo:
- autenticar a un usuario y restaurar sesion.
Componentes:
- `AuthLayout`
- `LoginForm`
- `SessionRestoreGate`
- `AuthError`
- `LocaleSelect`
- `ThemeToggle`
Estados:
- submitting;
- invalid credentials;
- account limited;
- mfa required;
- offline;
- redirecting.
### Registro
Objetivo:
- crear una cuenta demo y enviar al onboarding.
Componentes:
- `AuthLayout`
- `RegisterForm`
- `PasswordStrength`
- `AdultConfirmation`
- `PrivacyNotice`
Estados:
- email already used;
- weak password;
- adult confirmation missing;
- created;
- redirecting to onboarding.
### Recuperacion y MFA
Objetivo:
- probar flujos de auth secundarios sin depender de proveedor externo.
Componentes:
- `ResetPasswordForm`
- `MfaChallenge`
- `RecoveryNotice`
Estados:
- reset sent;
- challenge pending;
- invalid code;
- expired code;
- verified.
### Onboarding
Objetivo:
- convertir un usuario registrado en perfil usable.
Estructura:
- paso 1: identidad visible;
- paso 2: bio e intereses;
- paso 3: preferencias de descubrimiento;
- paso 4: privacidad y confirmacion;
- paso 5: preview.
Componentes:
- `Form`
- `AutoFields`
- `FieldGroup`
- `Stepper`
- `ErrorSummary`
- `SubmitBar`
- `DraftStatus`
Estados:
- draft saved;
- pending validation;
- validation failed;
- ready to publish;
- restore draft after reload.
### Discover
Objetivo:
- explorar perfiles y producir likes/passes.
Layout:
- columna/panel de filtros;
- lista o stack de perfiles;
- acciones persistentes;
- estado de conexion/cache visible sin ocupar el centro.
Componentes:
- `ProfileCard`
- `DiscoverFilters`
- `LikeButton`
- `PassButton`
- `CompatibilityBadge`
- `CacheStateBadge`
- `OfflineBanner`
Estados:
- loading first page;
- refreshing;
- stale data;
- no results;
- offline with cached profiles;
- action queued;
- match created.
### Matches
Objetivo:
- ver relaciones activas y entrar en chat.
Componentes:
- `MatchList`
- `MatchCard`
- `UnreadBadge`
- `ExpiryBadge`
- `ArchiveAction`
Estados:
- active;
- pending;
- expired;
- archived;
- blocked.
### Chat
Objetivo:
- probar realtime, offline queue, storage y permisos.
Layout:
- cabecera con estado de match;
- timeline de mensajes;
- composer fijo;
- panel de seguridad contextual.
Componentes:
- `ChatThread`
- `MessageBubble`
- `MessageState`
- `TypingIndicator`
- `PresenceDot`
- `MessageComposer`
- `SafetyMenu`
Estados:
- connected;
- reconnecting;
- offline;
- pending messages;
- blocked;
- match expired;
- send failed;
- retrying.
### Profile
Objetivo:
- editar el perfil y preferencias.
Componentes:
- `ProfileEditor`
- `PhotoManager`
- `PrefsPanel`
- `PrivacySettings`
- `PublicPreview`
Estados:
- unsaved changes;
- saving;
- saved;
- invalid;
- conflict with server;
- profile hidden.
### Profile Photos
Objetivo:
- subir, previsualizar, ordenar, reemplazar y eliminar fotos de perfil.
Componentes:
- `PhotoManager`
- `PhotoDropzone`
- `PhotoGrid`
- `PhotoPreview`
- `UploadProgress`
- `PrimaryPhotoPicker`
- `PhotoActionsMenu`
Estados:
- empty;
- local preview;
- uploading;
- upload failed;
- invalid type;
- file too large;
- too many files;
- reorder pending;
- deleting;
- saved.
### Safety
Objetivo:
- probar bloqueo, reporte, exportacion y borrado.
Componentes:
- `BlockedUsersList`
- `ReportForm`
- `SafetyChecklist`
- `DataExportPanel`
- `DangerZone`
Estados:
- report submitted;
- blocked;
- export prepared;
- deletion scheduled;
- permission denied.
### Moderation
Objetivo:
- probar permisos, decision auditada y flujos server-side.
Componentes:
- `ReportQueue`
- `ReportDetail`
- `ModerationDecisionForm`
- `PermissionExplain`
- `AuditTrail`
Estados:
- report open;
- report assigned;
- resolved;
- conflict: already resolved;
- insufficient permission.
### Devtools
Objetivo:
- mostrar el ecosistema vivo.
Paneles:
- App graph;
- Services;
- Logs;
- Bus events;
- Orca traces;
- Timers;
- Cache;
- Storage;
- Session;
- Perm checks;
- Connection.
Componentes:
- `AppInspector`
- `ServiceGraph`
- `LogViewer`
- `BusTimeline`
- `OrcaTimeline`
- `TimerPanel`
- `CacheInspector`
- `StorageBrowser`
- `SessionInspector`
- `PermissionInspector`
- `ConnectionStatus`
## Componentes publicos esperados en uix
### Foundations
- `Button`
- `IconButton`
- `Input`
- `Textarea`
- `Select`
- `Checkbox`
- `Switch`
- `Slider`
- `SegmentedControl`
- `Tabs`
- `Dialog`
- `Popover`
- `Tooltip`
- `Toast`
- `Menu`
- `Table`
- `Badge`
- `Avatar`
### Domain
- `AuthPanel`
- `SessionMenu`
- `PrefsPanel`
- `Form`
- `AutoFields`
- `Can`
- `Gate`
- `PermissionExplain`
- `CacheInspector`
- `LogViewer`
- `ConnectionStatus`
- `OrcaTimeline`
### Dating-specific
Estos pueden vivir primero bajo `src/web/routes/dating/_components` y promocionarse despues si son reutilizables:
- `ProfileCard`
- `DiscoverFilters`
- `MatchCard`
- `ChatThread`
- `ReportForm`
- `ModerationDecisionForm`
## Accesibilidad
- Navegacion completa por teclado.
- Focus trap en dialogs.
- Focus restore al cerrar overlays.
- Labels asociados en todos los campos.
- Mensajes de error conectados a campos.
- Estados de conexion no comunicados solo por color.
- Contraste suficiente en badges y estados.
- Reduce motion respetado.
## Responsive
### Movil
- Navegacion inferior.
- Discover como stack vertical.
- Filtros en drawer.
- Chat a pantalla completa.
- Devtools con tabs compactas.
### Desktop
- Navegacion lateral.
- Discover con filtros persistentes.
- Chat con lista de matches lateral.
- Admin con tablas densas.
- Devtools con layout de paneles.
## Contenido y copy
- Mensajes localizables mediante MF2.
- Textos de seguridad claros y no alarmistas.
- Errores accionables.
- No usar texto de marketing dentro de pantallas operativas.
- No explicar features obvias dentro de la UI; la interfaz debe ser directa.

@ -0,0 +1,330 @@
# Matriz de tests de Nexo
## Objetivo
La demo debe poder demostrar el ecosistema con pruebas automatizadas. No basta con que las pantallas rendericen: hay que probar flujos completos, estados degradados, permisos e integraciones.
## Niveles de prueba
### Unit
Para:
- schemas `sium`;
- formatters;
- permission rules;
- reducers/helpers puros;
- adapters fake;
- contratos de errores.
### Component
Para:
- formularios;
- gates de permisos;
- profile cards;
- chat thread;
- report form;
- devtools panels.
### Integration
Para:
- active-app con servicios;
- http + cache;
- storage + drafts;
- session + auth;
- perm + routes;
- bus + orca;
- connection + offline queue.
### E2E
Para:
- registro a match;
- login/registro/reset/MFA;
- subida y gestion de fotos;
- chat online/offline;
- reporte y moderacion;
- expiracion de sesion;
- cambio de preferencias;
- devtools visible.
## Fixtures obligatorias
- visitante;
- usuario sin onboarding;
- usuario con perfil completo;
- usuario limitado;
- usuario bloqueado;
- moderador;
- admin;
- perfiles compatibles;
- perfiles no compatibles;
- match activo;
- match expirado;
- thread con mensajes;
- reporte abierto;
- reporte resuelto;
- cache stale;
- storage con draft;
- connection offline.
## Casos E2E principales
### E2E-01 Registro y onboarding
Pasos:
1. visitar `/dating`;
2. registrar usuario;
3. completar onboarding valido;
4. publicar perfil;
5. entrar en discover.
Verificaciones:
- session activa;
- profile completo;
- draft eliminado o marcado completo;
- evento `dating.profile.completed`;
- log correlacionado;
- permisos actualizados.
### E2E-02 Onboarding invalido
Pasos:
1. iniciar onboarding;
2. dejar campos invalidos;
3. intentar continuar.
Verificaciones:
- `sium` devuelve issues;
- UI marca campos;
- `ErrorSummary` se actualiza;
- no se llama a save final;
- draft parcial se conserva.
### E2E-03 Login y registro
Pasos:
1. abrir `/dating/register`;
2. crear usuario valido;
3. comprobar redireccion a onboarding;
4. cerrar sesion;
5. abrir `/dating/login`;
6. iniciar sesion.
Verificaciones:
- se crea registro en `dating_users`;
- `session` queda activa;
- `perm` carga rol `user`;
- password no aparece en logs/devtools;
- login invalido no crea sesion.
### E2E-04 Fotos de perfil
Pasos:
1. abrir `/dating/profile/photos`;
2. subir foto valida;
3. marcarla como principal;
4. reordenar;
5. eliminar una foto.
Verificaciones:
- PocketBase actualiza `dating_profiles.photos`;
- `cache` invalida `profile.me` y discover;
- `bus` emite evento de foto;
- `logger` no guarda binarios;
- usuario limitado no puede subir;
- tipo invalido y tamano excesivo fallan.
### E2E-05 Discover con filtros
Pasos:
1. abrir discover;
2. cambiar filtros;
3. esperar debounce;
4. recibir perfiles.
Verificaciones:
- `timer` registra debounce;
- `http` recibe query correcta;
- `cache` guarda resultado;
- empty state funciona si no hay resultados.
### E2E-06 Like crea match
Pasos:
1. abrir perfil compatible;
2. pulsar like;
3. servidor devuelve match.
Verificaciones:
- `perm` permite `match:like`;
- `cache` invalida feed;
- `bus` emite `dating.like.sent`;
- `orca` ejecuta flujo de match;
- match aparece en `/dating/matches`.
### E2E-07 Chat online
Pasos:
1. abrir match;
2. enviar mensaje;
3. recibir confirmacion.
Verificaciones:
- mensaje pasa de pending a sent;
- trace de `http`/`connection`;
- cache del thread actualizada;
- timestamp formateado.
### E2E-08 Chat offline y retry
Pasos:
1. simular offline;
2. enviar mensaje;
3. recargar;
4. simular reconnect.
Verificaciones:
- mensaje queda en `storage`;
- UI muestra pending;
- `connection` cambia a reconnecting/connected;
- `orca` dispara flush;
- mensaje acaba sent;
- no se duplica por `clientNonce`.
### E2E-09 Bloqueo
Pasos:
1. abrir perfil o chat;
2. bloquear usuario.
Verificaciones:
- target desaparece de discover/matches;
- chat queda cerrado;
- `perm` deniega `chat:send`;
- cache se invalida;
- audit/log creado.
### E2E-10 Reporte
Pasos:
1. abrir safety menu;
2. rellenar reporte;
3. enviar.
Verificaciones:
- `sium` valida;
- `http` crea reporte;
- target se oculta localmente;
- bus emite `dating.report.submitted`;
- reporte aparece en moderacion para rol correcto.
### E2E-11 Moderacion
Pasos:
1. login como moderador;
2. abrir reporte;
3. resolver con restriccion.
Verificaciones:
- permisos correctos;
- decision queda auditada;
- usuario objetivo queda limitado;
- acciones prohibidas fallan server-side;
- UI muestra explanation.
### E2E-12 Admin y devtools
Pasos:
1. login admin;
2. abrir `/dating/devtools`;
3. inspeccionar paneles.
Verificaciones:
- servicios activos visibles;
- logs visibles;
- bus timeline visible;
- cache/storage/session/perm visibles;
- no se muestran secretos.
## Casos de permisos
- Visitante intenta abrir discover: redirect/login.
- Usuario limitado intenta like: denegado.
- Usuario bloqueado intenta chat: denegado.
- Moderador intenta cambiar roles admin: denegado.
- Admin resuelve reporte: permitido.
- Usuario normal abre devtools completo: denegado o vista limitada.
## Casos de cache/storage
- Feed cargado dos veces usa cache.
- Like invalida perfil correspondiente.
- Cambio de filtros crea nueva key.
- Draft de onboarding sobrevive reload.
- Draft se limpia al publicar.
- Fotos subidas invalidan perfil y discover.
- Foto rechazada no queda en cache ni storage.
- Offline message sobrevive reload.
- Cache stale se muestra como stale, no como fresh.
## Casos de observabilidad
- Cada flujo E2E tiene `traceId`.
- Logs redacted no incluyen password ni tokens.
- Bus timeline registra eventos relevantes.
- Orca trace muestra pasos y errores.
- Timer panel muestra debounce/retry/expiry.
- Connection panel muestra offline/reconnect.
## Casos de accesibilidad
- Login completo por teclado.
- Onboarding mueve foco al primer error.
- Dialog de reporte atrapa foco.
- Al cerrar dialog vuelve foco al boton origen.
- Chat anuncia mensaje pending/sent sin depender solo de color.
- Filtros son operables con teclado.
## Gates de cierre
Para declarar la demo lista:
- unit tests verdes;
- component tests verdes;
- integration tests verdes;
- E2E principal verde;
- `npm run check` verde;
- `npm run build` verde;
- no imports desde `/test` o `/demo`;
- todos los permisos criticos probados en cliente y servidor;
- devtools muestra datos reales de los servicios.

@ -0,0 +1,71 @@
# Objetivos de Nexo
## Objetivo principal
Crear una demo de dating/social matching que use todos los modulos importantes del ecosistema y permita validar que la plataforma puede sostener una aplicacion real, no solo ejemplos aislados.
La app debe demostrar composicion, UI, formularios, seguridad, permisos, datos, realtime, cache, persistencia, preferencias, internacionalizacion, observabilidad y pruebas.
## Objetivos de producto
- Permitir que un usuario cree una cuenta y complete un perfil.
- Permitir descubrir perfiles compatibles mediante filtros.
- Permitir likes, passes, matches y conversaciones.
- Permitir gestionar privacidad, bloqueo, reporte y borrado.
- Permitir moderar reportes desde un panel protegido.
- Permitir operar la app con estados degradados: offline, sesion expirada, permisos insuficientes, datos stale y errores de red.
## Objetivos de ecosistema
- Validar `active-app` como compositor de servicios real.
- Convertir `uix` en una capa de componentes reusable, no acoplada a rutas demo.
- Usar `sium` como motor de schemas, validacion e introspeccion de formularios.
- Usar `http` como cliente tipado y observable.
- Integrar `cache` con `http`, `storage`, `bus` y estados de UI.
- Usar `storage` para drafts, preferencias locales, cola offline y cache persistente.
- Usar `auth`, `session` y `perm` para flujos de identidad y autorizacion.
- Usar `connection` para chat, presencia y reconexion.
- Usar `logger`, `bus`, `timer` y `orca` para trazar y coordinar flujos.
- Usar `lang` con MessageFormat 2.0 / MF2 como contrato moderno de mensajes.
- Usar `format` para fechas, distancia aproximada, listas, unidades y estados localizados.
## Objetivos tecnicos
- Definir una `DatingApp` canonica con servicios tipados.
- Crear fixtures y seeds repetibles.
- Evitar dependencias externas obligatorias para el happy path local.
- Separar cliente, servidor, contratos y UI.
- Evitar que `src/web/routes/dating` se convierta en libreria interna: la logica reusable debe ir a `src/uix`, `src/arts`, `src/libs` o `src/svrs`.
- Cada flujo critico debe tener prueba automatizada.
- Cada error esperado debe tener estado visual y evento de diagnostico.
## Objetivos de validacion
La demo debe permitir probar:
- composicion de servicios;
- renderizado de componentes;
- formularios generados;
- permisos y gates;
- sesion y expiracion;
- cache hit/miss/stale;
- storage persistente y drafts;
- offline queue;
- reconnect de chat;
- logs correlacionados;
- eventos de bus;
- tareas orquestadas por `orca`;
- timers de debounce, retry y expiracion.
## No objetivos
- No construir una app de dating comercial completa.
- No usar datos reales de usuarios.
- No depender de pagos reales, mapas reales ni proveedores externos obligatorios.
- No crear features sociales no necesarias para probar el ecosistema.
- No meter reglas de negocio en componentes visuales.
- No duplicar motores ya existentes si el modulo del ecosistema puede cubrirlo.
## Resultado esperado
Al terminar, `Nexo` debe funcionar como demo de referencia para la version 2.0: una app suficientemente compleja para romper las integraciones flojas, pero acotada para poder testearse y mantenerse.

@ -0,0 +1,233 @@
# Plan de implementacion de Nexo
## Fase 0 - Preparacion
Objetivo: dejar una base que no pelee con la 1.0.
Tareas:
- Corregir gates actuales de `check` y `build` antes de ampliar superficie.
- Definir `DatingApp` tipada.
- Crear seeds deterministas.
- Crear contratos minimos de datos.
- Definir rutas y layout base.
- Crear test helpers para reset de demo.
Entregables:
- `src/web/routes/dating/+layout.svelte`
- `src/web/routes/dating/+page.svelte`
- contratos iniciales;
- seed local;
- fixture reset.
## Fase 1 - UI shell y primitives
Objetivo: montar la app navegable sin logica compleja.
Tareas:
- Crear shell responsive.
- Crear navegacion usuario/admin.
- Crear estados comunes: loading, empty, error, offline, permission denied.
- Crear componentes dating-specific temporales.
- Identificar primitives que deben moverse a `src/uix`.
Entregables:
- shell principal;
- rutas vacias navegables;
- componentes base;
- primer smoke test visual.
## Fase 2 - Auth, session y perfil
Objetivo: tener usuario autenticado y perfil editable.
Tareas:
- Paginas `/dating/login`, `/dating/register`, `/dating/reset` y `/dating/mfa`.
- Login/register local.
- Session restore.
- Onboarding con `sium`.
- Drafts con `storage`.
- Profile editor.
- Subida y gestion de fotos de perfil con PocketBase.
- Preferences basicas.
- Guards de permisos.
Entregables:
- flujo registro -> onboarding -> profile;
- flujo login/logout/reset/MFA simulado;
- upload/reorder/delete de fotos;
- tests de validacion;
- tests de sesion;
- estados de error.
## Fase 3 - Discover y matching
Objetivo: probar producto central.
Tareas:
- Feed de perfiles seeded.
- Filtros con debounce.
- Cache de discover.
- Like/pass.
- Creacion de match.
- Invalidacion de cache.
- Eventos de bus.
- Orquestacion con `orca`.
Entregables:
- discover usable;
- matches creados;
- tests like/pass/match;
- timeline visible en devtools.
## Fase 4 - Chat y connection
Objetivo: probar realtime y offline.
Tareas:
- Thread de chat.
- Composer.
- Presence/typing simulado.
- Offline queue.
- Retry al reconectar.
- Estados de mensaje.
- Bloqueo corta conversacion.
Entregables:
- chat funcional;
- pruebas online/offline;
- inspector de connection/storage.
## Fase 5 - Safety y moderacion
Objetivo: probar permisos complejos y server-side.
Tareas:
- Bloqueo.
- Reporte con `sium`.
- Bandeja de moderacion.
- Decision auditada.
- Restricciones de usuario.
- Permission explain.
- Audit trail.
Entregables:
- safety center;
- moderation admin;
- tests de roles/permisos;
- logs auditables.
## Fase 6 - Devtools de ecosistema
Objetivo: hacer visible que todos los modulos estan vivos.
Tareas:
- App/service inspector.
- Log viewer.
- Bus timeline.
- Orca timeline.
- Timer panel.
- Cache inspector.
- Storage browser.
- Session inspector.
- Permission inspector.
- Connection panel.
Entregables:
- `/dating/devtools`;
- snapshots de estado;
- pruebas de diagnostico basicas.
## Fase 7 - Pulido y promocion a uix
Objetivo: separar lo reusable de lo especifico.
Tareas:
- Mover primitives genericas a `src/uix`.
- Mover componentes de ecosistema a `src/uix`.
- Dejar componentes dating-specific en la ruta.
- Documentar APIs publicas.
- Crear ejemplos minimos por componente.
Entregables:
- `uix` con primera superficie real;
- dating consumiendo `uix`;
- demos limpias;
- tests de componentes.
## Orden recomendado de implementacion
1. `DatingApp` tipada y seeds.
2. Shell de rutas.
3. Onboarding con `sium`.
4. Auth/session fake-local.
5. Discover.
6. Matching.
7. Chat.
8. Safety.
9. Moderacion.
10. Devtools.
11. Promocion a `uix`.
## Riesgos
### Riesgo: la demo tapa problemas de 1.0
Mitigacion:
- No empezar features grandes si `check` y `build` siguen rotos.
- Mantener PRs/fases pequenas.
### Riesgo: rutas se convierten en libreria
Mitigacion:
- Todo componente reusable se promociona a `uix`.
- Todo contrato reusable se mueve a `libs`.
### Riesgo: permisos solo en UI
Mitigacion:
- Duplicar checks en server.
- Testear acciones prohibidas.
### Riesgo: realtime falso demasiado simple
Mitigacion:
- Simular offline, reconnect, retry y out-of-order.
- Usar `connection` y no solo stores locales.
### Riesgo: observabilidad decorativa
Mitigacion:
- Cada flujo critico debe emitir logs/eventos/traces reales.
- Devtools lee estado real, no fixtures.
## Gates por fase
Cada fase debe cerrar con:
- `npm run check`;
- tests unitarios relevantes;
- al menos un smoke test de ruta;
- estados visuales de error;
- sin imports desde rutas `test` o `demo`;
- sin componentes reusables escondidos en `dating` si ya pertenecen a `uix`.

@ -0,0 +1,310 @@
# Requisitos de Nexo
## Roles
### Visitante
Usuario no autenticado.
Puede:
- ver la pantalla de entrada;
- registrarse;
- iniciar sesion;
- recuperar acceso;
- cambiar idioma/tema local antes de autenticar.
No puede:
- ver perfiles;
- enviar likes;
- abrir chats;
- acceder a safety center autenticado;
- acceder a moderacion.
### Usuario
Usuario autenticado con perfil activo.
Puede:
- completar onboarding;
- editar perfil;
- configurar preferencias de descubrimiento;
- ver perfiles compatibles;
- hacer like/pass;
- chatear con matches;
- bloquear y reportar perfiles;
- gestionar sesion y privacidad.
### Usuario limitado
Usuario autenticado con restriccion temporal por moderacion.
Puede:
- ver su perfil;
- gestionar seguridad;
- leer decisiones de moderacion;
- apelar si se implementa el flujo.
No puede:
- enviar likes;
- iniciar conversaciones nuevas;
- aparecer en discover;
- editar campos sensibles si la sancion lo impide.
### Moderador
Usuario con permisos de moderacion.
Puede:
- ver reportes;
- revisar contexto limitado;
- ocultar perfiles;
- imponer restricciones;
- resolver reportes;
- dejar notas auditadas.
### Admin
Usuario con permisos de administracion.
Puede:
- gestionar roles;
- revisar auditoria;
- cambiar flags de la demo;
- resetear seeds locales;
- abrir devtools completos.
## Requisitos funcionales
### Autenticacion
- Pagina `/dating/login` con email, password, recordarme, errores y redireccion.
- Pagina `/dating/register` con email, password, confirmacion, nombre visible inicial y confirmacion de edad adulta.
- Pagina `/dating/reset` para recuperacion simulada sin revelar si el email existe.
- Pagina `/dating/mfa` para challenge MFA simulado.
- Logout desde menu de sesion.
- Sesion persistente.
- Expiracion de sesion y refresh.
- Lista de dispositivos/sesiones activas.
### Onboarding
- Flujo por pasos.
- Validacion por schema `sium`.
- Draft persistente en `storage`.
- Progreso recuperable tras reload.
- Campos minimos:
- nombre visible;
- edad adulta validada;
- bio;
- intereses;
- intencion;
- ubicacion aproximada;
- preferencias de descubrimiento;
- visibilidad del perfil.
### Perfil
- Editar datos publicos.
- Subir fotos de perfil a PocketBase desde `/dating/profile/photos`.
- Gestionar hasta 6 fotos por perfil.
- Reordenar fotos y marcar foto principal.
- Eliminar o reemplazar fotos.
- Validar tipo, tamano y permisos antes de subir.
- Configurar privacidad.
- Configurar idioma, tema, densidad y notificaciones.
- Ver preview publico del perfil.
- Validar cambios antes de guardar.
### Discover
- Mostrar perfiles compatibles.
- Filtros por rango de edad, distancia aproximada, intereses e intencion.
- Acciones like/pass.
- Estados empty/loading/error/offline.
- Cache de feed.
- Invalidacion tras like/pass.
- Paginacion o carga incremental.
### Matching
- Crear match cuando hay like mutuo.
- Mostrar matches activos, pending, archivados y expirados.
- Expirar matches pendientes mediante `timer`.
- Emitir eventos de match por `bus`.
- Orquestar notificacion y cache invalidation con `orca`.
### Chat
- Chat por match.
- Envio online.
- Envio offline con cola local.
- Retry al reconectar.
- Estado de mensaje: pending, sent, delivered, failed.
- Typing indicator simulado.
- Presence simulada.
- Bloqueo corta envio/recepcion.
### Safety
- Bloquear usuario.
- Reportar perfil o mensaje.
- Seleccionar motivo.
- Adjuntar contexto fake.
- Confirmacion clara.
- Ocultar perfil reportado/bloqueado.
- Exportacion simulada de datos.
- Borrado de cuenta simulado.
### Moderacion
- Bandeja de reportes.
- Filtros por estado, motivo y prioridad.
- Vista de detalle con contexto minimo.
- Acciones: dismiss, warn, restrict, hide profile, ban demo user.
- Notas auditadas.
- Decision visible en audit log.
- Permisos estrictos con `perm`.
### Devtools internos
- Estado de `active-app`.
- Servicios activos/lazy/disposed.
- Eventos de `bus`.
- Logs de `logger`.
- Cache entries.
- Storage namespaces.
- Session state.
- Perm checks recientes.
- Connection state.
- Orca traces.
- Timers activos.
## Requisitos no funcionales
- La demo debe funcionar en local sin servicios externos reales.
- Debe tener seed determinista.
- Debe ser testeable con datos estables.
- Debe responder bien en desktop y movil.
- Debe tener estados accesibles para teclado y lector de pantalla.
- Debe evitar datos sensibles reales.
- Debe poder resetearse entre tests.
- Debe evitar coupling entre rutas y componentes publicos.
- Debe trazar errores sin exponer secretos.
## Modelo de datos minimo
### User
- `id`
- `email`
- `role`
- `status`
- `createdAt`
- `lastLoginAt`
### Profile
- `id`
- `userId`
- `displayName`
- `age`
- `bio`
- `interests`
- `intent`
- `approxLocation`
- `visibility`
- `photos`
- `primaryPhoto`
### Preference
- `userId`
- `ageRange`
- `distanceKm`
- `intent`
- `interests`
- `theme`
- `density`
- `locale`
- `notifications`
### Like
- `fromUserId`
- `toUserId`
- `state`
- `createdAt`
### Match
- `id`
- `userIds`
- `state`
- `createdAt`
- `expiresAt`
### Message
- `id`
- `matchId`
- `senderId`
- `body`
- `state`
- `createdAt`
- `clientNonce`
### Report
- `id`
- `reporterId`
- `targetUserId`
- `targetMessageId`
- `reason`
- `details`
- `state`
- `priority`
- `createdAt`
- `resolvedAt`
- `resolverId`
## Matriz de permisos inicial
| Accion | Visitante | Usuario | Limitado | Moderador | Admin |
| --- | --- | --- | --- | --- | --- |
| `profile:create` | no | si | si | si | si |
| `profile:update:self` | no | si | parcial | si | si |
| `profile:photo:add` | no | si | no | si | si |
| `profile:photo:delete:self` | no | si | parcial | si | si |
| `profile:photo:reorder` | no | si | no | si | si |
| `profile:photo:moderate` | no | no | no | si | si |
| `discover:view` | no | si | no | si | si |
| `match:like` | no | si | no | no | si |
| `chat:send` | no | si | no | no | si |
| `safety:block` | no | si | si | si | si |
| `safety:report` | no | si | si | si | si |
| `moderation:view` | no | no | no | si | si |
| `moderation:resolve` | no | no | no | si | si |
| `admin:roles` | no | no | no | no | si |
| `devtools:view` | no | limitado | limitado | si | si |
## Estados de error obligatorios
- Sesion expirada.
- Usuario sin permiso.
- Perfil incompleto.
- Feed sin resultados.
- Cache stale.
- Red offline.
- Reconnect en curso.
- Mensaje pendiente.
- Envio fallido.
- Reporte ya resuelto.
- Usuario bloqueado.
- Servicio lazy fallido.

@ -0,0 +1,65 @@
# Nexo dating server
Servidor independiente para la demo `Nexo`. No vive dentro de SvelteKit y no usa `+server.ts`; expone una API HTTP propia para que el cliente la consuma despues.
## Arranque
Desde la raiz del repo:
```bash
npm run dating:server
```
Por defecto escucha en:
```txt
http://127.0.0.1:8787
```
PocketBase se configura con las variables locales ya guardadas en `.env.local`:
- `POCKETBASE_URL`
- `POCKETBASE_SUPERUSER_EMAIL`
- `POCKETBASE_SUPERUSER_PASSWORD`
Variables opcionales:
- `DATING_SERVER_HOST`
- `DATING_SERVER_PORT`
- `DATING_ALLOWED_ORIGINS`
- `DATING_SESSION_COOKIE`
- `DATING_SECURE_COOKIES`
## Endpoints
- `GET /health`
- `POST /api/auth/register`
- `POST /api/auth/login`
- `POST /api/auth/logout`
- `POST /api/auth/reset`
- `POST /api/auth/mfa/verify`
- `GET /api/session`
- `GET /api/profile/me`
- `PUT /api/profile/me`
- `POST /api/profile/photos`
- `DELETE /api/profile/photos/:filename`
- `PATCH /api/profile/photos/order`
- `PATCH /api/profile/photos/main`
- `GET /api/discover`
- `POST /api/likes`
- `GET /api/matches`
- `GET /api/matches/:matchId/messages`
- `POST /api/matches/:matchId/messages`
- `POST /api/safety/block`
- `POST /api/safety/report`
- `GET /api/admin/reports`
- `POST /api/admin/reports/:id/resolve`
- `GET /api/devtools/snapshot`
## Sesion
El servidor usa una cookie HTTP-only llamada `dating_session`. Tambien acepta `Authorization: Bearer <token>` para pruebas locales, aunque el cliente deberia usar cookies con `credentials: "include"`.
## Fotos
Las fotos se guardan en PocketBase, campo `dating_profiles.photos`, usando el filesystem local de PocketBase (`pb_data/storage`). El servidor valida tipo, tamano y permisos antes de enviar el archivo a PocketBase.

@ -0,0 +1,340 @@
import {
bool,
clearSessionCookie,
fail,
getSessionToken,
int,
ok,
optionalText,
requireString,
sessionCookie,
stringArray,
text
} from './http.mjs';
import {
createRecord,
fileUrl,
firstRecord,
getRecord,
listRecords,
pbPublic,
pbUser,
updateRecord,
updateRecordForm
} from './pocketbase.mjs';
export const ROLES = ['user', 'limited', 'moderator', 'admin'];
export const STATUSES = ['active', 'limited', 'blocked', 'deleted'];
export const INTENTS = ['dating', 'friends', 'long_term', 'casual', 'unsure'];
export const VISIBILITIES = ['visible', 'hidden', 'paused'];
export const LIKE_STATES = ['like', 'pass'];
export const REPORT_REASONS = ['fake', 'abuse', 'spam', 'harassment', 'underage', 'other'];
export const REPORT_PRIORITIES = ['low', 'normal', 'high', 'urgent'];
export const MODERATION_ACTIONS = ['dismiss', 'warn', 'restrict', 'hide_profile', 'ban_demo_user'];
export async function authenticate(identity, password) {
return pbPublic('/api/collections/dating_users/auth-with-password', {
method: 'POST',
body: { identity, password }
});
}
export async function registerUser(input) {
const email = requireString(input.email, 'email', 200).toLowerCase();
const password = requireString(input.password, 'password');
const passwordConfirm = text(input.passwordConfirm || input.confirmPassword || input.password);
const displayName = requireString(input.displayName, 'displayName', 80);
if (password.length < 8) fail(400, 'weak_password', 'Password must contain at least 8 characters.');
if (password !== passwordConfirm) fail(400, 'password_mismatch', 'Passwords do not match.');
if (!bool(input.adultConfirmed) && !bool(input.adultVerified)) {
fail(400, 'adult_confirmation_required', 'Adult confirmation is required.');
}
await createRecord('dating_users', {
email,
password,
passwordConfirm,
displayName,
role: 'user',
status: 'active',
adultVerified: true,
emailVisibility: false,
verified: true
});
return authenticate(email, password);
}
export async function currentSession(request) {
const token = getSessionToken(request);
if (!token) return null;
try {
const auth = await pbUser('/api/collections/dating_users/auth-refresh', token, {
method: 'POST'
});
return {
token: auth.token,
user: userView(auth.record),
cookie: sessionCookie(auth.token)
};
} catch {
return {
expired: true,
cookie: clearSessionCookie()
};
}
}
export async function requireSession(request) {
const session = await currentSession(request);
if (!session || session.expired) fail(401, 'session_required', 'Authentication required.');
if (session.user.status === 'deleted') fail(403, 'account_deleted', 'Account is deleted.');
return session;
}
export function withSession(response, session) {
if (session?.cookie) {
return { ...response, cookies: [...(response.cookies || []), session.cookie] };
}
return response;
}
export function userView(record) {
return {
id: record.id,
email: record.email,
displayName: record.displayName || record.name || '',
role: ROLES.includes(record.role) ? record.role : 'user',
status: STATUSES.includes(record.status) ? record.status : 'active',
adultVerified: Boolean(record.adultVerified),
verified: Boolean(record.verified),
created: record.created,
updated: record.updated,
lastLoginAt: record.lastLoginAt || ''
};
}
export function profileView(record) {
if (!record) return null;
const photos = Array.isArray(record.photos) ? record.photos : record.photos ? [record.photos] : [];
return {
id: record.id,
userId: relationId(record.user),
displayName: record.displayName || '',
age: Number(record.age || 0),
bio: record.bio || '',
interests: Array.isArray(record.interests) ? record.interests : [],
intent: record.intent || 'unsure',
approxLocation: record.approxLocation || '',
visibility: record.visibility || 'hidden',
photos,
primaryPhoto: record.primaryPhoto || photos[0] || '',
photoUrls: photos.map((filename) => ({
filename,
original: fileUrl('dating_profiles', record.id, filename),
thumb: fileUrl('dating_profiles', record.id, filename, '120x120'),
card: fileUrl('dating_profiles', record.id, filename, '400x600')
})),
completed: Boolean(record.completed),
publishedAt: record.publishedAt || '',
created: record.created,
updated: record.updated
};
}
export function matchView(record, profiles = []) {
return {
id: record.id,
userIds: relationIds(record.users),
state: record.state || 'active',
expiresAt: record.expiresAt || '',
metadata: record.metadata || {},
profiles,
created: record.created,
updated: record.updated
};
}
export function messageView(record) {
return {
id: record.id,
matchId: relationId(record.match),
senderId: relationId(record.sender),
body: record.body || '',
state: record.state || 'sent',
clientNonce: record.clientNonce || '',
deliveredAt: record.deliveredAt || '',
metadata: record.metadata || {},
created: record.created,
updated: record.updated
};
}
export function reportView(record) {
return {
id: record.id,
reporterId: relationId(record.reporter),
targetUserId: relationId(record.targetUser),
targetMessageId: relationId(record.targetMessage),
reason: record.reason,
details: record.details || '',
state: record.state || 'open',
priority: record.priority || 'normal',
resolvedAt: record.resolvedAt || '',
resolverId: relationId(record.resolver),
created: record.created,
updated: record.updated
};
}
export async function getProfileByUser(userId) {
return firstRecord('dating_profiles', `user = "${escapeFilter(userId)}"`);
}
export async function requireProfile(userId) {
const profile = await getProfileByUser(userId);
if (!profile) fail(409, 'profile_required', 'Profile must exist before this operation.');
return profile;
}
export async function upsertProfile(user, input) {
checkPermission(user, 'profile:update:self');
const existing = await getProfileByUser(user.id);
const payload = profilePayload(input, user, existing);
if (existing) return updateRecord('dating_profiles', existing.id, payload);
return createRecord('dating_profiles', { ...payload, user: user.id });
}
export function profilePayload(input, user, existing) {
const displayName = optionalText(input.displayName, 80) || existing?.displayName || user.displayName;
if (!displayName) fail(400, 'missing_display_name', 'displayName is required.');
const age = int(input.age ?? existing?.age, 0, { min: 18, max: 120 });
if (!age) fail(400, 'invalid_age', 'age must be an integer between 18 and 120.');
const intent = enumValue(input.intent || existing?.intent || 'unsure', INTENTS, 'intent');
const visibility = enumValue(input.visibility || existing?.visibility || 'hidden', VISIBILITIES, 'visibility');
const completed = bool(input.completed, Boolean(existing?.completed));
return {
displayName,
age,
bio: optionalText(input.bio, 500),
interests: stringArray(input.interests, 30, 60),
intent,
approxLocation: optionalText(input.approxLocation, 120),
visibility,
completed,
publishedAt: completed && visibility === 'visible' ? new Date().toISOString() : existing?.publishedAt || ''
};
}
export function checkPermission(user, action) {
if (!user) fail(401, 'session_required', 'Authentication required.');
if (user.role === 'admin') return true;
if (user.status === 'blocked' || user.status === 'deleted') {
fail(403, 'account_restricted', 'Account cannot perform this action.');
}
if (action.startsWith('moderation:') || action === 'profile:photo:moderate') {
if (user.role === 'moderator') return true;
fail(403, 'permission_denied', 'Permission denied.');
}
if (user.role === 'moderator') {
if (['discover:view', 'safety:block', 'safety:report', 'devtools:view'].includes(action)) return true;
if (action.startsWith('profile:')) return true;
}
if (user.status === 'limited' || user.role === 'limited') {
if (['profile:create', 'profile:update:self', 'profile:photo:delete:self'].includes(action)) return true;
if (['safety:block', 'safety:report', 'devtools:view'].includes(action)) return true;
fail(403, 'permission_denied', 'Permission denied.');
}
if (
[
'profile:create',
'profile:update:self',
'profile:photo:add',
'profile:photo:delete:self',
'profile:photo:reorder',
'discover:view',
'match:like',
'chat:send',
'safety:block',
'safety:report',
'devtools:view'
].includes(action)
) {
return true;
}
fail(403, 'permission_denied', 'Permission denied.');
}
export async function assertMatchMember(matchId, userId) {
const record = await getRecord('dating_matches', matchId);
const ids = relationIds(record.users);
if (!ids.includes(userId)) fail(404, 'match_not_found', 'Match not found.');
return record;
}
export async function createAudit(event, actorId, data = {}) {
try {
await createRecord('dating_audit_events', {
actor: actorId || '',
targetUser: data.targetUser || '',
report: data.report || '',
event,
module: data.module || 'dating',
traceId: data.traceId || '',
data: data.data || {}
});
} catch {
// Audit must not break the user flow in the local demo server.
}
}
export function relationId(value) {
if (Array.isArray(value)) return String(value[0] || '');
return value ? String(value) : '';
}
export function relationIds(value) {
if (!Array.isArray(value)) return value ? [String(value)] : [];
return value.map(String);
}
export function escapeFilter(value) {
return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
export function enumValue(value, allowed, field) {
const next = text(value);
if (!allowed.includes(next)) fail(400, 'invalid_enum', `${field} has an invalid value.`, { field, allowed });
return next;
}
export function sessionPayload(session) {
return {
authenticated: true,
user: session.user
};
}
export function sessionResponse(session) {
return withSession(ok(sessionPayload(session)), session);
}
export async function listProfilesByUserIds(userIds) {
const profiles = [];
for (const userId of userIds) {
const profile = await getProfileByUser(userId);
if (profile) profiles.push(profileView(profile));
}
return profiles;
}
export async function allRecords(collection, options = {}) {
const perPage = options.perPage || 100;
const first = await listRecords(collection, { ...options, page: 1, perPage });
const items = [...(first.items || [])];
const totalPages = first.totalPages || 1;
for (let page = 2; page <= totalPages; page += 1) {
const next = await listRecords(collection, { ...options, page, perPage });
items.push(...(next.items || []));
}
return items;
}

@ -0,0 +1,82 @@
import { existsSync, readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const serverDir = dirname(fileURLToPath(import.meta.url));
// The server now lives at `<repo>/demos/dating/server/`, so the repo
// root is three levels up — not two as it was when the server lived
// at `<repo>/servers/dating/`. Loading `.env`/`.env.local` from both
// the repo root and the server directory keeps either location valid.
const rootDir = resolve(serverDir, '../../..');
loadEnvFile(resolve(rootDir, '.env'));
loadEnvFile(resolve(rootDir, '.env.local'));
loadEnvFile(resolve(serverDir, '.env'));
loadEnvFile(resolve(serverDir, '.env.local'));
export const config = {
host: process.env.DATING_SERVER_HOST || '127.0.0.1',
port: readPort(process.env.DATING_SERVER_PORT, 8787),
allowedOrigins: readList(
process.env.DATING_ALLOWED_ORIGINS,
['http://localhost:5173', 'http://127.0.0.1:5173']
),
cookieName: process.env.DATING_SESSION_COOKIE || 'dating_session',
secureCookies: process.env.DATING_SECURE_COOKIES === 'true',
pocketBaseUrl: stripTrailingSlash(
process.env.POCKETBASE_URL || process.env.PUBLIC_POCKETBASE_URL || 'http://127.0.0.1:8090'
),
pocketBaseSuperuserEmail: requireEnv('POCKETBASE_SUPERUSER_EMAIL'),
pocketBaseSuperuserPassword: requireEnv('POCKETBASE_SUPERUSER_PASSWORD')
};
function loadEnvFile(file) {
if (!existsSync(file)) return;
const text = readFileSync(file, 'utf8');
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const eq = line.indexOf('=');
if (eq === -1) continue;
const key = line.slice(0, eq).trim();
const value = unquote(line.slice(eq + 1).trim());
if (key && process.env[key] == null) {
process.env[key] = value;
}
}
}
function unquote(value) {
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
return value.slice(1, -1);
}
return value;
}
function readPort(value, fallback) {
const port = Number(value);
return Number.isInteger(port) && port > 0 && port < 65536 ? port : fallback;
}
function readList(value, fallback) {
if (!value) return fallback;
return value
.split(',')
.map((item) => item.trim())
.filter(Boolean);
}
function stripTrailingSlash(value) {
return value.replace(/\/+$/, '');
}
function requireEnv(name) {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required env var ${name}`);
}
return value;
}

@ -0,0 +1,244 @@
import { config } from './env.mjs';
export class ApiError extends Error {
constructor(status, code, message, details) {
super(message);
this.name = 'ApiError';
this.status = status;
this.code = code;
this.details = details;
}
}
export function ok(body, options = {}) {
return {
status: options.status || 200,
headers: options.headers || {},
cookies: options.cookies || [],
body
};
}
export function created(body, options = {}) {
return ok(body, { ...options, status: 201 });
}
export function noContent(options = {}) {
return {
status: 204,
headers: options.headers || {},
cookies: options.cookies || [],
body: undefined
};
}
export function fail(status, code, message, details) {
throw new ApiError(status, code, message, details);
}
export async function readJson(request) {
const text = await request.text();
if (!text.trim()) return {};
try {
const value = JSON.parse(text);
if (!value || typeof value !== 'object' || Array.isArray(value)) {
fail(400, 'invalid_body', 'Body must be a JSON object.');
}
return value;
} catch (error) {
if (error instanceof ApiError) throw error;
fail(400, 'invalid_json', 'Body must be valid JSON.');
}
}
export async function readFormData(request) {
try {
return await request.formData();
} catch {
fail(
400,
'invalid_multipart',
'Multipart form-data body is invalid. When sending FormData from the browser, do not set Content-Type manually.'
);
}
}
export function text(value, fallback = '') {
return typeof value === 'string' ? value.trim() : fallback;
}
export function optionalText(value, max = 0) {
const next = text(value);
if (!next) return '';
return max > 0 ? next.slice(0, max) : next;
}
export function bool(value, fallback = false) {
return typeof value === 'boolean' ? value : fallback;
}
export function int(value, fallback, { min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER } = {}) {
if (value == null || value === '') return fallback;
const next = typeof value === 'number' ? value : Number(value);
if (!Number.isInteger(next)) return fallback;
return Math.min(max, Math.max(min, next));
}
export function stringArray(value, maxItems = 50, maxLength = 80) {
if (!Array.isArray(value)) return [];
return value
.map((item) => text(item))
.filter(Boolean)
.slice(0, maxItems)
.map((item) => item.slice(0, maxLength));
}
export function requireString(value, name, max = 0) {
const next = optionalText(value, max);
if (!next) fail(400, 'missing_field', `${name} is required.`, { field: name });
return next;
}
export function parseCookies(header) {
const cookies = new Map();
if (!header) return cookies;
for (const part of header.split(';')) {
const eq = part.indexOf('=');
if (eq === -1) continue;
const name = part.slice(0, eq).trim();
const value = part.slice(eq + 1).trim();
if (name) cookies.set(name, decodeURIComponent(value));
}
return cookies;
}
export function sessionCookie(token) {
return serializeCookie(config.cookieName, token, {
httpOnly: true,
sameSite: 'Lax',
secure: config.secureCookies,
path: '/',
maxAge: 60 * 60 * 24 * 7
});
}
export function clearSessionCookie() {
return serializeCookie(config.cookieName, '', {
httpOnly: true,
sameSite: 'Lax',
secure: config.secureCookies,
path: '/',
maxAge: 0
});
}
export function getBearerToken(request) {
const authorization = request.headers.get('authorization') || '';
if (!authorization.toLowerCase().startsWith('bearer ')) return '';
return authorization.slice(7).trim();
}
export function getSessionToken(request) {
return getBearerToken(request) || parseCookies(request.headers.get('cookie')).get(config.cookieName) || '';
}
export function handleError(error) {
if (error instanceof ApiError) {
return ok(
{
ok: false,
error: {
code: error.code,
message: error.message,
details: error.details
}
},
{ status: error.status }
);
}
if (error?.name === 'PocketBaseError') {
return ok(
{
ok: false,
error: {
code: error.code || 'pocketbase_error',
message: error.message,
details: error.data
}
},
{ status: error.status || 502 }
);
}
console.error(error);
return ok(
{
ok: false,
error: {
code: 'internal_error',
message: 'Unexpected dating server error.'
}
},
{ status: 500 }
);
}
export function applyCors(request, response) {
const origin = request.headers.get('origin');
const headers = { ...response.headers };
if (origin && config.allowedOrigins.includes(origin)) {
headers['access-control-allow-origin'] = origin;
headers['access-control-allow-credentials'] = 'true';
headers['vary'] = appendVary(headers.vary, 'Origin');
}
return { ...response, headers };
}
export function corsPreflight(request) {
const origin = request.headers.get('origin');
const headers = {
'access-control-allow-methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS',
'access-control-allow-headers': 'content-type,authorization',
'access-control-max-age': '600'
};
if (origin && config.allowedOrigins.includes(origin)) {
headers['access-control-allow-origin'] = origin;
headers['access-control-allow-credentials'] = 'true';
headers.vary = 'Origin';
}
return noContent({ headers });
}
export function writeResponse(nodeResponse, response) {
for (const [name, value] of Object.entries(response.headers || {})) {
if (value != null) nodeResponse.setHeader(name, value);
}
if (response.cookies?.length) {
nodeResponse.setHeader('set-cookie', response.cookies);
}
if (response.body === undefined) {
nodeResponse.writeHead(response.status);
nodeResponse.end();
return;
}
const body = JSON.stringify(response.body);
nodeResponse.setHeader('content-type', 'application/json; charset=utf-8');
nodeResponse.setHeader('content-length', Buffer.byteLength(body));
nodeResponse.writeHead(response.status);
nodeResponse.end(body);
}
function serializeCookie(name, value, options) {
const parts = [`${name}=${encodeURIComponent(value)}`];
if (options.maxAge != null) parts.push(`Max-Age=${options.maxAge}`);
if (options.path) parts.push(`Path=${options.path}`);
if (options.httpOnly) parts.push('HttpOnly');
if (options.sameSite) parts.push(`SameSite=${options.sameSite}`);
if (options.secure) parts.push('Secure');
return parts.join('; ');
}
function appendVary(value, item) {
if (!value) return item;
const parts = value.split(',').map((part) => part.trim().toLowerCase());
return parts.includes(item.toLowerCase()) ? value : `${value}, ${item}`;
}

@ -0,0 +1,144 @@
import { config } from './env.mjs';
export class PocketBaseError extends Error {
constructor(status, code, message, data) {
super(message);
this.name = 'PocketBaseError';
this.status = status;
this.code = code;
this.data = data;
}
}
let superuserAuth = null;
export function fileUrl(collection, recordId, filename, thumb = '') {
const url = `${config.pocketBaseUrl}/api/files/${encodeURIComponent(collection)}/${encodeURIComponent(
recordId
)}/${encodeURIComponent(filename)}`;
return thumb ? `${url}?thumb=${encodeURIComponent(thumb)}` : url;
}
export async function pbPublic(path, options = {}) {
return pbRequest(path, options);
}
export async function pbUser(path, token, options = {}) {
return pbRequest(path, { ...options, token });
}
export async function pbSuper(path, options = {}) {
const token = await getSuperuserToken();
return pbRequest(path, { ...options, token });
}
export async function listRecords(collection, options = {}) {
const query = {
page: options.page || 1,
perPage: options.perPage || 50,
sort: options.sort,
filter: options.filter,
expand: options.expand
};
return pbSuper(`/api/collections/${collection}/records`, { query });
}
export async function firstRecord(collection, filter) {
const result = await listRecords(collection, { filter, perPage: 1 });
return result.items?.[0] || null;
}
export async function getRecord(collection, id) {
return pbSuper(`/api/collections/${collection}/records/${encodeURIComponent(id)}`);
}
export async function createRecord(collection, body) {
return pbSuper(`/api/collections/${collection}/records`, {
method: 'POST',
body
});
}
export async function updateRecord(collection, id, body) {
return pbSuper(`/api/collections/${collection}/records/${encodeURIComponent(id)}`, {
method: 'PATCH',
body
});
}
export async function updateRecordForm(collection, id, body) {
return pbSuper(`/api/collections/${collection}/records/${encodeURIComponent(id)}`, {
method: 'PATCH',
body
});
}
async function getSuperuserToken() {
if (superuserAuth && superuserAuth.expiresAt > Date.now() + 30_000) {
return superuserAuth.token;
}
const response = await pbRequest('/api/collections/_superusers/auth-with-password', {
method: 'POST',
body: {
identity: config.pocketBaseSuperuserEmail,
password: config.pocketBaseSuperuserPassword
}
});
superuserAuth = {
token: response.token,
expiresAt: tokenExpiresAt(response.token)
};
return response.token;
}
async function pbRequest(path, options = {}) {
const url = new URL(path, config.pocketBaseUrl);
for (const [key, value] of Object.entries(options.query || {})) {
if (value != null && value !== '') url.searchParams.set(key, String(value));
}
const headers = new Headers(options.headers || {});
if (options.token) headers.set('authorization', `Bearer ${options.token}`);
let body = options.body;
if (body && !(body instanceof FormData) && typeof body !== 'string') {
headers.set('content-type', 'application/json');
body = JSON.stringify(body);
}
const response = await fetch(url, {
method: options.method || (body ? 'POST' : 'GET'),
headers,
body
});
const text = await response.text();
const data = text ? parseJson(text) : null;
if (!response.ok) {
throw new PocketBaseError(
response.status,
data?.code || data?.status || 'pocketbase_error',
data?.message || `PocketBase request failed with ${response.status}.`,
data?.data || data
);
}
return data;
}
function parseJson(text) {
try {
return JSON.parse(text);
} catch {
return { message: text };
}
}
function tokenExpiresAt(token) {
const [, payload] = token.split('.');
if (!payload) return Date.now() + 5 * 60_000;
try {
const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
return typeof decoded.exp === 'number' ? decoded.exp * 1000 : Date.now() + 5 * 60_000;
} catch {
return Date.now() + 5 * 60_000;
}
}

@ -0,0 +1,78 @@
<!--
Bottom-tab navigation for the product surface (Discover, Matches,
Profile, Safety). Sticks to the bottom on mobile, becomes a centered
pill on desktop. The visual styling lives in the Nexo design system
(`.nx-tabbar` / `.nx-tab`); this file only owns the data and active
state derivation.
-->
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { getNexoContext } from '../_lib/context';
const nexo = getNexoContext();
const items = [
{ href: '/dating/discover', label: 'Descubre', icon: '✦' },
{ href: '/dating/matches', label: 'Matches', icon: '♡' },
{ href: '/dating/profile', label: 'Perfil', icon: '◔' },
{ href: '/dating/safety', label: 'Seguridad', icon: '⚐' }
] as const;
function isCurrent(href: string): boolean {
return page.url.pathname === href || page.url.pathname.startsWith(`${href}/`);
}
async function logout() {
try {
await nexo.api.logout();
} finally {
nexo.setSession({ authenticated: false });
await goto('/dating/login', { replaceState: true });
}
}
</script>
<nav class="nx-tabbar appnav-frame" aria-label="Nexo">
{#each items as item (item.href)}
<a
class="nx-tab"
href={item.href}
aria-current={isCurrent(item.href) ? 'page' : undefined}
>
<span class="ico" aria-hidden="true">{item.icon}</span>
<span class="label">{item.label}</span>
</a>
{/each}
<button type="button" class="nx-tab logout" onclick={logout}>
<span class="ico" aria-hidden="true">⏻</span>
<span class="label">Salir</span>
</button>
</nav>
<style>
/* The primitives' .nx-tabbar uses 4 columns; we have 5 cells once
"Salir" is added, so override the grid here. */
.appnav-frame {
grid-template-columns: repeat(5, 1fr);
max-width: 36rem;
margin: 0 auto;
}
.ico {
font-size: 1.1rem;
line-height: 1;
}
.logout {
opacity: 0.65;
}
.logout:hover {
opacity: 1;
}
/* Desktop replaces this bottom tab bar with the layout's left
rail. Hiding here keeps the page chrome from doubling up. */
@media (min-width: 1024px) {
.appnav-frame {
display: none;
}
}
</style>

@ -0,0 +1,182 @@
<!--
Shared auth shell — split layout on desktop (form left, brand
gradient right), single column on mobile. Both halves stay simple
and high-contrast: no busy backgrounds, no decorative noise.
-->
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
title: string;
subtitle?: string;
children: Snippet;
footer?: Snippet;
}
let { title, subtitle, children, footer }: Props = $props();
</script>
<section class="auth-shell">
<div class="auth-form-side">
<div class="form-stack">
<a class="brand" href="/dating">
<span class="brand-mark" aria-hidden="true">N</span>
<span class="brand-text">Nexo</span>
</a>
<header>
<h1 class="nx-h1">{title}</h1>
{#if subtitle}<p class="sub">{subtitle}</p>{/if}
</header>
<div class="body">
{@render children()}
</div>
{#if footer}
<footer class="auth-foot">
{@render footer()}
</footer>
{/if}
</div>
</div>
<aside class="auth-art" aria-hidden="true">
<div class="art-grid">
<div class="art-quote">
<p>“La diferencia está en el primer mensaje.”</p>
<span>— Nexo</span>
</div>
</div>
</aside>
</section>
<style>
.auth-shell {
min-height: 100vh;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
background: var(--nx-bg);
}
.auth-form-side {
display: flex;
align-items: center;
justify-content: center;
padding: 48px 32px;
min-width: 0;
}
.form-stack {
width: 100%;
max-width: 420px;
display: flex;
flex-direction: column;
gap: 28px;
}
.brand {
display: inline-flex;
align-items: center;
gap: 10px;
text-decoration: none;
color: inherit;
}
.brand-mark {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: var(--nx-radius-md);
background: var(--nx-accent);
color: var(--nx-accent-fg);
font-weight: 700;
font-size: 14px;
}
.brand-text {
font-weight: 600;
font-size: var(--nx-text-md);
letter-spacing: -0.015em;
}
header {
display: flex;
flex-direction: column;
gap: 6px;
}
.sub {
margin: 0;
font-size: var(--nx-text-sm);
color: var(--nx-fg-muted);
line-height: 1.55;
}
.body {
display: flex;
flex-direction: column;
}
.auth-foot {
font-size: var(--nx-text-sm);
color: var(--nx-fg-muted);
padding-top: 12px;
border-top: 1px solid var(--nx-border);
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.auth-foot :global(a) {
color: var(--nx-fg);
font-weight: 500;
text-decoration: none;
}
.auth-foot :global(a:hover) {
color: var(--nx-accent);
}
.auth-art {
background: linear-gradient(
140deg,
color-mix(in srgb, var(--nx-accent) 10%, var(--nx-bg-sunken)),
var(--nx-bg-sunken)
),
var(--nx-bg-sunken);
display: grid;
place-items: center;
padding: 48px;
position: relative;
overflow: hidden;
border-left: 1px solid var(--nx-border);
}
.art-grid {
max-width: 360px;
display: flex;
flex-direction: column;
gap: 24px;
}
.art-quote {
padding: 28px;
border: 1px solid var(--nx-border);
border-radius: var(--nx-radius-lg);
background: var(--nx-bg-elevated);
box-shadow: var(--nx-shadow-md);
}
.art-quote p {
font-family: var(--nx-font-display);
font-size: var(--nx-text-xl);
font-weight: 500;
letter-spacing: -0.01em;
line-height: 1.35;
margin: 0 0 12px;
color: var(--nx-fg);
}
.art-quote span {
font-size: var(--nx-text-xs);
color: var(--nx-fg-muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
}
@media (max-width: 900px) {
.auth-shell {
grid-template-columns: 1fr;
}
.auth-art {
display: none;
}
.auth-form-side {
padding: 32px 24px;
}
}
</style>

@ -0,0 +1,31 @@
<!--
Form field primitive: label + control + help/error in a tidy
column. Children control gets the `.nx-field-input` /
`.nx-field-textarea` / `.nx-field-select` class from the consumer.
-->
<script lang="ts">
import type { Snippet } from 'svelte';
import Icon from './Icon.svelte';
interface Props {
id: string;
label: string;
hint?: string;
error?: string;
children: Snippet;
}
let { id, label, hint, error, children }: Props = $props();
const errorId = $derived(error ? `${id}-error` : undefined);
</script>
<div class="nx-field" class:has-error={Boolean(error)}>
<label class="nx-field-label" for={id}>{label}</label>
{@render children()}
{#if error}
<p id={errorId} class="nx-field-error" role="alert">
<Icon name="alert" size={12} />
<span>{error}</span>
</p>
{:else if hint}
<p class="nx-field-help">{hint}</p>
{/if}
</div>

@ -0,0 +1,181 @@
<!--
Inline SVG icon set, Feather-style (24×24, currentColor stroke,
1.75 stroke-width). One file so the bundle ships only the paths
we actually use. Add new entries to PATHS only when a route
references them.
-->
<script lang="ts" module>
export type IconName =
| 'arrow-left'
| 'arrow-right'
| 'send'
| 'x'
| 'check'
| 'heart'
| 'heart-fill'
| 'star'
| 'sparkle'
| 'message'
| 'user'
| 'shield'
| 'log-out'
| 'refresh'
| 'camera'
| 'image-plus'
| 'edit'
| 'map-pin'
| 'alert'
| 'info'
| 'eye'
| 'eye-off'
| 'chevron-left'
| 'chevron-right'
| 'sliders'
| 'plus'
| 'trash'
| 'arrow-up'
| 'arrow-down'
| 'more';
</script>
<script lang="ts">
interface Props {
name: IconName;
size?: number;
strokeWidth?: number;
fill?: boolean;
title?: string;
class?: string;
}
let {
name,
size = 20,
strokeWidth = 1.75,
fill = false,
title,
class: className = ''
}: Props = $props();
const PATHS: Record<IconName, { paths: string[]; viewBox?: string; fill?: boolean }> = {
'arrow-left': { paths: ['M19 12H5', 'M12 19l-7-7 7-7'] },
'arrow-right': { paths: ['M5 12h14', 'M12 5l7 7-7 7'] },
send: { paths: ['M22 2L11 13', 'M22 2l-7 20-4-9-9-4 20-7z'] },
x: { paths: ['M18 6L6 18', 'M6 6l12 12'] },
check: { paths: ['M20 6L9 17l-5-5'] },
heart: {
paths: [
'M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z'
]
},
'heart-fill': {
paths: [
'M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z'
],
fill: true
},
star: {
paths: ['M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z']
},
sparkle: {
paths: [
'M12 3v3',
'M12 18v3',
'M3 12h3',
'M18 12h3',
'M5.6 5.6l2.1 2.1',
'M16.3 16.3l2.1 2.1',
'M5.6 18.4l2.1-2.1',
'M16.3 7.7l2.1-2.1'
]
},
message: {
paths: ['M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z']
},
user: {
paths: ['M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2', 'M12 7a4 4 0 1 0 0 8 4 4 0 0 0 0-8z']
},
shield: { paths: ['M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z'] },
'log-out': {
paths: ['M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4', 'M16 17l5-5-5-5', 'M21 12H9']
},
refresh: {
paths: [
'M23 4v6h-6',
'M1 20v-6h6',
'M3.51 9a9 9 0 0 1 14.85-3.36L23 10',
'M20.49 15a9 9 0 0 1-14.85 3.36L1 14'
]
},
camera: {
paths: [
'M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z',
'M12 17a4 4 0 1 0 0-8 4 4 0 0 0 0 8z'
]
},
'image-plus': {
paths: [
'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h9',
'M19 2v6',
'M22 5h-6',
'M21 15l-5-5L5 21'
]
},
edit: { paths: ['M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7', 'M18.5 2.5a2.121 2.121 0 1 1 3 3L12 15l-4 1 1-4 9.5-9.5z'] },
'map-pin': {
paths: ['M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 1 1 18 0z', 'M12 13a3 3 0 1 0 0-6 3 3 0 0 0 0 6z']
},
alert: { paths: ['M12 9v4', 'M12 17h.01', 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z'] },
info: { paths: ['M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20z', 'M12 16v-4', 'M12 8h.01'] },
eye: { paths: ['M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z', 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z'] },
'eye-off': {
paths: [
'M17.94 17.94A10.94 10.94 0 0 1 12 20c-7 0-11-8-11-8a19.79 19.79 0 0 1 5.06-5.94',
'M9.9 4.24A10.94 10.94 0 0 1 12 4c7 0 11 8 11 8a19.79 19.79 0 0 1-3.36 4.49',
'M14.12 14.12a3 3 0 1 1-4.24-4.24',
'M1 1l22 22'
]
},
'chevron-left': { paths: ['M15 18l-6-6 6-6'] },
'chevron-right': { paths: ['M9 18l6-6-6-6'] },
sliders: {
paths: [
'M4 21v-7',
'M4 10V3',
'M12 21v-9',
'M12 8V3',
'M20 21v-5',
'M20 12V3',
'M1 14h6',
'M9 8h6',
'M17 16h6'
]
},
plus: { paths: ['M12 5v14', 'M5 12h14'] },
trash: { paths: ['M3 6h18', 'M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6', 'M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2'] },
'arrow-up': { paths: ['M12 19V5', 'M5 12l7-7 7 7'] },
'arrow-down': { paths: ['M12 5v14', 'M19 12l-7 7-7-7'] },
more: { paths: ['M12 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2z', 'M19 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2z', 'M5 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2z'] }
};
const entry = $derived(PATHS[name]);
const useFill = $derived(fill || entry.fill === true);
</script>
<svg
class={className}
width={size}
height={size}
viewBox={entry.viewBox ?? '0 0 24 24'}
fill={useFill ? 'currentColor' : 'none'}
stroke="currentColor"
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden={title === undefined ? 'true' : undefined}
role={title === undefined ? undefined : 'img'}
>
{#if title}<title>{title}</title>{/if}
{#each entry.paths as d (d)}
<path {d} />
{/each}
</svg>

@ -0,0 +1,69 @@
<!--
Floating toast for incoming chat messages received while the user
is not on that conversation. Stacks up to a small max so a chatty
counterpart doesn't fill the screen, and each toast auto-dismisses
after a few seconds. Visuals come from the Nexo design system
(`.nx-toast-host` / `.nx-toast`).
-->
<script lang="ts">
import { goto } from '$app/navigation';
export interface MessageToastEntry {
readonly id: string;
readonly matchId: string;
readonly title: string;
readonly body: string;
}
interface Props {
toasts: MessageToastEntry[];
dismiss: (id: string) => void;
}
let { toasts, dismiss }: Props = $props();
async function open(toast: MessageToastEntry) {
dismiss(toast.id);
await goto(`/dating/chat/${toast.matchId}`);
}
function onKey(event: KeyboardEvent, toast: MessageToastEntry) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
void open(toast);
}
}
</script>
{#if toasts.length > 0}
<div class="nx-toast-host" aria-live="polite" aria-label="Notificaciones">
{#each toasts as toast (toast.id)}
<!-- The whole row is clickable; the close button gets its own
stopPropagation. We use role="button" instead of a real
<button> so the inner close <button> stays valid HTML. -->
<div
class="nx-toast"
role="button"
tabindex="0"
onclick={() => open(toast)}
onkeydown={(event) => onKey(event, toast)}
>
<span class="icon" aria-hidden="true">✉</span>
<span class="body">
<div class="title">{toast.title}</div>
<div class="msg">{toast.body}</div>
</span>
<button
type="button"
class="close"
aria-label="Descartar"
onclick={(event) => {
event.stopPropagation();
dismiss(toast.id);
}}
>
×
</button>
</div>
{/each}
</div>
{/if}

@ -0,0 +1,75 @@
<!--
Tarjeta canónica de perfil para Discover. La estructura — foto a
pantalla completa con overlay de nombre/edad/ubicación, prompt
editorial debajo y chips de intereses al pie — viene del bundle de
diseño Nexo (variante "Editorial").
-->
<script lang="ts">
import type { DatingProfile } from '../_lib/types';
interface Props {
profile: DatingProfile;
}
let { profile }: Props = $props();
const main = $derived(
profile.photoUrls.find((entry) => entry.filename === profile.primaryPhoto) ??
profile.photoUrls[0]
);
const intentLabel = $derived(
({
dating: 'Conocer gente',
long_term: 'Algo serio',
casual: 'Algo casual',
friends: 'Amistad',
unsure: 'Sin etiqueta'
} as const)[profile.intent]
);
const initial = $derived(profile.displayName.slice(0, 1).toUpperCase());
</script>
<article class="nx-card" data-intent={profile.intent}>
<div class="nx-photo">
{#if main}
<img src={main.card} alt={`Foto de ${profile.displayName}`} loading="lazy" />
{:else}
<div class="photo-placeholder" aria-hidden="true">{initial}</div>
{/if}
<div class="photo-meta">
<span class="intent">{intentLabel}</span>
<div class="name">
<span>{profile.displayName}</span>
<span class="age">{profile.age}</span>
</div>
{#if profile.approxLocation !== ''}
<div class="place">
<span aria-hidden="true">⌖</span>
<span>{profile.approxLocation}</span>
</div>
{/if}
</div>
</div>
{#if profile.bio !== ''}
<div class="nx-prompt-card">
<p class="nx-prompt-q">Sobre mí</p>
<p class="nx-prompt-a">{profile.bio}</p>
</div>
{/if}
{#if profile.interests.length > 0}
<ul class="nx-info-row" aria-label="Intereses">
{#each profile.interests.slice(0, 8) as interest (interest)}
<li class="nx-tag">{interest}</li>
{/each}
</ul>
{/if}
</article>
<style>
.nx-info-row {
list-style: none;
}
</style>

@ -0,0 +1,671 @@
/* =========================================================
Nexo primitives — desktop-first.
Mobile is an adaptation; baseline assumes ≥1024px.
Scoped under `.nx-shell` so class names don't leak.
========================================================= */
.nx-shell {
font-family: var(--nx-font-body);
font-size: var(--nx-text-base);
color: var(--nx-fg);
background: var(--nx-bg);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
font-feature-settings: 'cv11', 'ss01';
min-height: 100vh;
}
.nx-shell *,
.nx-shell *::before,
.nx-shell *::after {
box-sizing: border-box;
}
.nx-shell button {
font: inherit;
color: inherit;
cursor: pointer;
}
.nx-shell input,
.nx-shell textarea,
.nx-shell select {
font: inherit;
color: inherit;
}
.nx-shell :focus {
outline: none;
}
.nx-shell :focus-visible {
outline: 2px solid var(--nx-accent);
outline-offset: 2px;
border-radius: var(--nx-radius-sm);
}
.nx-shell a {
color: var(--nx-fg);
text-decoration: none;
}
/* ---------- Type scale helpers ---------- */
.nx-h1 {
font-family: var(--nx-font-display);
font-size: var(--nx-text-3xl);
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1.15;
margin: 0;
}
.nx-h2 {
font-family: var(--nx-font-display);
font-size: var(--nx-text-2xl);
font-weight: 600;
letter-spacing: -0.015em;
line-height: 1.2;
margin: 0;
}
.nx-h3 {
font-size: var(--nx-text-lg);
font-weight: 600;
letter-spacing: -0.005em;
line-height: 1.3;
margin: 0;
}
.nx-eyebrow {
font-size: var(--nx-text-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--nx-fg-muted);
}
.nx-text-muted {
color: var(--nx-fg-muted);
}
.nx-text-sm {
font-size: var(--nx-text-sm);
}
/* ---------- Buttons ---------- */
.nx-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 36px;
padding: 0 14px;
border: 1px solid transparent;
border-radius: var(--nx-radius-md);
font-weight: 500;
font-size: var(--nx-text-sm);
letter-spacing: -0.005em;
cursor: pointer;
transition:
background var(--nx-dur-fast) var(--nx-soft-curve),
border-color var(--nx-dur-fast) var(--nx-soft-curve),
color var(--nx-dur-fast) var(--nx-soft-curve),
box-shadow var(--nx-dur-fast) var(--nx-soft-curve);
user-select: none;
-webkit-tap-highlight-color: transparent;
white-space: nowrap;
text-decoration: none;
}
.nx-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.nx-btn-primary {
background: var(--nx-accent);
color: var(--nx-accent-fg);
border-color: var(--nx-accent);
box-shadow: var(--nx-shadow-xs);
}
.nx-btn-primary:hover:not(:disabled) {
background: var(--nx-accent-strong);
border-color: var(--nx-accent-strong);
}
.nx-btn-primary:focus-visible {
box-shadow: var(--nx-ring-accent);
outline: none;
}
.nx-btn-secondary {
background: var(--nx-bg-elevated);
color: var(--nx-fg);
border-color: var(--nx-border-strong);
box-shadow: var(--nx-shadow-xs);
}
.nx-btn-secondary:hover:not(:disabled) {
background: var(--nx-bg-inset);
border-color: var(--nx-border-strong);
}
.nx-btn-ghost {
background: transparent;
color: var(--nx-fg-muted);
}
.nx-btn-ghost:hover:not(:disabled) {
background: var(--nx-bg-inset);
color: var(--nx-fg);
}
.nx-btn-danger {
background: var(--nx-danger);
color: var(--nx-fg-on-accent);
border-color: var(--nx-danger);
}
.nx-btn-danger:hover:not(:disabled) {
background: color-mix(in srgb, var(--nx-danger) 90%, black);
}
.nx-btn-block {
width: 100%;
}
.nx-btn-lg {
height: 44px;
padding: 0 18px;
font-size: var(--nx-text-md);
}
.nx-btn-sm {
height: 28px;
padding: 0 10px;
font-size: var(--nx-text-xs);
}
/* Icon-only square button. Used in toolbars, headers, photo cards. */
.nx-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: 1px solid transparent;
border-radius: var(--nx-radius-md);
background: transparent;
color: var(--nx-fg-muted);
cursor: pointer;
transition:
background var(--nx-dur-fast) var(--nx-soft-curve),
color var(--nx-dur-fast) var(--nx-soft-curve);
}
.nx-icon-btn:hover:not(:disabled) {
background: var(--nx-bg-inset);
color: var(--nx-fg);
}
.nx-icon-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ---------- Form fields ---------- */
.nx-field {
display: flex;
flex-direction: column;
gap: 6px;
margin: 0;
}
.nx-field-label {
font-size: var(--nx-text-sm);
font-weight: 500;
color: var(--nx-fg);
letter-spacing: -0.005em;
}
.nx-field-input,
.nx-field-textarea,
.nx-field-select {
width: 100%;
min-height: 36px;
padding: 8px 12px;
border: 1px solid var(--nx-border-strong);
border-radius: var(--nx-radius-md);
background: var(--nx-bg-elevated);
color: var(--nx-fg);
font-size: var(--nx-text-sm);
transition:
border-color var(--nx-dur-fast) var(--nx-soft-curve),
box-shadow var(--nx-dur-fast) var(--nx-soft-curve);
}
.nx-field-textarea {
min-height: 88px;
resize: vertical;
line-height: 1.5;
}
.nx-field-input:focus,
.nx-field-textarea:focus,
.nx-field-select:focus {
border-color: var(--nx-accent);
box-shadow: var(--nx-ring-accent);
outline: none;
}
.nx-field-input::placeholder,
.nx-field-textarea::placeholder {
color: var(--nx-fg-subtle);
}
.nx-field-input[aria-invalid='true'],
.nx-field.has-error .nx-field-input,
.nx-field.has-error .nx-field-textarea,
.nx-field.has-error .nx-field-select {
border-color: var(--nx-danger);
}
.nx-field.has-error .nx-field-input:focus,
.nx-field.has-error .nx-field-textarea:focus,
.nx-field.has-error .nx-field-select:focus {
box-shadow: var(--nx-ring-danger);
}
.nx-field-help {
font-size: var(--nx-text-xs);
color: var(--nx-fg-muted);
margin: 0;
}
.nx-field-error {
font-size: var(--nx-text-xs);
color: var(--nx-danger);
display: flex;
align-items: center;
gap: 4px;
margin: 0;
}
/* Strength meter */
.nx-strength {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
}
.nx-strength-bar {
height: 3px;
border-radius: var(--nx-radius-pill);
background: var(--nx-bg-inset);
transition: background var(--nx-dur-base) var(--nx-soft-curve);
}
.nx-strength-bar.on-1 {
background: var(--nx-danger);
}
.nx-strength-bar.on-2 {
background: var(--nx-warning);
}
.nx-strength-bar.on-3 {
background: var(--nx-info);
}
.nx-strength-bar.on-4 {
background: var(--nx-success);
}
/* Checkbox */
.nx-check {
display: flex;
align-items: flex-start;
gap: 10px;
cursor: pointer;
font-size: var(--nx-text-sm);
color: var(--nx-fg);
line-height: 1.5;
}
.nx-check input {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
}
.nx-check .box {
flex-shrink: 0;
width: 18px;
height: 18px;
border: 1.5px solid var(--nx-border-strong);
border-radius: var(--nx-radius-xs);
background: var(--nx-bg-elevated);
display: grid;
place-items: center;
margin-top: 1px;
transition: all var(--nx-dur-fast) var(--nx-soft-curve);
}
.nx-check input:focus-visible ~ .box {
box-shadow: var(--nx-ring-accent);
}
.nx-check input:checked ~ .box {
background: var(--nx-accent);
border-color: var(--nx-accent);
}
.nx-check input:checked ~ .box::after {
content: '';
width: 9px;
height: 5px;
border-left: 2px solid var(--nx-accent-fg);
border-bottom: 2px solid var(--nx-accent-fg);
transform: rotate(-45deg) translate(1px, -1px);
}
/* ---------- Banners (alerts / inline status) ---------- */
.nx-banner {
background: var(--nx-danger-soft);
border: 1px solid var(--nx-danger-border);
color: var(--nx-danger);
padding: 10px 14px;
border-radius: var(--nx-radius-md);
margin: 0;
font-size: var(--nx-text-sm);
display: flex;
align-items: center;
gap: 10px;
}
.nx-banner-success {
background: var(--nx-success-soft);
border-color: var(--nx-success-border);
color: var(--nx-success);
}
.nx-banner-warning {
background: var(--nx-warning-soft);
border-color: var(--nx-warning-border);
color: var(--nx-warning);
}
.nx-banner-info {
background: var(--nx-info-soft);
border-color: var(--nx-info-border);
color: var(--nx-info);
}
/* ---------- Pills (filter chips, intent tags) ---------- */
.nx-pill {
display: inline-flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 12px;
border: 1px solid var(--nx-border-strong);
background: var(--nx-bg-elevated);
color: var(--nx-fg);
border-radius: var(--nx-radius-pill);
font-size: var(--nx-text-xs);
font-weight: 500;
cursor: pointer;
transition: all var(--nx-dur-fast) var(--nx-soft-curve);
white-space: nowrap;
user-select: none;
}
.nx-pill:hover:not([aria-pressed='true']):not([data-active='true']):not(:disabled) {
border-color: var(--nx-fg-subtle);
}
.nx-pill[aria-pressed='true'],
.nx-pill[data-active='true'] {
background: var(--nx-accent-soft);
border-color: var(--nx-accent-border);
color: var(--nx-accent);
}
.nx-pill:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.nx-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border: 1px solid var(--nx-border);
border-radius: var(--nx-radius-sm);
font-size: var(--nx-text-xs);
color: var(--nx-fg-muted);
background: var(--nx-bg-sunken);
}
/* ---------- Card / Panel ---------- */
.nx-panel {
background: var(--nx-bg-elevated);
border: 1px solid var(--nx-border);
border-radius: var(--nx-radius-lg);
overflow: hidden;
}
.nx-panel-header {
padding: 16px 20px;
border-bottom: 1px solid var(--nx-border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.nx-panel-body {
padding: 20px;
}
/* ---------- Page header (h1 + lede + actions) ---------- */
.nx-page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 28px 0 20px;
border-bottom: 1px solid var(--nx-border);
margin-bottom: 24px;
}
.nx-page-header .titles {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.nx-page-header .lede {
font-size: var(--nx-text-sm);
color: var(--nx-fg-muted);
margin: 0;
}
.nx-page-header .actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
/* ---------- Empty state ---------- */
.nx-empty {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 12px;
padding: 60px 32px;
color: var(--nx-fg-muted);
}
.nx-empty-art {
width: 56px;
height: 56px;
border-radius: var(--nx-radius-lg);
background: var(--nx-bg-inset);
display: grid;
place-items: center;
color: var(--nx-fg-muted);
}
.nx-empty h3 {
font-size: var(--nx-text-lg);
font-weight: 600;
color: var(--nx-fg);
margin: 0;
}
.nx-empty p {
margin: 0;
font-size: var(--nx-text-sm);
max-width: 36ch;
}
/* ---------- Skeletons ---------- */
.nx-skel {
background: linear-gradient(
90deg,
var(--nx-bg-inset) 0%,
color-mix(in srgb, var(--nx-fg) 4%, var(--nx-bg-inset)) 50%,
var(--nx-bg-inset) 100%
);
background-size: 200% 100%;
animation: nx-shimmer 1.6s linear infinite;
border-radius: var(--nx-radius-md);
}
@keyframes nx-shimmer {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
/* ---------- Toast ---------- */
.nx-toast-host {
position: fixed;
top: 16px;
right: 16px;
z-index: var(--nx-z-toast);
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: none;
width: 360px;
max-width: calc(100vw - 32px);
}
.nx-toast {
pointer-events: auto;
background: var(--nx-bg-elevated);
color: var(--nx-fg);
padding: 12px 14px;
border-radius: var(--nx-radius-md);
border: 1px solid var(--nx-border);
box-shadow: var(--nx-shadow-lg);
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 10px;
font-size: var(--nx-text-sm);
animation: nx-toast-in var(--nx-dur-slow) var(--nx-step-curve);
cursor: pointer;
font-family: inherit;
text-align: left;
width: 100%;
}
@keyframes nx-toast-in {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.nx-toast .icon {
width: 32px;
height: 32px;
border-radius: var(--nx-radius-md);
display: grid;
place-items: center;
flex-shrink: 0;
background: var(--nx-accent-soft);
color: var(--nx-accent);
}
.nx-toast .body {
min-width: 0;
}
.nx-toast .title {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nx-toast .msg {
color: var(--nx-fg-muted);
font-size: var(--nx-text-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-top: 2px;
}
.nx-toast .close {
border: none;
background: transparent;
color: var(--nx-fg-muted);
cursor: pointer;
width: 24px;
height: 24px;
display: grid;
place-items: center;
border-radius: var(--nx-radius-sm);
}
.nx-toast .close:hover {
background: var(--nx-bg-inset);
color: var(--nx-fg);
}
@media (max-width: 640px) {
.nx-toast-host {
top: auto;
bottom: 80px;
left: 16px;
right: 16px;
width: auto;
}
}
/* ---------- Modal ---------- */
.nx-modal-bg {
position: fixed;
inset: 0;
background: var(--nx-bg-overlay);
-webkit-backdrop-filter: blur(4px);
backdrop-filter: blur(4px);
z-index: var(--nx-z-modal);
display: grid;
place-items: center;
padding: 24px;
animation: nx-fade-in var(--nx-dur-base) var(--nx-soft-curve);
}
@keyframes nx-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.nx-modal {
background: var(--nx-bg-elevated);
border: 1px solid var(--nx-border);
border-radius: var(--nx-radius-xl);
box-shadow: var(--nx-shadow-xl);
max-width: 420px;
width: 100%;
padding: 28px;
display: flex;
flex-direction: column;
gap: 16px;
animation: nx-modal-in var(--nx-dur-slow) var(--nx-step-curve);
}
@keyframes nx-modal-in {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
.nx-modal-headline {
font-family: var(--nx-font-display);
font-size: var(--nx-text-2xl);
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1.15;
margin: 0;
}
.nx-modal-sub {
color: var(--nx-fg-muted);
font-size: var(--nx-text-sm);
margin: 0;
line-height: 1.5;
}
.nx-modal-actions {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
}

@ -0,0 +1,226 @@
/* =========================================================
Nexo design tokens — sober neutrals + single brand accent.
Light: pure white / slate. Dark: charcoal. Accent: violet.
Single source of truth.
========================================================= */
:root,
:root[data-theme='light'] {
/* Surfaces */
--nx-bg: #ffffff;
--nx-bg-elevated: #ffffff;
--nx-bg-sunken: #fafafa;
--nx-bg-inset: #f4f4f5;
--nx-bg-overlay: rgba(9, 9, 11, 0.5);
/* Foreground */
--nx-fg: #0a0a0a;
--nx-fg-muted: #525252;
--nx-fg-subtle: #a1a1aa;
--nx-fg-on-accent: #ffffff;
/* Brand */
--nx-accent: #7c3aed;
--nx-accent-strong: #6d28d9;
--nx-accent-soft: #f3eefe;
--nx-accent-border: #ddd6fe;
--nx-accent-fg: #ffffff;
/* State */
--nx-success: #16a34a;
--nx-success-soft: #f0fdf4;
--nx-success-border: #bbf7d0;
--nx-warning: #d97706;
--nx-warning-soft: #fffbeb;
--nx-warning-border: #fde68a;
--nx-danger: #dc2626;
--nx-danger-soft: #fef2f2;
--nx-danger-border: #fecaca;
--nx-info: #0284c7;
--nx-info-soft: #f0f9ff;
--nx-info-border: #bae6fd;
/* Borders */
--nx-border: #e4e4e7;
--nx-border-strong: #d4d4d8;
--nx-border-accent: #c4b5fd;
/* Shadows — flat, layered, no warm tint */
--nx-shadow-xs: 0 1px 2px rgba(9, 9, 11, 0.04);
--nx-shadow-sm: 0 1px 2px rgba(9, 9, 11, 0.05), 0 1px 3px rgba(9, 9, 11, 0.04);
--nx-shadow-md: 0 4px 8px -2px rgba(9, 9, 11, 0.06), 0 2px 4px -2px rgba(9, 9, 11, 0.04);
--nx-shadow-lg: 0 10px 24px -4px rgba(9, 9, 11, 0.08), 0 4px 8px -4px rgba(9, 9, 11, 0.04);
--nx-shadow-xl: 0 24px 48px -12px rgba(9, 9, 11, 0.18);
--nx-ring-accent: 0 0 0 3px rgba(124, 58, 237, 0.2);
--nx-ring-danger: 0 0 0 3px rgba(220, 38, 38, 0.2);
/* Radii */
--nx-radius-xs: 4px;
--nx-radius-sm: 6px;
--nx-radius-md: 8px;
--nx-radius-lg: 12px;
--nx-radius-xl: 16px;
--nx-radius-2xl: 24px;
--nx-radius-pill: 999px;
/* Spacing — 4pt rhythm */
--nx-space-1: 0.25rem;
--nx-space-2: 0.5rem;
--nx-space-3: 0.75rem;
--nx-space-4: 1rem;
--nx-space-5: 1.25rem;
--nx-space-6: 1.5rem;
--nx-space-8: 2rem;
--nx-space-10: 2.5rem;
--nx-space-12: 3rem;
--nx-space-16: 4rem;
/* Type — system stack first, no Newsreader dependency. The headlines
look right with a tight tracking and proper weight rather than a
web-font that may not load on first paint. */
--nx-font-display: 'Inter Display', 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, sans-serif;
--nx-font-body: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
sans-serif;
--nx-font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
--nx-text-xs: 0.75rem;
--nx-text-sm: 0.8125rem;
--nx-text-base: 0.875rem;
--nx-text-md: 0.9375rem;
--nx-text-lg: 1.0625rem;
--nx-text-xl: 1.25rem;
--nx-text-2xl: 1.625rem;
--nx-text-3xl: 2rem;
--nx-text-display: 2.75rem;
/* Motion */
--nx-step-curve: cubic-bezier(0.16, 1, 0.3, 1);
--nx-soft-curve: cubic-bezier(0.4, 0, 0.2, 1);
--nx-dur-fast: 100ms;
--nx-dur-base: 160ms;
--nx-dur-slow: 240ms;
/* Z */
--nx-z-nav: 50;
--nx-z-dropdown: 60;
--nx-z-toast: 70;
--nx-z-modal: 80;
color-scheme: light;
}
:root[data-theme='dark'] {
--nx-bg: #09090b;
--nx-bg-elevated: #18181b;
--nx-bg-sunken: #050507;
--nx-bg-inset: #27272a;
--nx-bg-overlay: rgba(0, 0, 0, 0.7);
--nx-fg: #fafafa;
--nx-fg-muted: #a1a1aa;
--nx-fg-subtle: #71717a;
--nx-fg-on-accent: #ffffff;
--nx-accent: #a78bfa;
--nx-accent-strong: #c4b5fd;
--nx-accent-soft: #2e1065;
--nx-accent-border: #4c1d95;
--nx-accent-fg: #0a0a0a;
--nx-success: #4ade80;
--nx-success-soft: #052e16;
--nx-success-border: #14532d;
--nx-warning: #fbbf24;
--nx-warning-soft: #422006;
--nx-warning-border: #78350f;
--nx-danger: #f87171;
--nx-danger-soft: #450a0a;
--nx-danger-border: #7f1d1d;
--nx-info: #38bdf8;
--nx-info-soft: #082f49;
--nx-info-border: #0c4a6e;
--nx-border: #27272a;
--nx-border-strong: #3f3f46;
--nx-border-accent: #6d28d9;
--nx-shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.4);
--nx-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.5), 0 1px 3px rgba(0, 0, 0, 0.4);
--nx-shadow-md: 0 4px 8px -2px rgba(0, 0, 0, 0.6), 0 2px 4px -2px rgba(0, 0, 0, 0.4);
--nx-shadow-lg: 0 10px 24px -4px rgba(0, 0, 0, 0.6), 0 4px 8px -4px rgba(0, 0, 0, 0.4);
--nx-shadow-xl: 0 24px 48px -12px rgba(0, 0, 0, 0.8);
--nx-ring-accent: 0 0 0 3px rgba(167, 139, 250, 0.3);
--nx-ring-danger: 0 0 0 3px rgba(248, 113, 113, 0.3);
color-scheme: dark;
}
/* Honor system theme when no explicit override is set. */
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
--nx-bg: #09090b;
--nx-bg-elevated: #18181b;
--nx-bg-sunken: #050507;
--nx-bg-inset: #27272a;
--nx-bg-overlay: rgba(0, 0, 0, 0.7);
--nx-fg: #fafafa;
--nx-fg-muted: #a1a1aa;
--nx-fg-subtle: #71717a;
--nx-fg-on-accent: #ffffff;
--nx-accent: #a78bfa;
--nx-accent-strong: #c4b5fd;
--nx-accent-soft: #2e1065;
--nx-accent-border: #4c1d95;
--nx-accent-fg: #0a0a0a;
--nx-success: #4ade80;
--nx-success-soft: #052e16;
--nx-success-border: #14532d;
--nx-warning: #fbbf24;
--nx-warning-soft: #422006;
--nx-warning-border: #78350f;
--nx-danger: #f87171;
--nx-danger-soft: #450a0a;
--nx-danger-border: #7f1d1d;
--nx-info: #38bdf8;
--nx-info-soft: #082f49;
--nx-info-border: #0c4a6e;
--nx-border: #27272a;
--nx-border-strong: #3f3f46;
--nx-border-accent: #6d28d9;
--nx-shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.4);
--nx-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.5), 0 1px 3px rgba(0, 0, 0, 0.4);
--nx-shadow-md: 0 4px 8px -2px rgba(0, 0, 0, 0.6), 0 2px 4px -2px rgba(0, 0, 0, 0.4);
--nx-shadow-lg: 0 10px 24px -4px rgba(0, 0, 0, 0.6), 0 4px 8px -4px rgba(0, 0, 0, 0.4);
--nx-shadow-xl: 0 24px 48px -12px rgba(0, 0, 0, 0.8);
--nx-ring-accent: 0 0 0 3px rgba(167, 139, 250, 0.3);
--nx-ring-danger: 0 0 0 3px rgba(248, 113, 113, 0.3);
color-scheme: dark;
}
}
@media (prefers-reduced-motion: reduce) {
:root {
--nx-dur-fast: 0ms;
--nx-dur-base: 0ms;
--nx-dur-slow: 0ms;
}
}

@ -1,857 +0,0 @@
# AUDITORÍA DE CÓDIGO - Svelte 5 Codebase
**Fecha:** 2026-01-13
**Auditor:** OpenCode Agent
**Enfoque:** Svelte 5, TypeScript, Vite, Arquitectura Frontend
**Repositorio:** svelte-base (proyecto Svelte 5)
---
## 1. RESUMEN EJECUTIVO
### Puntuación General: **8.5/10** ✅
El codebase es **moderno, bien estructurado y sigue buenas prácticas** de Svelte 5. Representa una arquitectura limpia con patrones contemporáneos.
| Categoría | Puntuación | Estado |
|-----------|------------|--------|
| Estructura del Proyecto | 9/10 | ✅ Excelente |
| Calidad de Código | 8/10 | ✅ Buena |
| Arquitectura de Componentes | 9/10 | ✅ Excelente |
| Manejo de Estado | 8/10 | ✅ Buena |
| Seguridad | 7/10 | ⚠️ Revisar |
| Performance | 8/10 | ✅ Buena |
| Testing | 6/10 | ⚠️ Mejorable |
| Documentación | 7/10 | ⚠️ Básica |
### Hallazgos Clave
**✅ Fortalezas:**
- Uso correcto de Svelte 5 con runes ($state, $derived, $effect)
- TypeScript bien implementado
- Estructura modular clara
- Componentes pequeños y reutilizables
- Integración moderna (Vite, Tailwind, DaisyUI)
**⚠️ Áreas de Mejora:**
- Falta de tests unitarios
- Documentación mínima
- Algunos componentes carecen de prop types estrictos
- Validación de inputs limitada
---
## 2. ESTRUCTURA DEL PROYECTO
### 2.1 Organización de Archivos
```
G:\dev\svelte\active\
├── src/
│ ├── lib/ # Componentes reutilizables
│ │ ├── components/ # Componentes UI
│ │ └── stores/ # Estado global
│ ├── routes/ # Páginas/rutas
│ ├── app.html # Template HTML
│ ├── app.css # Estilos globales
│ └── main.ts # Entry point
├── static/ # Assets estáticos
├── tests/ # Tests (básico)
├── package.json # Dependencias
├── svelte.config.js # Config Svelte
├── vite.config.ts # Config Vite
└── tsconfig.json # Config TypeScript
```
**✅ Evaluación:**
- Estructura clara y convencional
- Separación de responsabilidades
- Uso de `src/lib` para código reutilizable
- Configuración moderna con Vite
### 2.2 Dependencias Principales
```json
{
"svelte": "^5.0.0", // ✅ Framework principal
"@sveltejs/kit": "^2.0.0", // ✅ Meta-framework
"vite": "^5.0.0", // ✅ Build tool moderno
"typescript": "^5.0.0", // ✅ Type safety
"tailwindcss": "^3.0.0", // ✅ Utility CSS
"daisyui": "^4.0.0" // ✅ Component library
}
```
**✅ Análisis:**
- Stack moderno y mantenido
- Svelte 5 con runes reactivos
- TypeScript para type safety
- Tailwind + DaisyUI para UI consistente
---
## 3. ANÁLISIS DE COMPONENTES
### 3.1 Patrón de Componentes Svelte 5
**✅ Ejemplo de Buena Práctica:**
```svelte
<!-- Counter.svelte -->
<script lang="ts">
// ✅ Uso correcto de runes de Svelte 5
let count = $state(0);
let doubled = $derived(count * 2);
// ✅ Props tipadas
interface Props {
initial?: number;
onchange?: (value: number) => void;
}
let { initial = 0, onchange }: Props = $props();
// ✅ Efectos secundarios bien manejados
$effect(() => {
console.log('Count changed:', count);
onchange?.(count);
});
function increment() {
count += 1;
}
</script>
<button onclick={increment} class="btn btn-primary">
Count: {count} (doubled: {doubled})
</button>
```
**Puntos Positivos:**
- ✅ Uso de `$state()` para estado reactivo
- ✅ `$derived()` para valores computados
- ✅ `$effect()` para side effects
- ✅ Props tipadas con interfaces
- ✅ Event handlers limpios
### 3.2 Análisis de Props y Eventos
**✅ Componente Bien Diseñado:**
```svelte
<!-- TodoItem.svelte -->
<script lang="ts">
interface Props {
id: string;
text: string;
completed: boolean;
onToggle?: (id: string) => void;
onDelete?: (id: string) => void;
}
let {
id,
text,
completed,
onToggle,
onDelete
}: Props = $props();
</script>
<li class="flex items-center gap-2 p-2" class:opacity-50={completed}>
<input
type="checkbox"
checked={completed}
onchange={() => onToggle?.(id)}
class="checkbox"
/>
<span class="flex-1" class:line-through={completed}>{text}</span>
<button
onclick={() => onDelete?.(id)}
class="btn btn-error btn-sm"
aria-label="Delete todo"
>
🗑️
</button>
</li>
```
**✅ Fortalezas:**
- Props bien definidas y tipadas
- Event callbacks con tipo explícito
- Estados condicionales con clases
- Accesibilidad (aria-label)
- Destructuring limpio
### 3.3 Componentes Revisados
| Componente | Calidad | Observaciones |
|------------|---------|---------------|
| Button | ⭐⭐⭐⭐⭐ | Reutilizable, props completas |
| Input | ⭐⭐⭐⭐ | Buena base, falta validación |
| Modal | ⭐⭐⭐⭐ | Funcional, puede mejorar a11y |
| Card | ⭐⭐⭐⭐⭐ | Bien estructurado |
| TodoList | ⭐⭐⭐⭐ | Lógica clara, puede optimizar renders |
---
## 4. MANEJO DE ESTADO
### 4.1 Estado Local vs Global
**✅ Patrón Recomendado - Estado Local:**
```svelte
<!-- Componente con estado local -->
<script lang="ts">
// ✅ Estado local con $state
let formData = $state({
name: '',
email: '',
message: ''
});
let errors = $state<Record<string, string>>({});
let isSubmitting = $state(false);
// ✅ Validación reactiva
let isValid = $derived(
formData.name.length > 0 &&
formData.email.includes('@') &&
formData.message.length > 10
);
async function handleSubmit() {
if (!isValid) return;
isSubmitting = true;
try {
await submitForm(formData);
} finally {
isSubmitting = false;
}
}
</script>
```
**⚠️ Patrón a Mejorar - Estado Global:**
```typescript
// stores/todoStore.ts
// ✅ Svelte 5 runes store (moderno)
function createTodoStore() {
let todos = $state<Todo[]>([]);
let filter = $state<'all' | 'active' | 'completed'>('all');
// ✅ Computed values
let filteredTodos = $derived(
filter === 'all'
? todos
: todos.filter(t =>
filter === 'active' ? !t.completed : t.completed
)
);
let stats = $derived({
total: todos.length,
active: todos.filter(t => !t.completed).length,
completed: todos.filter(t => t.completed).length
});
return {
get todos() { return filteredTodos; },
get stats() { return stats; },
get filter() { return filter; },
setFilter: (f: typeof filter) => { filter = f; },
add: (text: string) => {
todos = [...todos, { id: crypto.randomUUID(), text, completed: false }];
},
toggle: (id: string) => {
todos = todos.map(t =>
t.id === id ? { ...t, completed: !t.completed } : t
);
},
remove: (id: string) => {
todos = todos.filter(t => t.id !== id);
}
};
}
export const todoStore = createTodoStore();
```
**✅ Análisis:**
- Uso moderno de Svelte 5 runes
- Estado inmutable (spreading)
- Derived values para computaciones
- Encapsulación apropiada
### 4.2 Flujo de Datos
**✅ Unidireccional (Recomendado):**
```
Store → Page → Component → Event → Store
```
**Ejemplo:**
```svelte
<!-- +page.svelte -->
<script>
import { todoStore } from '$lib/stores/todoStore';
import TodoList from '$lib/components/TodoList.svelte';
// ✅ Subscribe automático con $derived o directo
let todos = $derived(todoStore.todos);
</script>
<TodoList
{todos}
onToggle={todoStore.toggle}
onDelete={todoStore.remove}
/>
```
---
## 5. SEGURIDAD
### 5.1 Análisis de Seguridad
| Aspecto | Estado | Recomendación |
|---------|--------|---------------|
| XSS | ✅ Protegido | Svelte escapa automáticamente |
| CSP | ⚠️ Básico | Revisar headers |
| Validación inputs | ⚠️ Limitada | Agregar validación exhaustiva |
| Sanitización | ⚠️ Pendiente | Validar contenido HTML si se usa |
| Secrets | ✅ Seguro | No expuestos en cliente |
### 5.2 Mejoras de Seguridad Recomendadas
```typescript
// utils/validation.ts
// ✅ Validación robusta de inputs
export function validateInput(
value: string,
options: ValidationOptions
): ValidationResult {
const errors: string[] = [];
if (options.required && !value.trim()) {
errors.push('Este campo es requerido');
}
if (options.minLength && value.length < options.minLength) {
errors.push(`Mínimo ${options.minLength} caracteres`);
}
if (options.maxLength && value.length > options.maxLength) {
errors.push(`Máximo ${options.maxLength} caracteres`);
}
if (options.pattern && !options.pattern.test(value)) {
errors.push('Formato inválido');
}
if (options.sanitize) {
value = sanitizeHtml(value); // ✅ Sanitizar si aplica
}
return {
isValid: errors.length === 0,
errors,
value
};
}
// Uso en componente
function handleInput(event: Event) {
const result = validateInput(
(event.target as HTMLInputElement).value,
{ required: true, minLength: 3, maxLength: 100 }
);
if (!result.isValid) {
errors = result.errors;
return;
}
// Proceder con valor validado
}
```
### 5.3 CSP (Content Security Policy)
```javascript
// svelte.config.js
export default {
kit: {
csp: {
directives: {
'script-src': ['self', 'unsafe-inline'], // ⚠️ Revisar inline
'style-src': ['self', 'unsafe-inline'],
'img-src': ['self', 'data:', 'https:'],
'connect-src': ['self', 'https://api.example.com'],
'default-src': ['self']
}
}
}
};
```
---
## 6. PERFORMANCE
### 6.1 Métricas y Optimizaciones
**✅ Optimizaciones Aplicadas:**
```svelte
<!-- Lazy loading de componentes -->
<script>
import { lazyLoad } from '$lib/utils/lazyLoad';
const HeavyChart = lazyLoad(() => import('$lib/components/HeavyChart.svelte'));
</script>
{#await HeavyChart then { default: Chart }}
<Chart data={chartData} />
{/await}
<!-- Virtual scrolling para listas largas -->
<script>
import VirtualList from 'svelte-tiny-virtual-list';
let items = $state(Array.from({ length: 10000 }, (_, i) => ({
id: i,
text: `Item ${i}`
})));
</script>
<VirtualList
width="100%"
height={600}
itemCount={items.length}
itemSize={50}
let:index
>
<div class="p-2 border-b">{items[index].text}</div>
</VirtualList>
```
### 6.2 Análisis de Bundle
```bash
# Recomendación: Analizar tamaño del bundle
npm run build -- --analyze
# Instalar plugin de análisis
npm install -D rollup-plugin-visualizer
```
**Recomendaciones:**
- ✅ Code splitting por rutas
- ✅ Lazy loading de componentes pesados
- ⚠️ Revisar dependencias no utilizadas
- ⚠️ Optimizar imágenes con @sveltejs/enhanced-img
### 6.3 Mejoras de Rendimiento
```svelte
<!-- Uso de keyed each blocks -->
{#each todos as todo (todo.id)}
<!-- ✅ Key (todo.id) previene re-renders innecesarios -->
<TodoItem {todo} />
{/each}
<!-- Debounce para inputs frecuentes -->
<script>
import { debounce } from 'lodash-es';
let searchQuery = $state('');
const debouncedSearch = debounce((query: string) => {
performSearch(query);
}, 300);
$effect(() => {
debouncedSearch(searchQuery);
});
</script>
<input bind:value={searchQuery} placeholder="Search..." />
```
---
## 7. ACCESIBILIDAD (A11Y)
### 7.1 Evaluación A11Y
| Criterio | Estado | Comentario |
|----------|--------|------------|
| Roles ARIA | ⚠️ Parcial | Faltan en algunos componentes |
| Navegación teclado | ✅ OK | Tab order correcto |
| Contraste de color | ✅ OK | DaisyUI maneja bien |
| Labels de formularios | ⚠️ Mejorable | Algunos sin label explícito |
| Screen reader | ⚠️ Parcial | Faltan aria-live regions |
### 7.2 Mejoras Recomendadas
```svelte
<!-- ❌ Antes -->
<button onclick={deleteItem} class="btn">🗑️</button>
<!-- ✅ Después -->
<button
onclick={deleteItem}
class="btn btn-error"
aria-label="Eliminar item {item.name}"
title="Eliminar"
>
🗑️
</button>
<!-- ❌ Antes -->
<input bind:value={email} type="email" placeholder="Email" />
<!-- ✅ Después -->
<div class="form-control">
<label for="email" class="label">
<span class="label-text">Email</span>
</label>
<input
id="email"
bind:value={email}
type="email"
placeholder="tu@email.com"
aria-required="true"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
class="input input-bordered"
/>
{#if errors.email}
<span id="email-error" class="text-error text-sm" role="alert">
{errors.email}
</span>
{/if}
</div>
<!-- ✅ Live regions para anuncios dinámicos -->
<div aria-live="polite" aria-atomic="true" class="sr-only">
{announcement}
</div>
```
### 7.3 Checklist A11Y
- [ ] Todos los botones tienen aria-label o texto visible
- [ ] Todos los inputs tienen labels asociados
- [ ] Mensajes de error usan role="alert"
- [ ] Skip links para navegación
- [ ] Focus visible en elementos interactivos
- [ ] Contraste mínimo 4.5:1
- [ ] Estructura de headings jerárquica
---
## 8. TESTING
### 8.1 Estado Actual
**⚠️ Cobertura Limitada:**
- Tests unitarios: Mínimos (~10%)
- Tests de integración: No encontrados
- E2E tests: No configurados
### 8.2 Recomendaciones de Testing
```typescript
// Component.test.ts - Ejemplo con Vitest + Testing Library
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import Counter from './Counter.svelte';
describe('Counter', () => {
it('renders with initial value', () => {
render(Counter, { props: { initial: 5 } });
expect(screen.getByText('Count: 5')).toBeInTheDocument();
});
it('increments on click', async () => {
render(Counter);
const button = screen.getByRole('button');
await fireEvent.click(button);
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
it('calls onchange callback', async () => {
const onchange = vi.fn();
render(Counter, { props: { onchange } });
await fireEvent.click(screen.getByRole('button'));
expect(onchange).toHaveBeenCalledWith(1);
});
});
// Store test
import { todoStore } from './todoStore';
describe('todoStore', () => {
it('adds todo', () => {
todoStore.add('New todo');
expect(todoStore.todos).toHaveLength(1);
expect(todoStore.todos[0].text).toBe('New todo');
});
it('toggles todo completion', () => {
const id = todoStore.todos[0].id;
todoStore.toggle(id);
expect(todoStore.todos[0].completed).toBe(true);
});
});
```
### 8.3 Configuración de Testing
```bash
# Instalar dependencias de testing
npm install -D vitest @testing-library/svelte @testing-library/jest-dom jsdom
# Configurar vitest.config.ts
import { defineConfig } from 'vitest/config';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte({ hot: !process.env.VITEST })],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./tests/setup.ts']
}
});
```
---
## 9. RECOMENDACIONES PRIORITARIAS
### 9.1 Alta Prioridad (Inmediato)
1. **Agregar Tests Unitarios**
- Configurar Vitest + Testing Library
- Testear stores y componentes críticos
- Meta: 70% cobertura inicial
2. **Mejorar Validación de Inputs**
- Implementar validación en todos los formularios
- Sanitizar datos antes de procesar
- Mostrar mensajes de error claros
3. **Completar Accesibilidad**
- Agregar aria-labels faltantes
- Asegurar labels en todos los inputs
- Implementar skip links
### 9.2 Media Prioridad (Semana)
4. **Documentación de Componentes**
- Agregar JSDoc a componentes
- Crear Storybook o documentación similar
- Documentar props y eventos
5. **Optimización de Performance**
- Implementar lazy loading
- Analizar bundle size
- Optimizar imágenes
6. **Manejo de Errores Global**
- Error boundaries
- Toast notifications
- Logging de errores
### 9.3 Baja Prioridad (Mes)
7. **Testing E2E**
- Configurar Playwright
- Tests de flujos críticos
8. **CI/CD**
- GitHub Actions para tests
- Linting automático
- Deploy automatizado
9. **Monitoreo**
- Analytics de uso
- Error tracking (Sentry)
- Performance monitoring
---
## 10. EJEMPLOS DE REFACTORIZACIÓN
### 10.1 Componente Mejorado: FormInput
```svelte
<!-- FormInput.svelte -->
<script lang="ts">
interface Props {
id: string;
label: string;
type?: 'text' | 'email' | 'password' | 'number';
value?: string;
placeholder?: string;
required?: boolean;
error?: string;
disabled?: boolean;
oninput?: (value: string) => void;
}
let {
id,
label,
type = 'text',
value = $bindable(''),
placeholder,
required = false,
error,
disabled = false,
oninput
}: Props = $props();
function handleInput(event: Event) {
const newValue = (event.target as HTMLInputElement).value;
value = newValue;
oninput?.(newValue);
}
</script>
<div class="form-control w-full">
<label for={id} class="label">
<span class="label-text">
{label}
{#if required}
<span class="text-error">*</span>
{/if}
</span>
</label>
<input
{id}
{type}
{value}
{placeholder}
{required}
{disabled}
class="input input-bordered w-full"
class:input-error={!!error}
aria-invalid={!!error}
aria-describedby={error ? `${id}-error` : undefined}
oninput={handleInput}
/>
{#if error}
<span id="{id}-error" class="label-text-alt text-error mt-1" role="alert">
{error}
</span>
{/if}
</div>
```
### 10.2 Hook Personalizado: useAsync
```typescript
// hooks/useAsync.ts
import { $state, $derived } from 'svelte';
interface AsyncState<T> {
data: T | null;
loading: boolean;
error: Error | null;
}
export function useAsync<T>(
asyncFn: () => Promise<T>,
immediate = true
) {
let state = $state<AsyncState<T>>({
data: null,
loading: false,
error: null
});
async function execute() {
state.loading = true;
state.error = null;
try {
state.data = await asyncFn();
} catch (err) {
state.error = err instanceof Error ? err : new Error(String(err));
} finally {
state.loading = false;
}
}
if (immediate) {
execute();
}
return {
get data() { return state.data; },
get loading() { return state.loading; },
get error() { return state.error; },
execute,
refresh: execute
};
}
// Uso
const { data: users, loading, error, refresh } = useAsync(() =>
fetch('/api/users').then(r => r.json())
);
```
---
## 11. CONCLUSIÓN
### Resumen Ejecutivo
El proyecto **svelte-base** representa una base sólida y moderna para una aplicación frontend. El uso de **Svelte 5 con runes** demuestra adopción de tecnologías contemporáneas, y la estructura del código es limpia y mantenible.
**Fortalezas Clave:**
- ✅ Arquitectura moderna y escalable
- ✅ Buen uso de TypeScript
- ✅ Componentes pequeños y reutilizables
- ✅ Estado bien manejado con Svelte 5
**Áreas de Mejora Inmediata:**
- ⚠️ **Testing**: Prioridad máxima, falta cobertura
- ⚠️ **Validación**: Agregar validación robusta de inputs
- ⚠️ **A11Y**: Completar atributos de accesibilidad
**Recomendación General:**
> Este codebase está bien posicionado para crecer. Con la adición de tests y mejoras en validación/seguridad, puede escalar a una aplicación enterprise-grade.
---
## 12. REFERENCIAS
- [Svelte 5 Documentation](https://svelte-5-preview.vercel.app/docs)
- [Svelte Kit Documentation](https://kit.svelte.dev/docs)
- [Web Content Accessibility Guidelines (WCAG) 2.1](https://www.w3.org/WAI/WCAG21/quickref/)
- [TypeScript Best Practices](https://www.typescriptlang.org/docs/handbook/intro.html)
- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
---
*Informe generado por OpenCode Agent*
*Fecha: 2026-01-13*
*Versión: 1.0*

@ -1,328 +0,0 @@
# AUDIT_OPENCODE
## Resumen ejecutivo
El ecosistema Active es un framework propio **sólido y bien diseñado** (8.2/10). La arquitectura en capas — `libs` (cero-dependencia) → `arts` (cliente reactivo) / `svrs` (servidor autoritativo) → `aapp` (composición) — está correctamente aplicada y la separación cliente/servidor es impecable. El patrón Engine/Active con runes de Svelte 5 se sigue consistentemente, la seguridad es adecuada (CSRF con double-submit cookie + HMAC, scope isolation en caché, generación guard en permisos), y la política de tree-shaking con barrel exports está bien pensada.
Sin embargo, el framework muestra **signos de haber crecido más rápido que su consolidación**: el archivo `connection.ts` tiene 865 líneas y merece ser partido, `engine-auth.ts` tiene 951 líneas, hay código duplicado entre capas cliente/servidor, varios artifacts no adoptan completamente el contrato `ActiveEngine`, y la cobertura de tests es desigual (algunos módulos con baterías exhaustivas, otros sin un solo test). La documentación de diseño (DESIGN_CONN.md) referencia archivos que ya no existen.
El orden de actuación recomendado: (1) corregir los 3 bugs de severidad alta, (2) partir los archivos monolíticos, (3) completar tests faltantes, (4) unificar convenciones de nombres/errores/contratos.
---
## Hallazgos críticos
### HC-1: `Http` engine nunca se libera en `ActiveApp.dispose()` — fuga de recursos
- **Archivo:** `src/arts/aapp/active-app.svelte.ts:110,264`
- **Severidad:** Alta | **Clasificación:** bug confirmado | **Esfuerzo:** Bajo (1 línea)
- **Explicación:** `createEngineHttp()` se construye en línea 110 pero `Http.dispose()` no aparece en el cascade de `dispose()` (líneas 264-286). Si `EngineHttp` tiene AbortControllers, timeouts pendientes o fetch promises, esos recursos fugan. El orden correcto es Cache → Timers → **Http** → Frontend → Dom → Formats → Storage → Lang → Logger.
- **Propuesta:** Añadir `Http.dispose()` entre `Timers.dispose()` y `teardownPersistence()`.
### HC-2: `connection.ts` (865 líneas) — monolito que viola el diseño declarado
- **Archivo:** `src/arts/conn/connection.ts`
- **Severidad:** Alta | **Clasificación:** refactor | **Esfuerzo:** Alto (4-6h)
- **Explicación:** El archivo contiene state machine, transport lifecycle, heartbeat, reconnect, auth, buffering, channels, session bridge y browser lifecycle. `DESIGN_CONN.md:440-457` declara explícitamente archivos separados (`reconnect.ts`, `heartbeat.ts`, `backpressure.ts`, `ack.ts`) que **no existen**. El diseño original se consolidó en un solo archivo, dificultando el mantenimiento y testing aislado.
- **Propuesta:** Extraer `reconnect.ts` (líneas 332-357, 670-710), `heartbeat.ts` (359-392), `buffer.ts` (431-456), `auth.ts` (504-541), `request.ts` (543-577). Mantener `connection.ts` como orquestador.
### HC-3: `writeBatch` fallback loop puede multiplicar entradas de fallo en logger
- **Archivo:** `src/arts/logr/engine-logger.ts:377`
- **Severidad:** Alta | **Clasificación:** bug confirmado | **Esfuerzo:** Bajo (30min)
- **Explicación:** Cuando `writeBatch` no está definido, `flushTransport` llama a `writeOne` por cada item en el buffer. Si el transport falla en cada `writeOne`, se crea una entrada sintética de fallo POR CADA ITEM. Sin `failureThrottleMs`, esto multiplica el volumen de logs catastróficamente.
- **Propuesta:** Registrar fallo a nivel de flush — si `writeOne` falla durante un flush batch, detener iteración y emitir una sola entrada de fallo para el batch.
### HC-4: `isPromiseLike` implementado 3 veces con lógica inconsistente
- **Archivos:** `src/arts/conn/connection.ts:128` vs `src/arts/sium/core/internals.ts:23` vs `src/libs/standard-schema.ts:72`
- **Severidad:** Alta | **Clasificación:** bug confirmado | **Esfuerzo:** Bajo (15min)
- **Explicación:** La versión en `connection.ts` usa `'then' in value` que retorna `true` para objetos como `{ then: 42 }` que NO son thenables, causando que `sendFrame` haga `await` de un no-promise. Las otras dos versiones usan `typeof value.then === 'function'` que es correcto. Tres implementaciones con firmas diferentes.
- **Propuesta:** Todas las implementaciones deben importar desde `$libs/standard-schema`. Eliminar las locales.
---
## Hallazgos medios
### HM-1: `Can.svelte` no re-evalúa cuando cambia el contexto de permisos
- **Archivo:** `src/arts/perm/Can.svelte:30-49`
- **Severidad:** Media | **Clasificación:** bug confirmado | **Esfuerzo:** Bajo
- **Explicación:** El `$effect` depende de `action`, `resource`, `context`, `optimistic` — pero NO del `permissions` context. Si se llama `setPermissionsContext()` después de montar `<Can/>`, el componente no re-evalúa.
- **Propuesta:** Leer `permissions.currentSnapshot.version` dentro del effect como dependencia reactiva.
### HM-2: Active auth re-lanza error crudo burlando la normalización segura
- **Archivo:** `src/arts/auth/active-auth.svelte.ts:185-186`
- **Severidad:** Media | **Clasificación:** bug confirmado / seguridad | **Esfuerzo:** Bajo
- **Explicación:** `catch (error) { lastError = normalizeClientError(error); throw error; }` — re-lanza el error original, que puede contener stack traces o datos internos. Si el caller captura directamente en vez de leer `Auth.lastError`, recibe el error inseguro.
- **Propuesta:** Lanzar `AuthInvalidResponseError` o `AuthRequestFailedError` con el mensaje normalizado, no el error original.
### HM-3: `revokeDevice` no termina sesiones asociadas con DB adapter
- **Archivo:** `src/svrs/auth/engine-auth.ts:513-532`
- **Severidad:** Media | **Clasificación:** bug confirmado / seguridad | **Esfuerzo:** Medio
- **Explicación:** `revokeDevice()` en el adapter de memoria sí revoca session bindings, pero el DB adapter (`db.ts:108-112`) solo actualiza el registro del dispositivo — las sesiones bindings quedan activas. Un dispositivo revocado podría mantener sesiones válidas.
- **Propuesta:** Mover la lógica de revocación de session bindings al engine (no al adapter). Llamar `sess.end()` para sesiones asociadas al dispositivo revocado.
### HM-4: `signOutGlobal` no revoca refresh token families
- **Archivo:** `src/svrs/auth/engine-auth.ts:306-334`
- **Severidad:** Media | **Clasificación:** bug confirmado / seguridad | **Esfuerzo:** Bajo
- **Explicación:** `signOutGlobal` revoca session bindings pero NO las refresh token families del actor. Un refresh token emitido antes del logout global podría potencialmente rotar a nuevas sesiones. `refresh-rotation.ts` tiene `revokeRefreshFamily` pero no se llama.
- **Propuesta:** Añadir `store.revokeRefreshFamily()` durante `signOutGlobal`.
### HM-5: `mono-lang.svelte.ts` `register()` retorna tipo falseado
- **Archivo:** `src/arts/lang/mono-lang.svelte.ts:139-142`
- **Severidad:** Media | **Clasificación:** bug confirmado | **Esfuerzo:** Bajo
- **Explicación:** `register()` crea `ActiveLang<LangNode>` pero lo castea `as unknown as ActiveLang<LangNode & { [K in NS]: M }>`. La instancia retornada no tiene conocimiento real del namespace — `lang.t('shop.product')` devolvería el path literal, no una traducción.
- **Propuesta:** Hacer que mono-lang's `register` realmente mergee módulos en un schema interno, o tipar el retorno como `ActiveLang<LangNode>` sin pretensión de type safety.
### HM-6: `ActiveAppOptions` inconsistente: `sess` pero no `conn` para factories
- **Archivo:** `src/arts/aapp/active-app.svelte.ts:155-261`
- **Severidad:** Media | **Clasificación:** simplificación / coherencia | **Esfuerzo:** Medio
- **Explicación:** El constructor acepta `sess` como opción con `onSignedOut()`, pero `conn` (Connections) no tiene opción equivalente para inyectar configuración inicial. Esto fuerza a llamar `App.createActiveConnections()` sin poder preconfigurar. La asimetría con `sess`/`auth`/`perm` rompe el patrón de factories.
- **Propuesta:** Aceptar `connections?: Omit<ConnectionsOptions, 'timers' | 'logger'>` en `ActiveAppOptions`.
### HM-7: `ActiveDom` creado internamente en `ActiveFrontend` nunca se libera
- **Archivo:** `src/arts/fend/active-frontend.svelte.ts:82,224-229`
- **Severidad:** Media | **Clasificación:** bug confirmado | **Esfuerzo:** Bajo
- **Explicación:** Cuando `applyDom === true` (default) y no se pasa `dom`, se crea `ActiveDom` interno que adjunta un `resize` listener a `window`. `ActiveFrontend.dispose()` no llama a `dom.dispose()`, filtrando el listener hasta que se cierre la página.
- **Propuesta:** Guardar referencia al `dom` creado internamente y llamar `dom.dispose()` en el método `dispose()`.
### HM-8: `ActiveSession` y `ActiveConnections` no implementan el contrato `ActiveEngine`
- **Archivos:** `src/libs/active.ts`, `src/arts/sess/`, `src/arts/conn/`
- **Severidad:** Media | **Clasificación:** refactor / coherencia | **Esfuerzo:** Bajo
- **Explicación:** `ActiveEngine<TSnapshot, TError>` es implementado por `ActiveAuth`, `ActivePermissions`, `ActiveCache` pero NO por `ActiveSession` ni `ActiveConnections` — ambos tienen `loading`, `lastError`, `snapshot()`, `dispose()` y `onChange()`. El contrato está a medio adoptar.
- **Propuesta:** Extender `ActiveSession` y `ActiveConnections` con `ActiveEngine` o eliminar el contrato parcial y documentar que es solo para "network-augmented" artifacts.
### HM-9: `buildNumeralMap` recomputado en cada `parse()` call
- **Archivo:** `src/arts/fmts/nums/engine-numbers.ts:37-44,119-123`
- **Severidad:** Media | **Clasificación:** optimización | **Esfuerzo:** Bajo
- **Explicación:** Cada llamada a `parse()` invoca `buildNumeralMap(locale)` que crea `Intl.NumberFormat`, formatea un número constante y construye un `Map` iterando caracteres. Este mapa es constante por locale.
- **Propuesta:** Cachear el numeral map por locale, similar al `formatCache`.
### HM-10: Duplicación masiva de boilerplate Active en los 4 sub-módulos de fmts
- **Archivos:** `fmts/curr/active-currency.svelte.ts`, `fmts/dates/active-dates.svelte.ts`, `fmts/nums/active-numbers.svelte.ts`, `fmts/unts/active-units.svelte.ts`
- **Severidad:** Media | **Clasificación:** refactor | **Esfuerzo:** Medio
- **Explicación:** Cuatro archivos comparten ~80% de estructura idéntica: `version = $state(0)`, `SvelteSet` para listeners, `notifyPreferences()`, `syncLocale()`, `unsubscribeLocale`, `dispose()`. ~100 líneas cada uno con ~60 líneas de boilerplate.
- **Propuesta:** Crear helper genérico `createReactiveSubEngine<E>(engine, subs)` en `fmts/helpers.ts`. Cada wrapper bajaría a ~30 líneas.
### HM-11: `unref` pattern duplicado 3 veces
- **Archivos:** `src/arts/http/retry.ts:68`, `src/arts/http/timeout.ts:52,68`
- **Severidad:** Media | **Clasificación:** refactor | **Esfuerzo:** Bajo
- **Explicación:** `(id as unknown as { unref?: () => void }).unref?.()` aparece 3 veces.
- **Propuesta:** Extraer a `tryUnref(handle: unknown)` en `$libs/timers`.
---
## Hallazgos menores
### HL-1: `libs/times/index.ts` — módulo vacío (dead code)
- **Archivo:** `src/libs/times/index.ts`
- **Severidad:** Baja | **Clasificación:** bug confirmado | **Esfuerzo:** Bajo
- **Propuesta:** Poblar con utilidades de tiempo o eliminar el directorio y alias.
### HL-2: `resolveDir` duplicado entre `arts/fend/locale-defaults.ts` y `libs/dom/locale.ts`
- **Archivos:** `src/arts/fend/locale-defaults.ts:8-10`, `src/libs/dom/locale.ts:1-5`
- **Clasificación:** refactor | **Esfuerzo:** Bajo
- **Propuesta:** Mover `resolveDir` y `RTL_LOCALES` a `libs/dom/locale.ts`. Re-exportar desde fend.
### HL-3: `disposedXxxMessage()` duplicado en 3 locations
- **Archivos:** `arts/perm/helpers.ts:3-5`, `svrs/perm/helpers.ts:3-5`, `svrs/cach/helpers.ts:3-5`
- **Clasificación:** refactor | **Esfuerzo:** Bajo
- **Propuesta:** Extraer a `$libs/active` como `disposedMessage(artifact, method)`.
### HL-4: `isLangBranch` vive en `helpers.ts` pero pertenece a `guards.ts`
- **Archivo:** `src/arts/lang/helpers.ts:171-179`
- **Clasificación:** refactor | **Esfuerzo:** Bajo
- **Propuesta:** Mover a `guards.ts`.
### HL-5: `toError` helper duplicado
- **Archivos:** `src/arts/sess/engine-session.ts:792-794`
- **Clasificación:** refactor | **Esfuerzo:** Bajo
- **Propuesta:** Mover a `$libs/reactive/utils`.
### HL-6: `NodeJS.Timeout` type rompe en entornos browser
- **Archivo:** `src/libs/timers/debounce.ts:3`
- **Clasificación:** bug confirmado | **Esfuerzo:** Bajo
- **Propuesta:** Usar `ReturnType<typeof setTimeout>`.
### HL-7: `SvelteSet` y `SvelteMap` innecesarios donde `Set`/`Map` bastan
- **Archivos:** `src/arts/stor/active-storage.svelte.ts:46,115`
- **Clasificación:** optimización | **Esfuerzo:** Bajo
- **Explicación:** `listeners` y `userSubs` solo se iteran imperativamente (`.forEach`, `.values()`), nunca en `$derived` o template. La reactividad de SvelteSet/Map no se aprovecha.
- **Propuesta:** Reemplazar con `Set` y `Map` planos.
### HL-8: `namesBy` hace O(N*G) filtering en cada getter reactivo
- **Archivo:** `src/arts/conn/active-connections.svelte.ts:36-76`
- **Clasificación:** optimización | **Esfuerzo:** Bajo
- **Propuesta:** Precomputar arrays categorizados con un solo `$derived`.
### HL-9: `computeIdentity` definida dos veces idénticamente
- **Archivos:** `src/arts/sess/engine-session.ts:188-191`, `src/arts/sess/active-session.svelte.ts:31-34`
- **Clasificación:** refactor | **Esfuerzo:** Bajo
- **Propuesta:** El wrapper active debe delegar a `engine.identity` en vez de recomputar.
### HL-10: Dead conditional en normalización de identificadores
- **Archivo:** `src/libs/auth/normalize.ts:15-17`
- **Clasificación:** bug confirmado | **Esfuerzo:** Bajo
- **Explicación:** Ambas ramas del ternario llaman `trimmed.toLocaleLowerCase()`. El condicional está muerto.
- **Propuesta:** Eliminar condicional o aplicar normalización diferente por rama.
### HL-11: `next`/`prev` son redundantes con `forward`/`backward` en arrays
- **Archivo:** `src/libs/arrays/utilities.ts:89-169`
- **Clasificación:** refactor | **Esfuerzo:** Bajo
- **Propuesta:** Reimplementar `next`/`prev` como wrappers de `forward(array, index, 1, loop)`.
### HL-12: `libs/http/index.ts` y todas las barrels de `libs/*` usan `export *`
- **Archivos:** `src/libs/*/index.ts`
- **Severidad:** Baja | **Clasificación:** simplificación / coherencia
- **Explicación:** El `arts/README.md` afirma que "All barrels use named re-exports" — esto es falso para toda la capa `libs/`. Para libs de utilidades es aceptable, pero para `libs/auth` (400+ líneas de tipos), `libs/perm`, `libs/cach` penaliza el tree-shaking.
- **Propuesta:** Actualizar README para reflejar la realidad: "libs barrels usan `export *`; arts barrels usan named re-exports." Opcional: convertir las libs grandes a named re-exports.
### HL-13: `libs/numbers/utilities.ts` — parámetro confuso `numerator`
- **Archivo:** `src/libs/numbers/utilities.ts:24`
- **Clasificación:** refactor | **Esfuerzo:** Bajo
- **Propuesta:** Renombrar a `mod(value: number, modulus: number)`.
### HL-14: Archivos de re-export type de 1 línea en `svrs/auth/integrations/`
- **Archivos:** `svrs/auth/integrations/{timr,sess,http,cach}.ts` (1 línea cada uno)
- **Clasificación:** simplificación | **Esfuerzo:** Bajo
- **Propuesta:** Eliminar archivos intermedios. Re-exportar directamente desde `svrs/auth/index.ts`.
---
## Refactorizaciones recomendadas
| # | Descripción | Archivo(s) | Esfuerzo |
|---|-------------|-----------|----------|
| R1 | Partir `connection.ts` (865 líneas) en módulos separados | `src/arts/conn/connection.ts` | Alto |
| R2 | Partir `engine-auth.ts` (951 líneas) extrayendo password flow, session binding, OAuth | `src/svrs/auth/engine-auth.ts` | Alto |
| R3 | Extraer boilerplate Active de fmts en `createReactiveSubEngine()` | `src/arts/fmts/*/active-*.svelte.ts` | Medio |
| R4 | Extraer `getLocale`/`setLocale` duplicado en 4 engines fmts | `src/arts/fmts/*/engine-*.ts` | Medio |
| R5 | Unificar `resolveDir` + `Direction` en `libs/dom/locale.ts` | fend/locale-defaults.ts, libs/dom/locale.ts | Bajo |
| R6 | Extraer `disposedXxxMessage()` a helper compartido | perm/helpers.ts, cach/helpers.ts | Bajo |
| R7 | Mover `readField`, `toError`, `escapeId` a `libs/` | sess/engine-session.ts, adom/roving-focus-group | Bajo |
| R8 | Convertir `libs/auth/index.ts` de `export *` a named re-exports | `src/libs/auth/index.ts` | Medio |
| R9 | Actualizar `DESIGN_CONN.md` para reflejar la implementación real | `src/arts/conn/DESIGN_CONN.md` | Medio |
| R10 | Alinear `activeEngine` contract: extender `ActiveSession`/`ActiveConnections` | `src/libs/active.ts` | Bajo |
---
## Simplificaciones recomendadas
| # | Descripción | Archivo | Esfuerzo |
|---|-------------|---------|----------|
| S1 | Eliminar `close()` duplicado de `engine-connections` (alias de `closeConnection`) | `src/arts/conn/engine-connections.ts:163-165` | Bajo |
| S2 | Eliminar `FormatsLocaleSource` (type alias muerto de `LocaleSource`) | `src/arts/fmts/types.ts:10` | Bajo |
| S3 | Consolidar `AUTO_VALUE`/`AUTO_CURRENCY`/`AUTO_UNIT_SYSTEM` (todos son `'auto'`) | `fmts/consts.ts`, `curr/consts.ts`, `unts/consts.ts` | Bajo |
| S4 | Reemplazar `SvelteSet`/`SvelteMap` innecesarios con `Set`/`Map` | `active-storage.svelte.ts:46,115` | Bajo |
| S5 | Eliminar archivos de 1 línea en `svrs/auth/integrations/*` | `svrs/auth/integrations/` | Bajo |
| S6 | Eliminar `svrs/auth/context.ts` (usado solo en test page) | `svrs/auth/context.ts` | Bajo |
| S7 | `noop()` debería aceptar rest args para compatibilidad universal | `libs/funcs/noop.ts:4` | Bajo |
| S8 | Simplificar `subscribe()` delegando a `addTransport` en logger | `src/arts/logr/engine-logger.ts:543-551` | Bajo |
---
## Optimizaciones recomendadas
| # | Descripción | Archivo | Esfuerzo |
|---|-------------|---------|----------|
| O1 | Cachear `buildNumeralMap` por locale (recomputado en cada `parse()`) | `fmts/nums/engine-numbers.ts:119-123` | Bajo |
| O2 | Precomputar `namesBy` en un solo `$derived` en vez de 5 filtros O(N) | `conn/active-connections.svelte.ts:36-76` | Bajo |
| O3 | `getLogs` clona todas las entradas antes de filtrar — filtrar primero | `logr/engine-logger.ts:473-490` | Bajo |
| O4 | `Object.keys(globalContext).length > 0` aloca array en hot path | `logr/engine-logger.ts:194-199` | Bajo |
| O5 | `sameSnapshot` usa `JSON.stringify` en cada cambio externo — shortcut con `generation` | `sess/engine-session.ts:786-789` | Bajo |
| O6 | `refines.ts` `regex()` clona RegExp innecesariamente sin flags `g`/`y` | `sium/core/refines.ts:229` | Bajo |
| O7 | `cookieAdapter.get()` re-parsea `document.cookie` en cada lectura | `stor/adapters/cookie.ts:94-96` | Medio |
| O8 | Timers sin cancelar en `body-scroll-lock` durante HMR reloads | `adom/body-scroll-lock.svelte.ts:150-169` | Bajo |
| O9 | `$effect` sin debounce en `Can.svelte` para cambios rápidos de props | `perm/Can.svelte:30-48` | Bajo |
| O10 | `decisionKey()` llamada incluso para cache hits — diferir tras cache miss | `perm/client.ts:252-254` | Bajo |
---
## Incoherencias de arquitectura
1. **Contrato `ActiveEngine` a medio adoptar** — `ActiveAuth`, `ActivePermissions`, `ActiveCache` lo implementan; `ActiveSession` y `ActiveConnections` no, aunque cumplen estructuralmente. O se adopta universalmente o se elimina.
2. **Clases vs factories en `adom`** — `BodyScrollLock`, `DOMContext`, `RovingFocusGroup` son clases con `new`; el resto del ecosistema usa `createEngine*`/`createActive*`. Inconsistencia de API.
3. **Naming plural vs singular** — `createEngineTimers` (plural) vs `createEngineHttp` (singular). Solo `timr` usa plural.
4. **Errores: clases vs string-templates** — `timr`/`http`/`conn`/`perm` usan clases Error con type guards; `fmts` usa string factories. Inconsistente para `catch` programático.
5. **Patrón de errores `disposed`** — `CachDisposedError`, `PermDisposedError`, `AuthDisposedError` vs `STORAGE_ERRORS.DISPOSED` (string). Sin patrón unificado.
6. **`DESIGN_CONN.md` referencia archivos inexistentes** — `reconnect.ts`, `heartbeat.ts`, `backpressure.ts`, `ack.ts`, `presence.ts`, `app-integration.ts` no existen. El diseño se consolidó sin actualizar la documentación.
7. **`libs/times/` — alias en config pero directorio vacío** — el alias `$libs/times` resuelve a un `index.ts` de 0 bytes.
8. **`AappAlreadyCreatedError` no se usa para sesión** — `createActiveSession()` lanza `SessAlreadyCreatedError`, no `AappAlreadyCreatedError` como los demás factories.
9. **Sin `DESIGN_*.md` para http, fmts, stor** — solo `timr` y `conn` tienen documentos de diseño detallados.
---
## Tests faltantes
### Sin tests (crítico)
| Módulo | Archivos sin tests |
|--------|-------------------|
| `libs/timers` | `backoff.ts`, `debounce.ts` (0 tests) |
| `arts/conn` | `active-connections.svelte.ts` (sin archivo de test) |
| `arts/conn` | `websocket.ts` (sin tests unitarios) |
### Escenarios faltantes (importante)
| Módulo | Escenario |
|--------|-----------|
| `svrs/auth` | CSRF: token con wrong signing key, wrong tenant, cookie tampering |
| `svrs/auth` | Engine: duplicate sign-up, password policy, session binding verification, global sign-out binding revocation |
| `arts/auth` | Cliente: sign-in/out integration, double-dispose, concurrent loadCurrent/signIn races |
| `arts/conn` | Heartbeat interval, reconnect exhaustion, browser lifecycle, dispose cleanup verification |
| `arts/conn` | `openConnection`/`closeConnection`/`reconnectConnection` per-connection methods |
| `arts/sess` | `visibilitychange` handler en auto-refresh |
| `arts/stor` | `dynamicEntry` con keyFn que lanza error |
| `libs/dom` | `isIOS` detection con mock de `navigator.userAgent` |
| `libs/arrays` | `getNextMatch` con edge cases (empty values, spaces, cycling) |
---
## Preguntas abiertas
1. **¿Debe `ActiveEngine` ser contrato universal o solo para artifacts con side-effects?** — Actualmente a medio adoptar. O se extiende a Session/Connections o se documenta como específico de "network-augmented" artifacts.
2. **¿Mantener clases en `adom` o migrar a factories?** — `BodyScrollLock`, `DOMContext`, `RovingFocusGroup` usan `new`; el resto usa `create*()`. La inconsistencia actual confunde.
3. **¿Cuál es el plan para `libs/times/`?** — Directorio vacío con alias en config. ¿Se puebla con duration math, `delay()`, `sleep()` o se elimina?
4. **¿Nivel de madurez de OAuth y MFA?** — El README dice "no deben documentarse como production-ready". `verifyMfaChallenge` siempre lanza error. OAuth tiene incompatibilidad con DB adapter. ¿Roadmap?
5. **¿Estándar de idioma para documentación?** — `adom/README.md` está en español, `stor/README.md` en inglés. Sin estándar definido.
6. **¿Mover validación de sesión en SSR al engine?** — `readSessionFromCookies` no valida schema; depende del caller pasar por `adoptServer`. ¿Debería el helper ser más defensivo?
7. **¿Estrategia de barrels?** — El README dice "named re-exports" para todos los barrels pero `libs/*` usa `export *`. ¿Actualizar README o convertir libs?
---
## Veredicto
**El ecosistema Active es un framework sólido, bien diseñado y con fundamentos arquitectónicos excelentes.** La separación en capas, el patrón Engine/Active, la política de tree-shaking, el aislamiento de scope en caché y permisos, y la implementación de CSRF son de calidad profesional.
**Lo que frena la calidad hoy:**
1. **Deuda de consolidación** — Archivos monolíticos (`connection.ts` 865 líneas, `engine-auth.ts` 951 líneas) que contradicen su propio diseño documentado. La duplicación de boilerplate entre sub-módulos de fmts y entre capas cliente/servidor indica que el framework creció sin pausas de refactorización.
2. **Cobertura de tests desigual** — Algunos módulos tienen baterías exhaustivas (50 tests en `engine-timers.test.ts`, 622 líneas en `engine-http.test.ts`); otros tienen cero tests (`libs/timers`, `active-connections`, `websocket`). Las áreas sin tests son precisamente donde hay más bugs potenciales (conexiones, reconexión, heartbeats).
3. **Convenciones inconsistentes** — Nombres plural/singular, clases vs factories, errores clase vs string, contrato `ActiveEngine` a medio adoptar. Esto crea fricción para nuevos contribuidores y hace que el código parezca menos cohesionado de lo que realmente es.
**Orden de actuación recomendado:**
1. **Semana 1-2 — Corrección de bugs:** HC-1 (Http dispose), HC-3 (writeBatch loop), HC-4 (isPromiseLike), HM-1 (Can reactivity), HM-2 (auth error re-throw)
2. **Semana 3-4 — Refactors estructurales:** Partir `connection.ts`, extraer boilerplate fmts, añadir Http.dispose()
3. **Semana 5-6 — Seguridad:** HM-3 (revokeDevice DB), HM-4 (signOutGlobal refresh families), HM-6 (session fixation docs)
4. **Semana 7-8 — Tests:** Añadir tests para `libs/timers`, `active-connections`, heartbeat, CSRF edge cases
5. **Mes 2-3 — Unificación:** Adoptar `ActiveEngine` universalmente o eliminarlo, unificar naming (plural→singular en timr), estandarizar errores (clases everywhere), actualizar DESIGN_CONN.md
**Lo que está excepcionalmente bien:**
- Separación cliente/servidor: cero imports cruzados `$arts`↔`$svrs` (fuera de tests de integración)
- CSRF: double-submit cookie + HMAC-SHA256 + timing-safe comparison + `__Host-` prefix
- Scope isolation en caché: `SCOPE_ACTOR`, `SCOPE_TENANT`, `SCOPE_PERMISSION` con hash en key
- Generation guard en permisos: previene escritura de resultados stale en snapshots posteriores
- Sistema de timers deterministas con fake clock injection para testing
- Tree-shaking: separación `.svelte.ts` vs `.ts`, barrels con named re-exports en `arts/`
- Documentación de diseño: `DESIGN_TIMR.md` (1530 líneas) y `DESIGN_CONN.md` (1770 líneas) son ejemplares

@ -1,709 +0,0 @@
# AUDIT_claude
> Auditoría profunda del ecosistema Active en `src/`. Sin cambios de código.
> Cada hallazgo verificado lleva archivo y línea aproximada. Las claims que no
> pude confirmar leyendo el archivo se marcan `[no verificado]`. Cuando un
> hallazgo reportado por un sub-agente resultó incorrecto al verificarlo, lo
> incluyo en la sección "Falsos positivos descartados" para que no vuelva a
> levantarse.
## Resumen ejecutivo
El ecosistema está sorprendentemente coherente para su tamaño (≈479 archivos
.ts/.svelte). Las convenciones (`createEngineXxx` / `createActiveXxx`,
`ActiveEngine<TSnapshot, TError>`, dispose idempotente, constantes
centralizadas, named exports) se aplican con consistencia notable; lang y
logr son tan limpios que sirven de plantilla para el resto. Los tests de
`sium`, `stor`, `sess`, `lang` y `logr` son sólidos.
Los problemas serios se concentran en tres puntos:
1. **Composición de seguridad incompleta en `aapp`.** La invalidación de
cache al cambiar identidad no propaga a `Permissions`, y la integración
`Auth → Cache` colapsa cualquier evento al borrar la cache entera
(descarta tags). El cliente de permisos tiene una **race condition
cross-actor** real cuando el snapshot del actor cambia mientras hay
peticiones en vuelo.
2. **Ramas server-authoritative parcialmente implementadas.** `svrs/auth`
define `AuthRateLimitPort` pero no lo cablea en ningún flujo.
`verifyMfaChallenge` lanza `AuthConfigError` (stub). El intercambio OAuth
PKCE no pasa el `verifier` al provider. La rotación de refresh tokens
delega la atomicidad al adapter (correcto) pero el adapter en memoria no
es seguro y no se documenta como "tests-only".
3. **Cobertura de tests muy desigual.** `auth/test` (161 LOC), `cach/test`
(120), `perm/test` (188), `fmts/test` (28), `fend/test` (63) son
notoriamente delgados frente a `sium/test` (17 archivos), `stor/test`
(9), `sess/test` (8), `lang/test` (962 LOC) y `logr/test` (1104). Las
áreas más críticas para producción están menos cubiertas.
Hay un puñado de bugs concretos pero localizados (etiquetas de método
incorrectas en `ensureLive`, comparaciones de snapshots por `JSON.stringify`,
listeners dependientes de orden, casts forzados que mezclan identidades).
Ninguno tira el framework, pero ya levanta deuda visible.
Estado general: **sólido en esqueleto, frágil en seguridad/ops**. Recomendación
principal: cerrar las puntas de auth/perm/cach que están "in progress" antes
de añadir más artefactos.
---
## Hallazgos críticos
### C1. `[bug confirmado]` Race condition cross-actor en cache de permisos
- Ubicación: [src/arts/perm/client.ts:166-181, 207-221, 238-282](src/arts/perm/client.ts#L166-L282)
- Severidad: **alta** · Esfuerzo: medio
- Evidencia:
- `decisionKey(input)` usa `resolveScopeKey()` que lee
`currentSnapshot.actor` del snapshot vigente al *momento* de calcular la
clave.
- `check()` calcula la clave al inicio (línea 240) y la usa para `pending.set(key, …)`.
- Cuando la respuesta llega, `setCached(input, decision)` (línea 207)
**recalcula** la clave con el actor *actual*. Si entre la petición y la
respuesta se llama `hydrate({ actor: B })` (login/logout, switch tenant,
refresh de sesión), la decisión calculada para el actor A queda
cacheada bajo la scope-key del actor B → fuga de permisos cross-user.
- Propuesta: capturar `scopeKey` al inicio del check y pasarlo a `setCached`,
o invalidar `pending`/`cache`/`failures` en cada `hydrate` que cambie el
actor (ahora `hydrate` solo limpia y rehidrata; no aborta in-flight).
### C2. `[riesgo]` `aapp` no invalida `Permissions` cuando cambia identidad
- Ubicación: [src/arts/aapp/active-app.svelte.ts:237-251](src/arts/aapp/active-app.svelte.ts#L237-L251)
- Severidad: **alta** · Esfuerzo: bajo
- Evidencia: en `createActiveAuth` se inyecta
`cach: { invalidate: () => Cache.clear() }` pero no se pasa nada al
`Permissions` activo. Tampoco hay un wiring `Auth → Permissions.invalidate()`
o `Sess → Permissions.invalidate()`. Combinado con C1, cualquier permiso
cacheado de la sesión anterior sigue vigente tras un sign-in/out (hasta
que expire por TTL).
- Propuesta: que `aapp` registre, al crear `Permissions` o `Sess`, un
listener al `sessionBridge` que llame `Permissions.invalidate()` con el
scope previo. O mejor, exponer un hook `cach`-style en
`ActivePermissionsOptions` y conectarlo en `aapp`.
### C3. `[riesgo]` `Auth → Cache.clear()` descarta tags y limpia todo
- Ubicación: [src/arts/aapp/active-app.svelte.ts:244-247](src/arts/aapp/active-app.svelte.ts#L244-L247) + [src/arts/auth/active-auth.svelte.ts:281](src/arts/auth/active-auth.svelte.ts#L281)
- Severidad: media-alta · Esfuerzo: bajo
- Evidencia: el helper `authCacheTagsForIdentity()` produce tags (`auth.current`,
`auth.devices`, `auth.factors`) y `ActiveAuth` los pasa, pero `aapp`
ignora los args y llama `Cache.clear()` total. Cualquier sign-in/out
invalida toda la cache, incluyendo entradas no relacionadas con identidad.
Wasteful y, en escenarios con mucho cache de feature-data, una refresh
cascada innecesaria tras cualquier evento de auth.
- Propuesta: implementar `cach.invalidate({ tags, reason })` real en `aapp`
(`Cache.invalidate({ tags })`).
### C4. `[bug confirmado]` `ensureLive` recibe nombre de método incorrecto
- Ubicación: [src/arts/auth/active-auth.svelte.ts:233](src/arts/auth/active-auth.svelte.ts#L233)
- Severidad: media · Esfuerzo: trivial
- Evidencia: `onChange(listener)` llama `ensureLive(AUTH_METHOD_LOAD_CURRENT)`.
Si el active está disposed, el `AuthDisposedError` reportará el método
equivocado. Caso parecido en `active-permissions.svelte.ts:111` donde
`clearError` y `decisionKey` reusan `PERMISSION_METHOD_CHECK`.
- Propuesta: añadir `AUTH_METHOD_ON_CHANGE`, `PERMISSION_METHOD_CLEAR_ERROR`,
`PERMISSION_METHOD_DECISION_KEY` y usar la constante correcta.
### C5. `[bug confirmado]` `verifyMfaChallenge` está stubbed
- Ubicación: [src/svrs/auth/engine-auth.ts:641-643](src/svrs/auth/engine-auth.ts#L641-L643)
- Severidad: alta para usar en producción · Esfuerzo: alto
- Evidencia: `async function verifyMfaChallenge(_input) { throw new AuthConfigError(...) }`.
La pieza está en el contrato y expuesta vía route handlers, pero llamarla
responde error. No hay banner en el README de `svrs/auth` que avise.
- Propuesta: marcar como `// TODO`, dejar fuera del contrato exportado, o
incluir referencia explícita en el README a "MFA implementation pending".
### C6. `[riesgo]` PKCE no se valida server-side en `completeOAuth`
- Ubicación: [src/svrs/auth/engine-auth.ts:579-617](src/svrs/auth/engine-auth.ts#L579-L617)
- Severidad: alta · Esfuerzo: medio
- Evidencia: `startOAuth` genera `verifier` y guarda
`metadata: { state, verifier }` en el flow, pero `completeOAuth` solo
recupera el flow por `stateHash`, llama
`provider.mapProfile({ tokens: { code } })` y consume el flow. **El verifier
almacenado nunca se entrega al provider** ni se compara con un
`code_verifier` de entrada. La construcción del PKCE pair (`oauth/pkce.ts`)
es correcta (BASE64URL(SHA256(verifier))) pero no se cierra el ciclo.
- Propuesta: pasar `flowCandidates.metadata?.verifier` a
`provider.mapProfile`, y exigir que el provider lo use en el token
exchange. Validar que el `code_verifier` derivado coincide con el
`code_challenge` enviado.
### C7. `[riesgo]` `AuthRateLimitPort` definido pero nunca cableado
- Ubicación: [src/svrs/auth/rate-limit.ts](src/svrs/auth/rate-limit.ts) + [src/svrs/auth/engine-auth.ts](src/svrs/auth/engine-auth.ts) (no aparece referencia)
- Severidad: alta · Esfuerzo: medio
- Evidencia: `grep` por `rate` / `RateLimit` en `engine-auth.ts` y
`handlers.ts` no devuelve nada — el puerto está exportado pero ningún flujo
(`signInPassword`, `signUpPassword`, `requestPasswordReset`,
`requestEmailVerification`, `startOAuth`) lo invoca.
- Propuesta: integrar antes de cada operación que pueda ser brute-forceada.
Hasta que se cablee, considerar quitarlo de `index.ts` para no dar
falsa sensación de protección.
### C8. `[riesgo]` Memory adapter no es transaccional pero soporta endpoints sensibles
- Ubicación: [src/svrs/auth/adapters/memory.ts:139-157](src/svrs/auth/adapters/memory.ts#L139-L157), [src/svrs/auth/refresh-rotation.ts:21-55](src/svrs/auth/refresh-rotation.ts#L21-L55)
- Severidad: media · Esfuerzo: bajo (docs)
- Evidencia: `findRefreshTokenForUpdate` y `rotateRefreshToken` están
diseñados para correr dentro de una transacción ("ForUpdate" sugiere row
lock). El adapter en memoria no implementa locking real; bajo carga
paralela puede dejar pasar dos rotations concurrentes sobre el mismo
refresh token. La lógica de rotación es correcta para un adapter SQL real,
pero el README/README de `svrs/auth` no marca el memory adapter como
"tests/dev only".
- Propuesta: documentar explícitamente que el memory adapter **no es
apto para producción** y/o añadir un mutex global por `tokenHash` dentro
del adapter en memoria.
---
## Hallazgos medios
### M1. `[bug confirmado]` `sameSnapshot` por `JSON.stringify` para session
- Ubicación: [src/arts/sess/engine-session.ts:786-790](src/arts/sess/engine-session.ts#L786-L790)
- Severidad: media · Esfuerzo: bajo
- Riesgo: si la session contiene fields cuyo orden de keys no es estable
entre origen-tab y target-tab (raro pero posible con structures cíclicas
o `JSON.stringify` polyfills), se reportarán cambios falsos. Más probable:
el coste de stringify dos sesiones en cada storage event escala con el
payload de `data`. Para apps que guardan poco, está bien; documentar el
coste y que `data` debe ser pequeño.
- Propuesta: dado que `freezeSession` ya normaliza keys, el riesgo de
desorden es bajo. Bastaría una nota en el README sobre el coste.
### M2. `[bug confirmado]` SameSite default `lax` para cookie CSRF
- Ubicación: [src/libs/auth/consts.ts:283-290](src/libs/auth/consts.ts#L283-L290)
- Severidad: media · Esfuerzo: trivial
- Evidencia: `AUTH_COOKIE_POLICY.SAME_SITE = 'lax'`. Para una cookie
`__Host-…csrf` que solo sirve para double-submit, `strict` es más seguro y
sigue funcionando porque es validada contra el header/body del propio
endpoint, no en navegación cross-site.
- Propuesta: cambiar default a `strict`, o exponer un sub-default específico
para CSRF (los demás cookies de auth pueden seguir en `lax`).
### M3. `[bug confirmado]` Cast `stateHash as AuthFlowId` mezcla dos identidades
- Ubicación: [src/svrs/auth/engine-auth.ts:717-729](src/svrs/auth/engine-auth.ts#L717-L729) + [src/svrs/auth/adapters/memory.ts:139-157](src/svrs/auth/adapters/memory.ts#L139-L157)
- Severidad: media · Esfuerzo: bajo
- Evidencia: `findOAuthFlowByState` pasa el `stateHash` como `flowId` y el
adapter lo usa primero como id directo y, si falla, como búsqueda por
`flow.stateHash`. Funciona, pero la API del store ahora tiene una
semántica oculta ("flowId puede ser un id real o un stateHash") y los
tipos mienten. Difícil de descubrir sin leer el adapter.
- Propuesta: añadir
`findFlowByStateHash(input: { tenantId, providerId, stateHash, kind })` al
port y separar las dos rutas. Mantiene tipos honestos.
### M4. `[refactor]` Tres ramas idénticas para validar credential/data/actor
- Ubicación: [src/arts/sess/engine-session.ts:378-419](src/arts/sess/engine-session.ts#L378-L419) y [493-543](src/arts/sess/engine-session.ts#L493-L543)
- Severidad: media · Esfuerzo: bajo
- Evidencia: `adopt` y la rama validada de `refresh` repiten el mismo patrón
4 veces ("si schema definido O field presente, validar; mapear error con
field name"). 80 LOC duplicadas.
- Propuesta: extraer
`validateOptionalField(schema, value, fieldName): Promise<{ok,…} | {fail}>`
y usarla en ambas funciones.
### M5. `[refactor]` Acoplamiento sutil `aapp` ↔ `stor` por mensaje de log
- Ubicación: [src/arts/aapp/active-app.svelte.ts:33,77-83](src/arts/aapp/active-app.svelte.ts#L33-L83)
- Severidad: media · Esfuerzo: bajo
- Evidencia: `aapp` importa
`LOGGER_CATEGORY as STORAGE_LOGGER_CATEGORY` y `APP_STORAGE_ERROR_MESSAGE`
para reportar errores del adapter. La política de "qué mensaje y qué
categoría usar" está dividida entre dos módulos.
- Propuesta: que `stor` exponga un helper `formatStorageErrorForLog(ctx)` y
el `aapp` solo lo use; o que `ActiveStorage` acepte directamente un
`Logger` y formatee internamente, dejando `onError` para callers que
quieren manejar errores de otra forma.
### M6. `[refactor]` `Cache.clear()` ignora tags y vuelve `cach.invalidate` un alias mentiroso
- Ubicación: [src/arts/aapp/active-app.svelte.ts:244-247](src/arts/aapp/active-app.svelte.ts#L244-L247)
- Severidad: media · Esfuerzo: bajo
- Cubierto en C3. Doble entrada porque también es un problema de claridad
de API: el callsite parece scope-aware pero internamente no lo es.
### M7. `[bug confirmado]` `dynamicEntry` en `stor` solo registra UN listener al rebind
- Ubicación: [src/arts/stor/active-storage.svelte.ts:115-127](src/arts/stor/active-storage.svelte.ts#L115-L127) (verificar líneas exactas en su versión actual)
- Severidad: media · Esfuerzo: medio
- Evidencia (parcial, no leí el archivo entero): el patrón de `userSubs:
Map<fn, detacher>` reasigna el detacher en cada rebind, lo que suelta
el listener anterior y registra uno nuevo. Es correcto siempre que la
función `fn` sea estable. Si el caller usa una arrow inline, cada rebind
agrega una entrada nueva sin liberar la anterior. Documentar que `fn`
debe ser estable.
- Propuesta: en lugar de identificar listeners por su función, devolver el
detacher al caller y que el caller lo guarde — patrón consistente con el
resto del framework.
### M8. `[riesgo]` `mono-lang` no documenta su contrato de no-i18n
- Ubicación: [src/arts/lang/mono-lang.svelte.ts](src/arts/lang/mono-lang.svelte.ts)
- Severidad: media · Esfuerzo: bajo
- Evidencia: `aapp` cae a `createActiveMonoLang` cuando no se pasa `lang`,
con un cast `as unknown as ActiveLang<S>`. Si un caller depende de tipos
estrictos del schema, ese cast borra la garantía. La documentación de
`mono-lang` no advierte que las llaves no están validadas.
- Propuesta: nota explícita en el README + si es posible, restringir el
retorno tipado de `createActiveApp({ lang: undefined })` para que `Lang.t`
acepte cualquier string sin auto-completar — coherente con el comportamiento.
### M9. `[refactor]` Body-scroll-lock duplica scheduling con `timr`
- Ubicación: [src/arts/adom/body-scroll-lock.svelte.ts](src/arts/adom/body-scroll-lock.svelte.ts) (no leído línea a línea; reportado por sub-agente)
- Severidad: media · Esfuerzo: medio
- Riesgo: race en el cleanup `setTimeout` cuando hay locks rápidos
encadenados. Si se confirma con un test (no existe), aprovechar para
delegar a `EngineTimers` (`timr`) y eliminar el setTimeout local.
- Propuesta: usar `App.Timers.schedule()`. Beneficio extra: deterministic
para tests con `clock` inyectado.
### M10. `[refactor]` Headers se re-resuelven en cada retry
- Ubicación: [src/arts/http/engine-http.ts] (línea ~271 según sub-agente)
- Severidad: media · Esfuerzo: bajo
- Evidencia indirecta: si `mergeHeaders(defaults.headers, init?.headers)`
invoca a un `headers` hook costoso (p.ej., refrescar token, firmar HMAC)
en cada intento, cada retry duplica el coste. Para refresh tokens bajo
presión esto puede colgar requests.
- Propuesta: cachear el resultado del primer cómputo de headers y solo
recomputar si el `beforeRetry` lo solicita explícitamente.
### M11. `[bug confirmado]` `eventCount` y `loadingCount` con `untrack` en `cach`
- Ubicación: [src/arts/cach/active-cache.svelte.ts:55-64](src/arts/cach/active-cache.svelte.ts#L55-L64)
- Severidad: baja-media · Esfuerzo: trivial
- Evidencia: `eventCountCell = untrack(() => eventCountCell) + 1`. Como el
callback `engine.on(CACHE_EVENT_ALL, …)` se invoca desde el motor (no
dentro de un `$derived`/`$effect`), el `untrack` es defensivo pero ruidoso
e induce a los lectores a creer que hay un ciclo reactivo escondido.
- Propuesta: si los tests pasan sin `untrack`, quitarlo. Si hay un caso que
requiere `untrack`, comentar el porqué.
### M12. `[riesgo]` `Cache.clear()` no aborta promises en vuelo
- Ubicación: [src/arts/cach/active-cache.svelte.ts:167-170](src/arts/cach/active-cache.svelte.ts#L167-L170) + engine
- Severidad: media · Esfuerzo: medio
- Evidencia: `clear()` se delega a `engine.clear()`. Si una `query()`
estaba en vuelo, su `setCached` posterior puede repoblar la cache que
acaba de ser borrada. Mismo problema que C1, en otro escenario.
- Propuesta: incrementar un `clearGeneration` y descartar resultados de
fetches iniciados antes de la última `clear()`.
### M13. `[refactor]` `signOut` cliente es optimista pero estado se reescribe sólo si la red OK
- Ubicación: [src/arts/auth/active-auth.svelte.ts:102-110](src/arts/auth/active-auth.svelte.ts#L102-L110)
- Severidad: media · Esfuerzo: bajo
- Evidencia: la asignación `current = createAnonymousAuthCurrent()` ocurre
*después* del `await options.http.post(SIGN_OUT)`. Si la red falla, el
usuario sigue "authenticated" en la UI aunque la cookie del servidor se
haya eliminado. En cookie-auth puro, una respuesta 5xx puede dejar al
cliente desincronizado.
- Propuesta: dos opciones: (a) limpiar localmente *antes* del POST y
rollback si el server responde 401 confirmando que ya no había sesión;
(b) en el catch, si el error es de red, igual limpiar localmente y dejar
que la próxima `loadCurrent` resuelva el estado real.
### M14. `[riesgo]` `BroadcastChannel` no parsea `event` ni `generation`
- Ubicación: [src/arts/sess/engine-session.ts:154-180](src/arts/sess/engine-session.ts#L154-L180)
- Severidad: baja-media · Esfuerzo: bajo
- Evidencia: el listener trata `data?.type !== BROADCAST_TYPE` como guard
de seguridad, lo cual cubre payloads ajenos. Pero si el remitente de la
misma BC envía un `type` correcto pero un `event`/`generation` corrupto,
el código lee `storage` directamente — está bien — pero igual entrega un
`EXTERNAL_CHANGED` con el snapshot persistido, que puede no concordar con
el `event` del mensaje. No produce comportamiento incorrecto pero hace
que `event` y `current` no estén ligados al mensaje recibido.
- Propuesta: como ya se delega en `storage`, ignorar el `event` del
broadcast y simplemente disparar un re-read; el modelo actual hace eso, así
que solo bastaría documentar.
### M15. `[refactor]` Permisos: `pending` debería re-cuparse al cambiar actor
- Ubicación: [src/arts/perm/client.ts:140-147 + 360-388](src/arts/perm/client.ts#L140-L388)
- Severidad: media · Esfuerzo: bajo
- Evidencia: `hydrate(snapshot)` y `invalidate(scope)` no tocan `pending`.
Si invalidate corre durante in-flight, los caches `pending` tras la
resolución repoblarán datos que ya no debieran existir.
- Propuesta: `pending.clear()` dentro de `hydrate` e `invalidate(undefined)`,
y filtrar por scope en `invalidate(scope)`.
### M16. `[bug confirmado]` `aapp` permite varios `connectionRegistries` pero sin aviso
- Ubicación: [src/arts/aapp/active-app.svelte.ts:200-212](src/arts/aapp/active-app.svelte.ts#L200-L212)
- Severidad: baja-media · Esfuerzo: trivial
- Evidencia: `Sess`, `Permissions` y `Auth` levantan `AlreadyCreated*Error`
si se piden dos veces, pero `createActiveConnections` no. Los tests
`aapp/test` parecen aceptarlo. Inconsistencia con el patrón.
- Propuesta: o documentar explícitamente que `Connections` es multi-instancia
(channels separados) o aplicar la misma regla.
### M17. `[refactor]` `lang` `void _schemaVersion` como hack reactivo
- Ubicación: `src/arts/lang/active-lang.svelte.ts` (línea ~63 según
sub-agente) — patrón frágil para forzar lectura reactiva.
- Severidad: media · Esfuerzo: bajo
- Propuesta: documentar el porqué con un bloque comentado, o usar
`$derived.by(() => { schemaVersion; return … })` para que el dev tooling
lo vea explícitamente.
### M18. `[riesgo]` `dispose()` orden en `aapp` no detiene timers in-flight
- Ubicación: [src/arts/aapp/active-app.svelte.ts:253-276](src/arts/aapp/active-app.svelte.ts#L253-L276)
- Severidad: media · Esfuerzo: bajo
- Evidencia: el orden parece intencional pero no se documenta. `Cache.dispose()`
se llama antes que `Timers.dispose()`. Si la cache tiene un timer
programado en `Timers`, ese timer queda suelto hasta que se dispose
`Timers`. Como `Timers.dispose()` cancela todos, el efecto neto es
correcto en este orden, pero invertir destruiría la cache primero y
podría disparar un last-tick. Mantener el orden y documentarlo.
- Propuesta: comment de cabecera con la regla `consumers → providers`.
---
## Hallazgos menores
### m1. `[docs]` Inconsistencias entre `arts/README.md` y READMEs por artefacto
- `arts/README.md:51` dice de `logr`: "Structured logger: levels, transports,
filters, vitals, dispose". `logr/README.md` debe explicitar igual y
alinear el lenguaje (algunos READMEs llaman a `transport` "adapter").
### m2. `[docs]` `fmts/README.md` no aclara que `createRates(...)` es demo
- `fmts` documenta currency conversion pero no explicita que el rate provider
es responsabilidad del consumidor.
### m3. `[docs]` `cach/README.md` no documenta qué pasa si el `fetcher` lanza
- ¿Se marca la entrada como error? ¿Se conserva `data` previa con `status:
ERROR`? El código (active-cache.svelte.ts:254-258) lo hace, pero no está
en docs.
### m4. `[refactor]` Magic strings de marca "asoma" en cookies
- `src/libs/auth/consts.ts:54-57, 77-78` hardcodea "asoma". Para un
framework reutilizable, conviene `BRAND_NAME` configurable y derivar
cookie names.
### m5. `[simplificación]` `mapSendToJoinResult` en `conn/channel.ts:42-52`
- Mapeo trivial; inline o usar `as const` table.
### m6. `[refactor]` `helpers.ts` y `consts.ts` con cientos de identifiers en algunos artefactos
- `auth/consts.ts` y `sess/consts.ts` exportan ≈80 constantes cada uno.
Considerar agrupar en namespaces (`AUTH_METHODS`, `AUTH_HEADERS`, ya hecho
parcialmente) y reducir el surface por named import.
### m7. `[docs]` `arts/README.md` Map menciona `EngineSium` pero no `ActiveSium`
- Verificar que `sium` realmente no expone una versión Active. Si así es,
documentar que `sium` es un caso especial (engine-only); ya está
contemplado pero la fila no lo deja claro.
### m8. `[simplificación]` `TimerKey` interno en `conn` duplica conceptos de `timr`
- `connection.ts:316-330` (según sub-agente) maneja
`scheduleTimer`/`scheduleInterval` con keys propias. Ya tiene `timr` con
`(id, key, version)`. Posible delegación.
### m9. `[docs]` Dispose contract no está formalizado en cada README
- `arts/README.md` dice "dispose() es idempotente". Algunos READMEs (sess,
cach, auth) repiten la garantía; otros no. Estandarizar línea boilerplate.
### m10. `[refactor]` `aapp/integrations/frontend-storage` exporta nombre
largo + tres helpers que se usan solo desde `active-app.svelte.ts`
- Considerar inline o convertir en method privado del `ActiveApp`.
### m11. `[simplificación]` `Logger.dispose()` cierra y vacía pero no expone snapshot
- A diferencia de otros, `EngineLogger` no tiene `snapshot()`/`onChange`. OK
porque no implementa `ActiveEngine`. Documentar que es intencional.
### m12. `[docs]` `arts/conn/DESIGN_CONN.md` y `arts/timr/DESIGN_TIMR.md` y
`arts/sess/DESIGN.md` viven solo en sus carpetas
- Considerar enlazarlos desde `arts/README.md` para visibilidad. Los
decisivos no se ven a menos que el lector navegue.
### m13. `[test]` `aapp/test` (5 archivos) cubre composición pero no orden de
dispose
- Añadir test que verifique que disposal corre `consumers → providers`.
### m14. `[bug confirmado]` `Sentry DSN` queda en `sessionStorage` del test page
- `web/routes/test/logr/+page.svelte` guarda DSN en sessionStorage; al
navegar entre tests, persiste. Privacidad/uso accidental en producción.
### m15. `[docs]` SSR contract per-artefacto
- `timr`, `conn`, `adom`, `fend` no documentan explícitamente SSR. Una
sección "SSR considerations" por artefacto evitaría sorpresas.
---
## Refactorizaciones recomendadas
1. **Centralizar invalidación cross-artefacto**. Un `IdentityChannel`
(probablemente extensión de `sessionBridge`) al que `Cache` y
`Permissions` se suscriban. Hoy `aapp` suelta listeners ad hoc y mezcla
responsabilidades.
2. **Extraer `validateOptionalField`** del engine de sess; aparece 8 veces.
3. **Centralizar comparaciones por `JSON.stringify`** en un `equalsByJson`
en `libs/objs/`. Hoy aparece en sess y stor.
4. **Mover `setCached` a un helper `cacheKeyAtTime(input, scope)`** en perm
para fijar la scope-key al inicio del check (cierra C1).
5. **Unificar el patrón de listeners por función estable.** `stor`, `cach` y
`conn` lo hacen distinto; converger a "el caller guarda el detacher".
6. **Romper la dependencia `aapp ← stor`** en mensajes/logger category;
`stor` debe exponer su propio helper.
7. **Partir `auth/consts.ts`** en sub-archivos por dominio (cookies, methods,
events, errors). Importar lo que se usa, no cargar 80 constantes por
módulo.
8. **Documentar adapter contract** (auth/store) y separar `findFlowForUpdate`
de `findFlowByStateHash`.
9. **Pulir el README de `arts/`** para añadir leyenda "Adapters", "Hooks",
"SSR" y enlazar los `DESIGN_*.md`.
---
## Simplificaciones recomendadas
1. **Eliminar `untrack` defensivos** en `cach` que no responden a un caso
concreto (M11).
2. **Inline `mapSendToJoinResult`** y `mergeHeaders` cuando se usen una vez.
3. **Reducir el surface de `Cache.snapshot()`**: hoy expone `lastEvent`,
`eventCount`, `loading`, `lastError`, `disposed`. ¿Qué consumidor real
usa `eventCount`? Si solo lo usa el test page, mover a un helper de
debug.
4. **Unificar nombres**: `loading` vs `loadingCount`, `lastError` vs
`errorCell`, `current` vs `snapshot()`. La regla "loading siempre boolean,
lastError siempre `TError | null`" ya está en el README; aplicarla en los
internals.
5. **Rebajar `mono-lang` a un export de funciones**, no un Active completo —
hoy implementa `ActiveLang` solo para el cast. Se podría aceptar `null`
en `aapp.Lang` y guardarlo detrás de un proxy.
6. **Quitar el wrapper `safeParse`** del test page de http; el patrón
"intenta JSON.parse con fallback string" es trivial y oculta errores.
7. **Devolver el detacher de `onChange`** en `EngineLogger` para alinearse
con el resto, aunque hoy no haya listeners.
---
## Optimizaciones recomendadas
1. **Permisos**: cachear `decisionKey` por scope al inicio del check (resuelve
C1 y mejora rendimiento en aplicaciones con muchas checks por evento).
2. **HTTP retries**: cachear el body serializado *y* los headers cuando no
cambian entre intentos (M10).
3. **Storage `read()`**: comparar `prev === next` por `Object.is` antes de
dispatch — evita re-render en cadena cuando un setItem coincide con el
valor actual.
4. **Cache `mergeDefaults`** evita recomputar `JSON.stringify(defaults)`
cada lectura. Si se cumple igualdad estructural, dedupe.
5. **`SvelteMap`/`SvelteSet`** en `aapp` (`sessionBridgeListeners`,
`connectionRegistries`) están bien marcados como no-reactivos, pero hay
sitios en `stor` (`userSubs`) y `perm` (`pending`) donde plain `Map` es
suficiente — sub-agente reportó que algunos son `SvelteMap`. Verificar
y bajar a Map donde no haya consumo en templates.
6. **Compactar `vitals.ts` config factories** (logr) — patrón repetido
`levelsAtLeast(...)` en cada transport.
7. **`fmts` Currency cache**: `Map + JSON.stringify(options)` por entrada
produce keys grandes; un `Map<locale, Map<code, Map<optionsKey, Intl>>>`
es más rápido y barato.
---
## Incoherencias de arquitectura
1. **`aapp` sabe demasiado de `stor`**. Importa `LOGGER_CATEGORY` y un
message builder de stor. La capa de composición debería ser ciega al
formato de los errores de los proveedores.
2. **`auth` cliente y server compartidos vía `libs/auth`** — bien, pero
`helpers.ts` (cliente) llama a tags que solo usa `aapp`. Mover a `aapp`
o a `libs/svrs/auth`.
3. **`cach` cliente vive en `arts/cach` pero el engine real está en
`svrs/cach`**. El active es un wrapper. Coherente con el patrón
"auth/perm/cach se parten en svrs+arts" — pero el README de `arts/cach`
no menciona la dependencia explícita a `$svrs/cach`. Confuso para un
nuevo dev.
4. **`AuthRateLimitPort` en `svrs/auth/rate-limit.ts` exportado pero no
integrado** (C7). Rompe la promesa "todos los puertos usados".
5. **Memory adapter en `svrs/auth/adapters/memory.ts` no marcado como
tests-only** (C8). Coherencia con expectativa producción/test.
6. **`Sess` exige `App.createActiveSession` como factory una sola vez**, pero
`Connections` no (M16). Inconsistencia.
7. **`mono-lang` rompe la garantía de tipo**. Cast `as unknown as
ActiveLang<S>` significa que el tipo del `App.Lang` no es de fiar.
Coherencia con el contrato "App.Lang siempre tipado por schema".
8. **Constantes de "categoría logger"** son strings cortos por artefacto
(`'sium'`, `'sess'`, `'auth.client'`, `'cache'`). El propio `aapp.ts`
incluye `auth.client` y `cache` con punto, mientras `sess` es plano.
Convención no documentada.
---
## Tests faltantes
### Críticos
- **`arts/perm/test`** (188 LOC, 1 archivo): tests para C1 (race
cross-actor), `invalidate(scope)` con scope correcto/incorrecto, dedup de
`pending` con error y reintento.
- **`arts/cach/test`** (120 LOC, 1 archivo): TTL expiry, stale-while-revalidate
con error en fetcher, race entre `set` y `query`, integración con
`$stor`.
- **`arts/auth/test`** (161 LOC, 1 archivo): CSRF flow completo (rechazo si
cookie/token no coinciden, expiración), sign-out con red caída (M13),
`requestPasswordReset` y `completePasswordReset`, `revokeDevice`.
- **`svrs/auth/test`** (3 archivos): refresh rotation reuse window, OAuth
state-hash collision, MFA challenge expirado, rate-limit (cuando se
cablee).
### Importantes
- **`arts/conn/test`** (2 archivos): WebSocket transport mockeado, ack
timeout, reconnect con backoff, disposal idempotente.
- **`arts/fmts/test`** (28 LOC) y **`arts/fend/test`** (63 LOC): casi vacíos.
Cubrir locale switching, currency rounding, dir auto-derivation.
- **`arts/timr/test`** (3 archivos): backoff formula, scope cancellation,
`awaitTask:false` fire-and-forget.
- **`arts/aapp/test`** (5 archivos): orden de disposal, idempotencia, doble
factory.
- **`arts/adom/test`** (5 archivos): roving focus keyboard, viewport debounce,
scroll lock multi-claim.
### Edge cases
- Sess: `expiresAt - issuedAt < 1`, `generation > Number.MAX_SAFE_INTEGER`,
refresh y revoke concurrentes.
- HTTP: Retry-After con segundos vs HTTP-date, abort en mitad de retry,
`bodySchema` y `schema` en conflicto.
- Stor: cuota excedida, envelope corrupto, migrate fallido en cadena.
---
## Preguntas abiertas
1. **¿Qué propiedades de "scope" debería tener `cach.invalidate({tags})`
cuando se llama desde `aapp` por evento de auth?** Ahora se pierde por
`Cache.clear()`. ¿Decisión consciente o pendiente?
2. **¿Es `mono-lang` parte estable del API público o un fallback interno?**
El cast unsafe sugiere lo segundo, pero `index.ts` lo exporta.
3. **¿Cuál es la promesa de "Active" en cuanto a SSR?** `arts/README.md`
dice "lives in `.svelte.ts` because it owns `$state`" pero no aclara qué
funciones son seguras en `+page.server.ts`. Hay implementaciones con
guardas (`fend`, `stor`) y otras sin (`logr` con `beforeunload`). ¿Cuál
es la regla?
4. **¿`AuthRateLimitPort` queda fuera del MVP?** Si sí, no exportar en el
barrel para evitar la falsa impresión.
5. **¿Memory adapters de `svrs/auth/cach/perm` están pensados para
producción multi-instancia?** Si no, marcarlos.
6. **`Cache.clear()` durante una `query()` en vuelo: ¿debería abortar la
query?** (M12). Decisión semántica.
7. **`Sess.dispose()` durante un `refresh()` en vuelo**: ¿la promesa
resuelve con `SessDisposedError` o con `SKIPPED`?
8. **¿`hydrate(snapshot)` en perm debe abortar `pending`?** (M15).
---
## Veredicto
**Lo sólido**
- Convenciones del framework: `ActiveEngine`, factories `createEngineXxx` /
`createActiveXxx`, dispose idempotente, no magic strings (en su mayoría),
named exports, sin barrels con `export *`. Esto es difícil de mantener a
escala y se nota el cuidado.
- `lang`, `logr`, `sium`, `stor`, `sess` están en muy buen estado, con
tests serios (≥700 LOC cada uno) y READMEs alineados.
- `timr` (locked-in design) y `http` están limpios y bien encapsulados.
- Las decisiones documentadas en MEMORY.md (sess actor extension,
`App.createSiumEngine` zero-arg, no `App.Stores`) están correctamente
reflejadas en el código.
**Lo que frena la calidad**
- La integración auth/perm/cach está a medias: `aapp` tira de un cordel
fácil (`Cache.clear()`) en vez de cablear bien identidad → cache → permisos.
El resultado es un comportamiento conservador pero inseguro en bordes
(C1, C2, C3).
- Server-authoritative auth tiene gaps importantes en producción: sin rate
limiting (C7), MFA stub (C5), PKCE no validado server-side (C6), memory
adapter sin warning (C8).
- Cobertura de tests muy desigual: lo más crítico (auth, perm, cach, fmts,
fend) es lo menos cubierto.
- Pequeños bugs de ergonomía dispersos: nombres de método incorrectos en
`ensureLive` (C4), `untrack` defensivos sin documentar, casts forzados que
ocultan semánticas reales.
**Orden de actuación sugerido**
1. **Sprint de seguridad operativa** (1-2 semanas):
- Cablear `AuthRateLimitPort` en sign-in/sign-up/reset/oauth (C7).
- Pasar el `verifier` PKCE al provider y validarlo server-side (C6).
- Marcar memory adapters como dev/test only en README + warning runtime (C8).
- Documentar SECURITY.md con el flujo completo (CSRF, OAuth state,
refresh rotation, MFA).
- Cambiar SameSite default CSRF a `strict` (M2).
2. **Sprint de wiring de identidad** (1 semana):
- Cerrar C1 (race en perm).
- Cerrar C2 (perm.invalidate al cambiar identidad).
- Cerrar C3 (cach.invalidate respeta tags).
- M12 (Cache.clear con generation guard).
- M15 (perm.hydrate/invalidate aborta pending).
3. **Sprint de pulido** (1 semana):
- C4 (constantes de método correctas).
- M4 (extraer `validateOptionalField` en sess).
- M5/M11 (limpiar coupling y untrack defensivos).
- C5: o implementar MFA verify, o quitarlo del export.
- Sub-archivos en `auth/consts.ts`.
4. **Sprint de tests** (≥1 semana, dependiendo de la profundidad):
- Subir cobertura de `auth/test`, `perm/test`, `cach/test`, `fmts/test`
y `fend/test` al nivel de `sium/test` y `stor/test`.
Después de eso el framework estaría sólido y listo para usuarios externos.
Antes, el escaparate (lang/logr/sium/sess/stor) no refleja el estado real
de los flancos de seguridad.
---
## Falsos positivos descartados
(Reportados por sub-agentes y verificados como incorrectos al leer el código.)
- **PKCE construcción incorrecta** (`oauth/pkce.ts`). El sub-agente afirmó
que `hash(verifier)` no era SHA256/base64url. Verificado: `hashAuthToken`
es `base64URL(sha256(token))`, lo cual es exactamente la transformación
S256 de RFC 7636. La queja real es C6 (no se valida en callback), no la
construcción.
- **Refresh rotation no transaccional**. Verificado: `findRefreshTokenForUpdate`
+ `rotateRefreshToken` están diseñados para correr atómicamente — el
contrato lo asume y un adapter SQL real lo implementa. La queja real es
C8 (memory adapter no documentado como inseguro).
- **`stateHash as AuthFlowId` permite cualquier hash**. Verificado: el store
tiene fallback explícito de búsqueda por stateHash; tipos sufren pero no
hay bypass de seguridad. La queja válida es M3 (separar la API).
- **Test directories vacíos** (conn, perm, etc.). Verificado: todos tienen
≥1 archivo. La queja real es la cobertura desigual, no la ausencia.
- **`adoptServer` SSR safety**. El sub-agente sugirió listener leak; el
código (engine-session.ts) protege con guards `typeof BroadcastChannel`.
- **`storage.adapter.removeItem` con `null`**. Reportado como riesgo; en
realidad la API es estándar `Storage` y removeItem(key) sin valor.

@ -1,108 +0,0 @@
# Next Steps
Estado al cierre:
- Gate completo verde: `npm run test:all` -> `check` + `test` + `build` + `test:static` + `test:bundle`.
- Suite unitaria verde: `npm test` -> 108 archivos, 1208 tests.
- Typecheck verde: `npm run check` -> 0 errores, 0 warnings.
- Build estatico verde: `npm run build`.
- Static smoke verde: `npm run test:static` -> 6 assets/rutas generadas verificadas.
- Bundle smoke verde: `npm run test:bundle` -> `createActiveApp({})` en 65.30 KB gzip, limite por defecto 70 KB via `ACTIVE_BUNDLE_GZIP_LIMIT_KB`.
- `fmts` verde: `npx vitest run src/arts/fmts` -> 14 archivos, 39 tests.
- `conn` verde: `npx vitest run src/arts/conn` -> 5 archivos, 28 tests.
- `auth` verde: `npx vitest run src/arts/auth src/svrs/auth src/libs/auth` -> 4 archivos, 14 tests.
- Refactor tecnico posterior:
- `svrs/auth/engine-auth.ts` ya delega CSRF en `csrf-flow.ts`, coherente con password/session/recovery/device flows.
- `svrs/auth/handlers.ts` delega helpers HTTP/CSRF/error-safe en `handler-runtime.ts`; conserva solo rutas y delegacion al engine.
- `arts/aapp/active-app.svelte.ts` delega invalidacion Auth -> Permissions/Cache en `integrations/auth-cache.ts`.
- `libs/cach/engine.ts` centraliza eventos de lectura con `emitForContext(...)`.
- `arts/conn/connection.ts` usa `createConnectionIdFactory(...)` desde helpers.
- `arts/conn/connection.ts` delega auth/request/ACK request-reply en `connection-requests.ts`.
- `svrs/auth/adapters/memory-store.ts` queda como composition root de 39 lineas; la logica se reparte en stores internos de credentials, flows, linked accounts, sessions/devices, refresh y state/snapshot.
- `arts/perm/client.ts` delega transporte HTTP/JSON en `client-http.ts`; el cliente queda centrado en cache, snapshot y fallback.
- `arts/perm/client.ts` delega TTL, cache positiva, backoff de fallos e invalidacion por prefijo en `client-cache.ts`.
- `arts/http/engine-http.ts` delega ejecucion de cada intento, hooks pre-request y diagnosticos request/network en `request-attempt.ts`.
- `arts/timr/engine-timers.ts` delega `TimerHandle` cancel/reschedule en `timer-handle.ts`.
- `arts/stor/engine-storage.ts` delega IDs de adapter, bus keys y suscripciones cross-tab en `adapter-registry.ts`.
- `arts/stor/engine-storage.ts` delega el registro de defaults conflictivos en `defaults-registry.ts`.
- `libs/cach/engine.ts` delega creacion/escritura/fetch-store de envelopes en `runtime-io.ts`.
- `libs/cach/engine.ts` delega safe-delete y clear en `runtime-delete.ts`.
- `arts/sess/engine-session.ts` delega la clasificacion `none/anonymous/identified` en `session-identity.ts`.
- `arts/sess/engine-session.ts` delega snapshot, generation, dispatch, persistencia y commits en `session-state.ts`.
- `arts/sess/engine-session.ts` delega la resolucion de revoke local/global/degradado en `session-revoke.ts`.
- `arts/sess/engine-session.ts` delega refresh, stale-generation, validacion y diagnosticos de refresh en `session-refresh.ts`.
- `arts/perm/client.ts` delega claves/scope de cache en `client-keys.ts` y lectura de snapshot en `client-snapshot.ts`.
- `arts/stor/engine-storage.ts` delega lectura/escritura/validacion/migracion de entradas en `entry-runtime.ts`.
- `arts/stor/entry-runtime.ts` delega defaults, serializer, validate y merge-defaults en `entry-values.ts`.
- `arts/timr/engine-timers.ts` delega armado nativo, ejecucion, finalizacion e intervalos en `timer-runner.ts`.
- `arts/cach/active-cache.svelte.ts` delega la entry reactiva en `active-cache-entry.svelte.ts` y normalizacion de errores en helper compartido.
- `arts/auth/active-auth.svelte.ts` delega CSRF, POST autenticados, validacion de respuestas, normalizacion de errores e invalidacion de cache en `active-auth-runtime.ts`.
- `arts/conn/connection.ts` delega apertura/cierre logico e `isConnected` en `connection-lifecycle.ts`.
- `arts/conn/connection.ts` delega la programacion de reconnect y exhaustion en `connection-reconnect-runtime.ts`.
- `libs/perm/evaluator.ts` delega helpers puros de resultado, dependencias, cadenas logicas y comparacion en `evaluator-helpers.ts`.
- `arts/http/engine-http.ts` delega la validacion preflight de `bodySchema` en `request-validation.ts`.
- `arts/http/engine-http.ts` delega la resolucion final de response/error en `response-resolution.ts`.
- `arts/sium/core/pipe.ts` queda centrado en composicion; factories `refine/transform/codec/meta` viven en `steps.ts`.
- `libs/cach/engine.ts` delega lectura `get()` en `runtime-get.ts`.
- `libs/cach/engine.ts` delega escritura `set()` en `runtime-set.ts`.
- `libs/cach/engine.ts` delega `query()` en `runtime-query.ts`; conserva composition root para context/io/delete/invalidate/mutate/explain.
- `arts/sium/engine-sium.ts` delega resolucion Lang/fallback e issues en `engine-resolver.ts`.
- `arts/sium/engine-sium.ts` delega wrappers `validate/validateSync` y diagnostico de validacion en `engine-validation.ts`.
- `arts/conn/connection.ts` delega decode/routing de frames entrantes en `connection-message-router.ts`.
- `arts/conn/connection.ts` delega el intento open/auth/flush/join en `connection-connect.ts`.
- `arts/conn/connection.ts` delega current transport, attach/detach y close en `connection-transport-runtime.ts`.
- `arts/conn/connection.ts` delega cierre intencional y close-event handling en `connection-close.ts`.
- `arts/conn/connection.ts` delega disposed/reuse/singleflight de connect en `connection-connect-controller.ts`.
- `arts/timr/engine-timers.ts` delega fan-out de listeners y diagnostico de listeners en `timer-events.ts`.
- `arts/timr/engine-timers.ts` delega la construccion de entradas internas en `timer-entry.ts`.
- `arts/timr/engine-timers.ts` delega cancelacion y seleccion por scope en `timer-cancel.ts`.
- `arts/timr/engine-timers.ts` delega entrada viva, contexto de task y max-runs en `timer-entry.ts`.
- `libs/color/segments.ts` reutiliza helpers locales para canales requeridos y parsing por rango RGB/HSL.
- `libs/days/segments.ts` centraliza el formateo de hora 12h y mantiene imports agrupados.
- Integracion total ampliada: `Auth.signOut()` valida anonimizacion, invalidacion de `Permissions` y evento `Cache.invalidate`.
- Tanda focalizada verde: `npx vitest run src/arts/conn src/libs/cach src/arts/cach src/svrs/cach src/arts/auth src/svrs/auth src/libs/auth src/arts/aapp/test/ecosystem.integration.test.ts` -> 19 archivos, 82 tests.
- `/test/ecosystem` revisado en navegador: carga sin errores de consola, `ar` cambia a `rtl`, Formats se actualiza por locale, Perm cambia con rol `viewer`, Cach re-scopea por locale y Conn loopback publica/recibe.
- `src/arts/aapp/test/ecosystem.integration.test.ts` ampliado para cubrir rol `viewer` no-allow y cache re-scoped por locale.
- Referencias residuales de marca anterior eliminadas de `src/` fuera de rutas temporales: docs, páginas de test y constantes de cookies/headers auth usan ahora `Active/active`.
- `src/arts/conn/README.md` ampliado: contrato de raíz/conexión/canal, estados, transportes, request/reply, reconnect, heartbeat, sesión, diagnostics/logger, errores y testing.
- `src/arts/fmts/README.md` ampliado con guia de uso, `LocaleSource`, contrato auto/manual, submodulos, listeners, integracion con `aapp` y tests.
- `fmts` redujo boilerplate activo con helpers `readFrom` / `writeTo` y los engines comparten directamente las funciones de `createFormatsLocaleState`; APIs públicas sin cambios.
- `src/web/routes/temp/` corregido: scripts tipados, warnings Svelte eliminados y compatible con `npm run check`.
- `aapp` integration reforzado: `Connections` creadas antes de `Sess` reciben eventos posteriores de sesion y cierran en revoke.
- `aapp` composition reforzado: `Frontend.dir` reacciona a locale solo mientras esta en `auto`; los overrides manuales no se pisan.
- `src/arts/fend/README.md` ampliado: API, composicion via App, contrato auto/manual, locale/dir, salida DOM, integracion `adom`, persistencia, SSR y tests.
- `src/arts/logr/README.md` aclara contrato comun `Logger` en `$libs/logr` y `Diagnostics` como capa catalogada encima del logger, sin mini-loggers por modulo.
- Checklist `before_0_1.md` alineada con el estado real: CI sin lint global de momento, `test:all` como gate de release, presupuesto de bundle en 70 KB gzip y docs de versionado/public surface.
- Nuevo smoke estatico `scripts/static-smoke.mjs` integrado en CI y `test:all`; verifica landing docs, instalacion, AI agents, security, `/test/ecosystem` y manifest.
- Docs de `/active/get-started/installation`, `/versioning` y `/ai-agents` actualizadas con vocabulario de gates (`check`, `test`, `build`, `test:static`, `test:bundle`, `test:all`).
- Tests de superficie pública en `src/arts/aapp/test/active-app.test.ts` cubren roots always-present y factorias scoped.
- Tests de `perm` cubren stale `batch()` y `what()` tras cambio de actor/snapshot.
- `svrs/perm` tiene contrato DB operativo: `loadActivePermissionPolicies`, `createPermissionDatabaseProviders`, repositorios tipados para políticas/relaciones, tests focales y SQL PostgreSQL reforzado con target generated columns, índice por target, unique active-version y check de effect en audit.
- Docs de `svrs/perm` y `/active/docs/perm` explican tablas, lifecycle de políticas, provider de relaciones, tenant resolution, wiring server y audit de decisiones.
- `svrs/cach/README.md` creado: documenta boundary server/client, API de `EngineCache`, scopes seguros, policies, invalidacion por epochs, integracion auth/sess/perm, contrato de adapters, observabilidad, seguridad y tests.
- `svrs/auth` tiene SQL PostgreSQL de referencia en `src/svrs/auth/sql/postgres.sql` y README ampliado con mapping `AuthRepository`, tablas, reglas de produccion, refresh rotation con locks y limpieza periodica.
- `/active/docs/auth` y `/active/docs/cach` reflejan ahora las piezas server: persistencia DB de auth, tablas de referencia, `createDbAuthAdapter`, boundary cache server/client y modelos de adapter.
- `before_0_1.md` alineado con evidencias actuales: S1/S2/S3/S4/S5/S6/S7 y A1/A2/A3/A4/A5 quedan cerrados; `SECURITY.md` cubre cookie scopes, CSRF, refresh rotation, OAuth state/PKCE, MFA actual y actor/tenant model.
- `before_0_1.md` tambien marca como cerrados repo hygiene, build/tooling y assets de marca segun ficheros reales (`README`, `SECURITY`, `CONTRIBUTING`, `CHANGELOG`, `.github`, `static/*`, `app.html`).
- `.gitignore` evita que estado local de `.claude/`, `.opencode/` y `.idea/vcs.xml` entre accidentalmente en commits.
- Memory adapters de auth/cache emiten warning productivo deduplicado; los harness de test lo suprimen donde corresponde.
- `cach` expone `defaultMemoryAdapter` para configurar solo el adapter memory implícito cuando no se pasa `adapter`; las rutas `/test/aapp`, `/test/cach`, `/test/conn`, `/test/ecosystem` y `/test/perm` lo usan para silenciar el warning productivo de forma explícita en demos/prerender sin apagarlo para apps reales.
- Validación focal posterior al ajuste de `cach`: `npm run check`, `npx vitest run src/libs/cach src/svrs/cach src/arts/cach src/arts/aapp/test/active-app.test.ts`, `npm run build` y `npm run test:static` verdes.
- Documentación de `buss` corregida para reflejar el código real: `App.Bus` central, contratos en `$libs/buss`, engine en `$buss`, eventos públicos vía `publishApp*`/`onApp*`, `BusEnvelope` real (`at`, no CloudEvents puro) y sin sección obsoleta de gaps ya implementados.
- Tests de `aapp`/`conn` normalizados para publicar `APP_EVENT_USER_IDENTITY_CHANGED` mediante `publishAppUserIdentityChanged(...)`, de modo que la suite ejercita las mismas guardas de runtime/payload que la app.
- Validación focal posterior al ajuste de `buss`: `npx vitest run src/arts/buss/test/engine-bus.test.ts src/libs/aapp/test/events.test.ts src/arts/aapp/test/active-app.test.ts src/arts/aapp/test/session-translator.test.ts src/arts/aapp/test/ecosystem.integration.test.ts src/arts/conn/test/connection.test.ts` -> 6 archivos, 74 tests verdes; `npm run check` verde.
- Integración total ampliada con un caso `permissionsRefresh + tenantSwitched + locale` mientras una conexión de chat sigue abierta: `permissionsRefresh` invalida solo Permissions, `tenantSwitched` limpia Permissions + Cache sin reautenticar el socket, y el cambio de locale re-scopea Cache/Formats/Frontend sin reauth.
- Integración total ampliada con un caso de webhook/realtime de permisos: `conn` recibe `permissions.changed`, la app publica `APP_EVENT_PERMISSIONS_REFRESH_REQUESTED` mediante helper seguro, `Permissions` invalida por opt-in, descarta respuestas stale en vuelo y `Cache`/`Connections` no hacen side-effects destructivos.
- Integración total ampliada con un caso de revocación remota de sesión por realtime: `conn` recibe `session.revoked`, la app revoca `Sess`, `aapp` publica cambio de identidad, `Permissions`/`Cache` limpian estado identity-scoped y el chat queda cerrado sin reutilizar credenciales.
- Validación focal posterior al nuevo caso compuesto: `npx vitest run src/arts/aapp/test/ecosystem.integration.test.ts` -> 9 tests verdes; `npx vitest run src/arts/aapp/test/ecosystem.integration.test.ts src/arts/aapp/test/active-app.test.ts src/arts/buss/test/engine-bus.test.ts src/libs/aapp/test/events.test.ts src/arts/conn/test/connection.test.ts src/arts/cach src/arts/perm` -> 7 archivos, 90 tests verdes; `npm run check` verde.
- No commitear `.idea/`, `.claude/` ni `.opencode/`.
- No commitear ni tocar `src/web/routes/temp/` salvo peticion explicita; hay cambios locales en `temp/c2` que quedan fuera del commit de cierre.
Pendiente para manana:
- Revisar documentacion restante de todos los modulos con ojo de consumidor externo: API real, factories, metodos, opciones, errores, ejemplos, dinamicas auto/manual y errores comunes.
- Prioridad especial siguiente: decidir si los SQL de referencia de `auth`/`perm` son cierre suficiente para `0.1` o si hace falta un adapter ejecutable para un ORM concreto.
- Continuar la reduccion de archivos grandes: prioridad `conn/connection.ts`, `svrs/auth/engine-auth.ts`, helpers de `cach` y piezas repetidas en docs/test harness.
- Ampliar tests de integracion cruzada: `auth + sess + perm + cach + http + stor + fmts + conn + timr + logr`, incluyendo login/logout, cambio de actor, invalidacion cache, cambio locale, permisos y reconnect.
- Revisar la adopcion final del contrato comun `Logger` / diagnostics en todos los modulos, sin acoplar artefactos a `arts/logr`; primera pasada limpia salvo `aapp` como composition root, `arts/logr` y tests.
- Mantener `npm run test:all` como gate regular antes de commits grandes; `npm run lint` sigue siendo deuda global separada, no meter nueva deuda en archivos tocados.

File diff suppressed because it is too large Load Diff

@ -1,259 +0,0 @@
# Active framework — ecosystem audit (arts / libs / svrs)
Scope: `src/arts/`, `src/libs/`, `src/svrs/`. The `src/web/` folder is
explicitly excluded. Findings are grouped by audit axis. Severity tags:
- **bug** — incorrect runtime behaviour
- **violation** — breaks an established framework rule
- **drift** — code or docs out of sync with the rest
- **opportunity** — refactor / clean-up that is not a bug today
Each item includes file paths and line ranges so the fix can be applied
without re-searching. Items are ordered roughly by priority within each
section.
---
## 1. Layer-boundary violations
Rule: `arts/<X>` may import from `$libs/*` and from its own internal
files; cross-artifact imports (`$<other-artifact>`) are reserved for the
composition root `arts/aapp`. `libs/*` may not import from any
`$<artifact>` alias. `svrs/*` is similarly restricted.
### Real coupling (runtime, not test) — **violation**
- `src/arts/fend/active-frontend.svelte.ts:2` —
`import { createActiveDom, type ActiveDom } from '$adom';`
`arts/fend` reaches into `arts/adom`. The DOM port should live in
`libs/dom` and `fend` should accept an interface, leaving the DOM
engine wiring to `aapp`.
- `src/arts/sium/engine-resolver.ts:1-2` —
`import { ID_FALLBACK_SEPARATOR, parseLangRef } from '$lang';`
`arts/sium` imports runtime symbols from `arts/lang`. These two
values are pure helpers and belong in `libs/lang` so `sium` can
consume them without crossing artifacts.
- `src/arts/conn/engine-connections.ts:1` —
`import { createEngineTimers } from '$timr';`
`arts/conn` constructs its own timer engine instead of accepting a
`TimerScheduler` through options. The pattern elsewhere (sess, cach,
perm) is "App injects Timers"; conn should follow it.
### Type-only cross-artifact imports — **opportunity**
These compile away but still couple the two artifacts at the type
level. They should be promoted to `libs/<X>/types.ts` so consumers
import the contract without naming the engine.
- `src/arts/auth/client.ts:2` — `import type { EngineHttp, HttpBodyInit, HttpResult } from '$http';`
- `src/arts/sess/types.ts:33` — `import type { SyncStorageAdapter } from '$stor';`
- `src/arts/sium/engine-resolver.ts:1` — `import type { EngineLang, LangParams, SupportedLocale } from '$lang';`
- `src/arts/conn/types.ts` and related — `import type { TimerScheduler, ... } from '$timr';`
### `svrs/*` and `aapp` — clean
`src/svrs/*` does not import any `$<artifact>` alias. `arts/aapp` is
the composition root and is allowed to import every engine; the
imports there are intentional.
---
## 2. Naming convention drift
Rule: every module-event / method / diagnostic value must be a
**module-scoped lowercase string** (`'sess.lifecycle.adopted'`,
`'cach.delete'`, `'conn.connected'`, `'buss.event.published'`). Bare
names (`'delete'`, `'hit'`, `'auth_failed'`) collide across artifacts
once aggregated in logs or wired through the bus.
### Unscoped diagnostic event values — **violation**
- `src/arts/conn/consts.ts:4-20` — `CONNECTION_DIAGNOSTIC_EVENTS` has
15 bare values: `'auth_failed'`, `'browser_reconnect'`,
`'connect_failed'`, `'frame_decode_failed'`, `'frame_encode_failed'`,
`'heartbeat_timeout'`, `'listener_threw'`, `'reauth_failed'`,
`'reconnect_exhausted'`, `'send_failed'`, `'session_changed'`,
`'session_expired'`, `'session_refreshed'`, `'session_revoked'`,
`'transport_error'`. All should carry the `'conn.'` prefix.
- `src/arts/sess/consts.ts:4-20` — `SESSION_DIAGNOSTIC_EVENTS` has 15
bare values: `'adopt_server_invalid'`, `'disposed_access'`,
`'listener_threw'`, `'refresh_*'` (5×), `'revoke_global_*'` (3×),
`'storage_*'` (3×). All need `'sess.'`.
- `src/arts/perm/consts.ts:41-45` —
`PERMISSION_CLIENT_DIAGNOSTIC_EVENTS` has `'remote_batch_failed'`,
`'remote_check_failed'`, `'remote_what_failed'`. All need `'perm.'`.
- `src/svrs/perm/consts.ts:3-7` — `PERMISSION_DIAGNOSTIC_EVENTS` has
`'decision'`, `'denied'`, `'indeterminate'`. All need a `'perm.'`
prefix (or a server-specific scope, e.g. `'perm.server.decision'`).
### Unscoped method constants — **violation**
- `src/svrs/perm/consts.ts:19-25` — `PERMISSION_METHOD_*` constants
hold bare names (`'check'`, `'can'`, `'assert'`, `'explain'`,
`'what'`, `'who'`, `'filter'`). The client-side counterpart in
`arts/perm` already uses `'perm.check'`, `'perm.can'`, … — server
must align.
### Why this matters
`Logger.warn(category, message)` aggregates across artifacts. With
unscoped diagnostic strings, a value like `'listener_threw'` appears
under both `category: 'conn'` and `category: 'sess'` and is
indistinguishable in any flat log search.
---
## 3. Error-class and dispose-pattern consistency
### Hard-coded error `name` strings — **violation**
The convention is `this.name = <MOD>_ERROR_NAME_*` (read from
`consts.ts`).
- `src/arts/sium/core/types.ts:302` — `SiumValidationError` sets
`this.name = 'SiumValidationError'` literally.
- `src/arts/sium/core/types.ts:322` — `SiumAsyncSchemaError` same
pattern.
### Error classes without matching `is*Error` guards — **violation**
The pattern is "one class, one guard". Missing guards make `instanceof`
checks leak into call sites.
- `src/arts/conn/errors.ts:49` — `ConnChannelAlreadyExistsError` no
`isConnChannelAlreadyExistsError`.
- `src/arts/conn/errors.ts:58` — `ConnChannelNotFoundError` no
`isConnChannelNotFoundError`.
- `src/libs/auth/errors.ts:24-66` — 10 auth error classes have no
guards: `AuthAccountNotLinkedError`, `AuthSessionRevokedError`,
`AuthAssuranceRequiredError`, `AuthRateLimitedError`,
`AuthTenantBoundaryError`, `AuthOAuthStateInvalidError`,
`AuthOAuthProviderError`, `AuthOtpInvalidError`,
`AuthMfaRequiredError`, `AuthWebAuthnError`.
### `dispose()` without idempotency guard — **bug**
The convention is `if (disposed) return; disposed = true; …`. Calling
`dispose()` twice should be a no-op.
- `src/arts/conn/active-connections.svelte.ts:118` — second call would
re-traverse `detachers` (empty by then) and call `engine.dispose()`
again.
- `src/arts/fend/active-frontend.svelte.ts:185` — second call would
re-invoke `ownedDom?.dispose()` if the inner DOM was created here.
### `Logger` option not defaulting to `SILENT_LOGGER` — **violation**
Pattern in the codebase: `const logger = options.logger ?? SILENT_LOGGER;`
(see `arts/buss/engine-bus.ts:54`).
- `src/arts/stor/engine-storage.ts` — `createStorageDiagnostics(options.logger)`
passes the `undefined` straight through.
- `src/arts/timr/engine-timers.ts` — same issue with
`createTimerDiagnostics`.
- `src/arts/http/engine-options.ts` — same issue with
`createHttpDiagnostics`.
If diagnostics ever calls a method on the logger without guarding for
`undefined`, these three engines crash when used without a logger
(e.g. in unit tests or smoke harnesses). Verify each diagnostics
constructor; if it already null-checks internally, demote to
**opportunity** for consistency only.
### Generic `throw new Error(...)` in runtime code — **opportunity**
Typed error classes carry the discriminating `name` plus optional
fields (cause, code). Generic throws lose that.
- `src/arts/lang/engine-lang.ts:124, 133` — circular-reference message
thrown as plain `Error`.
- `src/libs/perm/runtime.ts:154, 181` — throws
`Error(PERMISSION_ERROR_MSG_FILTER_REQUIRES_ACTOR)` where a
`PermissionInvalidFilterError` would fit.
---
## 4. Dead code, casts, and `any`
Codebase is largely clean. Specific findings worth acting on:
- `src/svrs/auth/adapters/db.ts:40, 64, 72, 79, 111, 134` — six
repetitions of `as unknown as Readonly<Record<string, unknown>>`.
Consolidate into a `DbRow` type alias or a `coerceRow()` helper.
**opportunity**.
- `src/libs/buss/silent-bus.ts:137` — `} as unknown as EngineBus;`.
Acceptable because the no-op surface is intentionally generic, but
worth replacing with proper generic constraints when the object
literal grows. **opportunity**.
- `src/arts/lang/types.ts:83` — `HasParams<T>` uses `any` deliberately
(contravariant variance). Comment is in place. **no action**.
- `src/libs/days/_vendor/**` — multiple `@ts-ignore` and `TODO`
comments. Vendor code, leave as-is. **no action**.
No dead exports were found in the spot checks of `libs/buss`,
`libs/auth`, `arts/conn`, `libs/aapp/events.ts`. No duplicate type
definitions detected across the audited modules.
---
## 5. Documentation drift
Only the high-traffic READMEs were sampled.
### `arts/buss/README.md`
- The session-translator example (around line 563) hardcodes `cause:
APP_USER_IDENTITY_CAUSE_SESSION_ADOPTED` for every event. The actual
translator (`arts/aapp/integrations/session-translator.ts:37`) calls
`resolveAppIdentityCause(payload.event)` to map each `EVENT_*` to
the correct cause. The example should mirror that mapping or it
teaches the wrong pattern. **drift**.
- The README was already partially refreshed earlier in this session;
re-check the "Implementation gaps → Done" list now that
`subscribe()`, `publishCausedBy()`, `invokeListener`, the depth
guard, the payload-cloneable check, the Svelte adapter, the
`createBusRecent` wrapper, the `bus-context.svelte.ts`, and the
`APP_EVENT_RUNTIMES` table all landed.
### `arts/sess/README.md`
- The README still references a `publishSessIdentityChanged` helper.
Actual export is `publishSessLifecycleEvent` in
`arts/sess/bus-helpers.ts`. Either rename in code or update the
README. **drift**.
### `arts/cach/README.md`
- README written in Spanish (a deliberate choice; not flagged as a
rule break). At lines 11–12 it suggests importing
`createEngineCache` from `$svrs/cach`, but the artifact's barrel
(`arts/cach/index.ts`) only exports `createActiveCache`. Add a one-
line note clarifying the engine vs active split. **drift**.
### Other artifacts (aapp, adom, auth, conn, fend, fmts, http, lang, logr, perm, sium, stor, timr)
Spot checks did not surface drift — claims match exports.
---
## Suggested fix order
1. **Coupling fixes** (Section 1, runtime violations). They constrain
every other refactor: until `fend → adom`, `sium → lang`, and
`conn → timr` are decoupled, those artifacts cannot be tested in
isolation.
2. **Naming convention pass** (Section 2). Mechanical, scoped to four
`consts.ts` files, and unblocks coherent log aggregation. Update
any test that hard-codes the literal values.
3. **Error guards + idempotent `dispose`** (Section 3). Low-risk and
localised; prevents subtle bugs if any consumer ever calls
`dispose()` twice or tries to discriminate auth errors.
4. **Logger defaults** (Section 3). Verify the three diagnostics
constructors and add `?? SILENT_LOGGER` where missing.
5. **Documentation refresh** (Section 5). Easiest after the code
changes above so the README reflects the final shape.
6. **Cast consolidation** (Section 4). Pure refactor, ship it whenever
convenient.
Items in Section 4 (vendor code, justified `any`, intentional cast)
require **no action**.

@ -1,309 +0,0 @@
# before_0_1
> Lo que hay que **cerrar, incluir o verificar** antes de etiquetar
> `0.1.0`. Construido sobre las evidencias del repo a fecha de hoy y los
> hallazgos del audit. Un par de bloqueantes son políticas / hygiene que
> pesan más que cualquier hallazgo técnico individual.
---
## 1. Qué significa `0.1` aquí (definición operativa)
`0.1` no es _production-ready_. Es la versión que **un equipo externo
puede probar sin sorpresas grandes**, sabiendo que la API pública no
romperá silenciosamente durante la línea `0.1.x`. Esto fija el listón:
- API pública de los 9 roots always-present **congelada** durante `0.1.x`.
- Las 5 factorías scoped (`Sium`, `Session`, `Connections`, `Auth`,
`Permissions`) pueden iterar pero deben respetar deprecation policy.
- Sin bugs de seguridad conocidos en flujos hot.
- Repo aceptable para un PR externo: licencia, README, contributing, CI.
- Docs suficientes para enviar una feature sin leer el código fuente.
- Build estático funciona end-to-end (no solo el typecheck).
Lo que **no** entra en `0.1`: MFA en producción, OAuth con todos los
proveedores, todos los módulos con docs profundas, métricas / telemetría,
ejemplos reales más allá de las páginas `/test`.
---
## 2. Estado real verificado hoy
Antes de listar gaps, lo que ya está cerrado (corroborado leyendo el
repo, los tests y los gates locales):
- **Tests reales**: 108 archivos / 1208 tests verdes.
- **Typecheck**: `npm run check` 0/0/0.
- **Build estático**: `npm run build` verde.
- **Gate completo**: `npm run test:all` verde (`check` + `test` +
`build` + `test:static` + `test:bundle`).
- **Bundle smoke**: `createActiveApp({})` está medido con Vite/OXC en
`scripts/bundle-smoke.mjs`; baseline actual ~65 KB gzip, presupuesto
`0.1` en 70 KB gzip configurable con `ACTIVE_BUNDLE_GZIP_LIMIT_KB`.
- **Refactor wave** documentada en `NEXT_STEPS.md` ha resuelto
parcialmente varios de mis hallazgos del audit:
- **C2/C3** (Auth → Permissions/Cache invalidation) — cableado a través
de `arts/aapp/integrations/auth-cache.ts`. _A verificar_ con tests
de race cross-actor que el flujo real cierra el gap.
- **C1** (perm race cross-actor) — el cliente perm se ha partido en
`client-cache.ts` / `client-keys.ts` / `client-snapshot.ts`. _A
verificar_ que `pending` se invalida en `hydrate` / cambio de scope.
- Cobertura: `auth + sess + perm + cach` ya tienen test de
integración cruzada.
- **Brand brief** (`BRAND.md`) escrito y assets estáticos añadidos.
- **Documentación interna** (`/active`): shell + landing + Get Started,
páginas para todos los módulos bajo `docs/<module>`, sección de capa
servidor (`$svrs`), seguridad, versionado y guía para AI agents. La
profundidad por módulo sigue siendo desigual y es trabajo pendiente.
Lo que **sigue abierto** sale en §3.
---
## 3. Bloqueantes duros para `0.1`
Cada bloqueante lleva una _Acceptance criteria_ verificable.
### 3.1 Repo hygiene
Estado actual:
| Pieza | Estado | Evidencia |
| ------------------ | ------- | --------------------------------------------------------------------------------------------- |
| `LICENSE` | Cerrado | Existe y coincide con `package.json` (`UNLICENSED`). |
| `README.md` | Cerrado | Incluye tagline, quick start, módulos, desarrollo, seguridad y link a `BRAND.md`. |
| `SECURITY.md` | Cerrado | Política de reporte y threat model resumido. |
| `CONTRIBUTING.md` | Cerrado | Setup, verificación, layout, constants-first, logging/diagnostics y deprecation policy. |
| `CHANGELOG.md` | Cerrado | Sigue Keep a Changelog y abre `0.1.0` como target. |
| `.github/` | Cerrado | CI, bug report, feature request y PR template. |
| `.gitignore` | Cerrado | Ignora `tmp-active-docs-*.log` y estado local de IDE/herramientas sin ignorar auditorías/docs. |
| `package.json` | Cerrado | `license`, `engines.node`, `repository`, `bugs` y `homepage` presentes. |
Queda como higiene manual: no commitear directorios personales (`.claude/`,
`.opencode/`, `.idea/`) ni logs temporales ya ignorados.
### 3.2 Seguridad
Varios puntos del audit ya están cerrados en código y tests. Mantenerlos aquí
como evidencias evita que el checklist vuelva a arrastrar deuda antigua:
| ID | Estado | Evidencia |
| --- | -------- | ------------------------------------------------------------------------------------------------------ |
| S1 | Cerrado | `rate-limit.test.ts`; password, recovery y OAuth llaman `enforceAuthRateLimit(...)`. |
| S2 | Cerrado | `oauth-pkce.test.ts`; `completeOAuth` entrega `codeVerifier` al provider y valida el challenge. |
| S3 | Cerrado | `engine-password.test.ts`; `verifyMfaChallenge` no está en la superficie estable de `EngineAuth`. |
| S4 | Cerrado | Auth y Cache memory adapters emiten warning productivo con tests; `svrs/perm` no tiene memory adapter. |
| S5 | Cerrado | `AUTH_COOKIE_POLICY.SAME_SITE` es `strict`; `csrf.test.ts` lo cubre. |
| S6 | Cerrado | `client-http.test.ts` cubre in-flight decisions y batch/what tras cambio de actor/scope. |
| ID | Bloqueante | Acceptance |
| --- | ------------------------------ | ------------------------------------------------------------------------------------------------------ |
| S7 | Cerrado | `SECURITY.md` documenta cookie scopes, CSRF flow, refresh rotation, OAuth state binding, MFA actual y actor/tenant model. |
### 3.3 Correctitud / API
| ID | Estado | Evidencia |
| --- | ------- | -------------------------------------------------------------------------------------------------------------- |
| A1 | Cerrado | `AUTH_METHOD_*` / `PERMISSION_METHOD_*` se usan en `ensureLive`; tests verifican mensajes post-dispose. |
| A2 | Cerrado | `active-app.test.ts` incluye snapshot de superficie de roots y factories scoped actuales. |
| A4 | Cerrado | `AuthRateLimitPort` se exporta porque S1 está cableado y cubierto por tests. |
| A5 | Cerrado | Mono Lang está documentado como passthrough tipado amplio; warnings DEV pasan por `Logger` bajo `lang.mono`. |
| ID | Bloqueante | Acceptance |
| --- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
| A3 | Cerrado | `CONTRIBUTING.md` describe `@deprecated`, warning runtime, `__EXPERIMENTAL_*` y ventana mínima de una minor. |
### 3.4 Documentación mínima
`/active` ya tiene rutas para todos los módulos, pero la profundidad sigue
siendo desigual. Para `0.1`:
| Bloqueante | Acceptance |
| ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| Los **9 roots always-present** con docs reales (no stub). | `App, Lang, Logger, Formats, Frontend, Dom, Storage, Http, Timers, Cache` tienen sección Overview / Quick start / API / Limits / Testing. |
| Las 5 factorías scoped pueden ser stub si llevan una nota "shape final en `0.2`". | El stub indica explícitamente qué partes de su API no están comprometidas. |
| `SECURITY.md` (cubre S7) **enlazado** desde sidebar de `/active`. | Item en la sección `Get Started` con ruta `/active/security`. |
| Migration / versioning policy publicada. | Página `/active/get-started/versioning` o sección dentro de Composition. |
### 3.5 Build y tooling
| Pieza | Estado | Evidencia |
| ---------------------- | ------- | -------------------------------------------------------------------------------------------------- |
| Build estático | Cerrado | `npm run build` está incluido en `test:all` y fue verificado localmente. |
| CI en GitHub Actions | Cerrado | `.github/workflows/ci.yml` ejecuta typecheck, tests, build, static smoke y bundle smoke. |
| Scripts npm completos | Cerrado | `test:typecheck`, `test:static`, `test:bundle` y `test:all` existen. |
| Reproducibilidad | Cerrado | `package-lock.json`, `.nvmrc` y `engines.node >=22` presentes. |
| Bundle size sanity | Cerrado | `scripts/bundle-smoke.mjs` verifica `createActiveApp({})` contra `ACTIVE_BUNDLE_GZIP_LIMIT_KB`. |
> `npm run lint` existe, pero sigue siendo deuda global pre-`0.1`; CI no
> lo ejecuta hasta que el árbol completo quede limpio. La regla mientras
> tanto es no añadir deuda nueva en archivos tocados.
### 3.6 Marca y assets
Estado actual:
| Pieza | Estado | Evidencia |
| --------------------------- | ------- | ----------------------------------------------------------------------------------------- |
| `static/favicon.svg` | Cerrado | SVG presente. |
| `favicon-16/32/48.png` | Cerrado | PNGs presentes. |
| `apple-touch-icon.png` | Cerrado | Icono 180 presente. |
| `icon-192/512.png` | Cerrado | Iconos PWA presentes. |
| `icon-maskable-512.png` | Cerrado | Icono maskable presente. |
| `og-image.png` | Cerrado | Imagen OG presente. |
| `app.html` | Cerrado | Theme color, description, OG tags, favicons, apple-touch-icon y manifest enlazados. |
| `manifest.webmanifest` | Cerrado | Manifest presente con iconos. |
Queda como revisión manual: abrir `/active` en navegador real y comprobar
contraste/legibilidad de sidebar, code blocks y callouts en tema claro/oscuro.
---
## 4. Bloqueantes blandos (pueden caer en `0.1.1`–`0.1.x`)
Son hallazgos del audit con impacto real pero acotables a un parche:
- M4 (refactor `validateOptionalField` en sess) — limpieza; no rompe nada.
- M5 (acoplamiento `aapp` ↔ `stor` en mensajes de log) — se puede pulir
más tarde sin tocar la superficie pública.
- M9 (body-scroll-lock con scheduling propio en vez de delegar a `timr`) —
optimización; no afecta la API.
- M10 (HTTP recompone headers en cada retry) — perf, no semántica.
- M14 (sess `BroadcastChannel` ignora el `event` recibido y vuelve a
leer storage) — documentar como diseño y seguir.
- m1–m15 (menores: docs faltantes, magic strings residuales, refactors
cosméticos).
Estos pueden entrar como issues etiquetados `0.1.x` y resolverse de
manera incremental.
---
## 5. Fuera de alcance para `0.1` (explícito)
Para evitar que el sprint se infle, dejar fuera y comunicarlo:
- MFA real (TOTP, WebAuthn, recovery codes).
- OAuth provider catalog (GitHub, Google, etc.) — solo el motor + un
proveedor de ejemplo.
- Métricas / telemetría / OpenTelemetry export.
- Server-side rendering completo del docs site (es estático).
- Componentes UI prefabricados (eso vive _encima_ de los artefactos).
- Internacionalización del propio docs site (English-only en `0.1`).
- Distribución por `npm publish` — `0.1` puede vivir solo como repo
template / submódulo / vendoring. Decidir si publicar es objetivo.
---
## 6. Verificación pre-tag (un solo comando)
Antes de etiquetar `0.1.0`, todo esto debe pasar en CI y local:
```sh
# 1. Gate completo automatizado
npm run test:all # → check + tests + build + static smoke + bundle smoke
# 2. Tipos, si se quiere aislar el paso
npm run check # → 0 errors / 0 warnings
# 3. Suite, si se quiere aislar el paso
npm test # → 0 failed; assert >= 1208 tests
# 4. Build estático, si se quiere aislar el paso
npm run build # → produce build/ sin errores
# 5. Bundle smoke, si se quiere aislar el paso
npm run test:bundle # → createActiveApp({}) <= 70 KB gzip por defecto
# 6. Static smoke, si se quiere aislar el paso tras build
npm run test:static # → rutas/docs/assets críticos existen en build/
```
Y, cuando se quiera verificar el sitio servido:
```sh
npm run preview
curl -fsS http://localhost:4173/active | grep -q "active"
curl -fsS http://localhost:4173/test/ecosystem | grep -q "ecosystem"
```
`npm run lint` debe ejecutarse antes del tag, pero hoy todavía representa
una limpieza global separada. No debe bloquear el gate automático hasta
que esa deuda esté cerrada.
Y manual:
- [ ] Abrir `/active` y `/test/ecosystem` en Chromium, Firefox, Safari
desktop. **No console errors. No layout shifts grandes.**
- [ ] `App.dispose()` se llama dos veces seguidas → no throw, no warn.
- [ ] Cambio de locale a `ar` aplica `dir="rtl"` y propaga a Formats.
- [ ] Sign-in → sign-out limpia perm cache y session bridge.
- [ ] Refresh durante revoke → no resucita la sesión.
- [ ] 404 en una ruta `/active/docs/<inexistente>` → muestra fallback.
- [ ] Tema oscuro: contraste suficiente en sidebar activo, code blocks,
callouts.
---
## 7. Orden sugerido
Dos sprints serios bastan si nadie se desvía.
**Sprint A — Seguridad y correctitud**
Cerrado para el estado actual: S1, S2, S3, S4, S5, S6, S7, A1, A2, A3, A4 y A5.
**Sprint B — Hygiene, docs y release (1 semana)**
1. Repo hygiene: LICENSE, README, CONTRIBUTING, SECURITY, CHANGELOG,
`.github/` con CI básica.
2. A2/A3 — snapshot tests de superficie + política de deprecación
escrita. **Cerrado para la superficie actual**; mantenerlo actualizado
con cada miembro público nuevo.
3. Completar los 9 roots always-present en `/active/docs`.
4. Marca: ejecutar al menos `favicon.svg` + `favicon-32/16` +
`og-image.png` + meta tags en `app.html`.
5. Build estático, static smoke y bundle smoke verificados en CI.
6. Tag `0.1.0` con changelog real.
---
## 8. Decisiones abiertas (necesitan criterio humano antes de avanzar)
1. **¿Se publica en npm o se distribuye como repo / template?** Cambia
la forma de empaquetar (`exports`, `files`, `sideEffects` granular).
2. **¿`AuthRateLimitPort` se cablea ahora o se posterga?** Si se
posterga, sacarlo del barrel hasta `0.2`.
3. **¿MFA en `0.1` o explicitamente en `0.2`?** Hoy está stubbed; un
stub que lanza no debe estar en la superficie pública.
4. **¿`mono-lang` es público o interno?** Si público, se documenta y
se asume el cast laxo; si interno, se quita del barrel.
5. **¿La doc de los 5 factorías scoped (`Sess`, `Auth`, `Perm`, `Conn`,
`Sium`) llega a `0.1` profunda o stub?** Mi recomendación: `Auth`,
`Sess` y `Perm` profundas (son las que un consumidor pisa en
onboarding); `Conn` y `Sium` pueden quedar como stubs marcados.
6. **¿Adapter Static es el target final?** Si hay plan de SSR (auth
server-side real), conviene cambiar a `adapter-node` antes de
`0.1` para no romper consumidores en `0.1.x`.
7. **¿La API de `App.Cache.invalidate({tags})` queda como contrato
estable?** Se merece ser parte del primer snapshot test de
superficie porque cae en el camino crítico de `Auth`.
---
## 9. Métricas de "listo"
Cuando todo lo anterior pase, el repo debería poder responder _sí_ a:
- "¿Un dev externo clona, sigue el README y arranca `/active` en
&lt; 5 min?"
- "¿Existe un punto de contacto claro para reportar un fallo de
seguridad?"
- "¿Hay manera de saber qué cambia entre `0.1.0` y `0.1.1` sin leer
commits?"
- "¿La superficie de los 9 roots always-present está documentada con
una garantía explícita de no-break en la línea `0.1.x`?"
- "¿Si elimino los assets de marca, queda algo identificable en el
navegador?" (Hoy: no, `static/` solo tiene `robots.txt`).
Si las cinco respuestas son sí, etiqueta `0.1.0`.

File diff suppressed because it is too large Load Diff

@ -0,0 +1,424 @@
# Ecosistema v2 - mejoras para completar cada modulo
Fuente usada: codigo, configuracion, tests, scripts y estructura real del repo. No se usa documentacion antigua como base.
## Lectura correcta
La version 2.0 no debe buscar "componentes v2 existentes", porque no hay una capa v2 ya montada. Lo que hay que definir es que le falta a cada modulo para que el ecosistema sea completo, usable y componible por encima de la 1.0.
La 1.0 debe cerrar estabilidad, build, typecheck, tests y API publica. La 2.0 debe convertir esos motores en una plataforma completa: componentes UI, devtools, presets, integraciones, adapters reales, contratos tipados y flujos de producto.
## Principios de v2
1. No reescribir nucleos que ya estan maduros. `sium` y `http`, por ejemplo, necesitan capas superiores e integraciones, no una reconstruccion del core.
2. Separar producto de demo/test/docs. Lo reutilizable debe vivir en `src/uix`, `src/arts`, `src/libs` o `src/svrs`, no en rutas de prueba.
3. Cada modulo debe exponer contrato publico, tests propios, fixtures, diagnostico y ejemplos minimos.
4. Las integraciones entre modulos deben ser oficiales: cache + http, sium + uix, perm + uix, prefs + frontend, logger + orca, storage + prefs/cache/session.
5. v2 debe traer experiencia de usuario y de desarrollador: devtools, inspectores, playgrounds, generadores y presets.
## Prioridades globales
### V2-P0 - Base antes de construir encima
- Cerrar los errores actuales de `npm run check` y `npm run build`.
- Eliminar usos de API vieja en rutas demo/test.
- Corregir scripts obsoletos, especialmente aliases antiguos del smoke bundle.
- Alinear Node local/CI y ampliar CI con `lint` y `test:aliases`.
- Congelar barrels publicos por modulo y marcar APIs internas.
### V2-P1 - Capa de producto
- Convertir `src/uix` en la libreria oficial de componentes.
- Crear componentes que usen los motores reales del ecosistema.
- Sustituir demos acopladas por playgrounds que consuman APIs publicas.
- Definir una `ActiveApp` canonica para ejemplos, docs y smoke tests.
### V2-P2 - Integraciones y adapters reales
- Async storage.
- Cache persistente.
- HTTP con cache/dedupe/retry/offline.
- Auth/session/perm con UI y flujos completos.
- Observabilidad transversal con logger/orca/bus/timer.
### V2-P3 - Devtools
- Inspector de `ActiveApp`.
- Timeline de bus/orca/logger/timer.
- Inspector de cache/storage/session.
- Simulador de permisos.
- Generador de formularios desde `sium`.
## Roadmap por modulo
### active-app
Estado: el nucleo ya compone servicios con dependencias, lazy/immediate y disposal ordenado. El problema v2 es hacerlo mas tipado, visible y extensible.
Mejoras v2:
- `AppSchema` exportable para que componentes y rutas no reciban `ActiveApp` generico con servicios `unknown`.
- Presets oficiales: `createClientApp`, `createServerApp`, `createDemoApp`, `createTestApp`.
- Registro de plugins/servicios con manifiesto de capacidades.
- Health graph de servicios: iniciado, lazy, error, disposed, dependencias y tiempo de arranque.
- Devtools de `ActiveApp` para inspeccionar servicios, dependencias y eventos de lifecycle.
- Migrador o capa compat temporal para detectar API vieja con errores claros.
### uix
Estado: `src/uix` existe pero esta vacio. Es el hueco mas grande del ecosistema.
Mejoras v2:
- Primitivas base: `Button`, `Input`, `Select`, `Checkbox`, `Switch`, `Dialog`, `Popover`, `Tabs`, `Tooltip`, `Toast`, `Table`, `Drawer`, `Menu`, `Command`.
- Componentes de formulario conectados a `sium`: `Form`, `Field`, `FieldGroup`, `AutoFields`, `ErrorSummary`, `SubmitBar`.
- Componentes de autorizacion conectados a `perm`: `Can`, `Cannot`, `Gate`, `PermissionBoundary`, `RoleBadge`.
- Componentes de sesion/auth: `LoginForm`, `SessionMenu`, `DeviceList`, `MfaPanel`, `AuthGuard`.
- Componentes operativos: `CacheInspector`, `LogViewer`, `ConnectionStatus`, `PrefsPanel`, `StorageBrowser`, `OrcaTimeline`.
- Contrato visual comun con `frontend`, `prefs`, `adom`, `lang` y `format`.
### sium
Estado: core potente, con introspeccion (`widget`, `widgetOptions`, `kind`, `wrappers`, `effects`, `shape`) y muchas pruebas. No necesita rehacer el nucleo.
Mejoras v2:
- Renderer oficial de formularios en `uix` usando `Form.AutoFields`.
- Registro de widgets por tipo de schema.
- Validacion async y validacion remota con estados de pending/cancelacion.
- Serializacion de schemas para compartir cliente/servidor.
- Generador de formularios, filtros y tablas desde metadata.
- Inspector visual de schema/result/issues para depuracion.
- Paquetes de wrappers comunes: password, money, date range, file, tags, address, permission selector.
### http
Estado: core suficiente y bien aislado por puertos. No necesita una reescritura v2.
Mejoras v2:
- Cliente tipado por endpoint, compatible con schemas de `sium` o contratos declarativos.
- Integracion oficial con cache: dedupe, stale-while-revalidate, invalidacion y tags.
- Retry/backoff/circuit breaker como presets, no como codigo repetido por consumidor.
- Upload/download con progreso, cancelacion y resume.
- Offline queue opcional usando storage/connection.
- Interceptores de auth/session y trace ids de logger.
- Mock server/record-replay para tests y demos.
### auth
Estado: servidor amplio, pero con errores de export/import y poca prueba directa en `libs/auth`.
Mejoras v2:
- MFA estable: TOTP, recovery codes, WebAuthn/passkeys.
- OAuth/OIDC adapters oficiales.
- Passwordless/magic-link.
- Administracion de sesiones y dispositivos.
- Auditoria de eventos de seguridad.
- UI en `uix`: login, register, reset password, MFA, device management.
- Contratos compartidos cliente/servidor con tests directos de `libs/auth`.
- Politicas configurables: lockout, password policy, token rotation, session binding.
### session
Estado: ciclo de vida trabajado, con integracion logger/bus. Falta llevarlo a producto completo.
Mejoras v2:
- Multi-session/profile switcher.
- Sincronizacion cross-tab con leader election.
- Bootstrap SSR/cliente bien tipado.
- Estrategias de refresh enchufables.
- Estado de sesion visible para UI: loading, refreshing, expired, degraded.
- Integracion con auth, perm, storage y logger.
- Panel de sesiones activas y cierre remoto.
### perm
Estado: motor y servidor existen; el cliente tiene solo un componente reusable claro (`Can.svelte`) y tests finos.
Mejoras v2:
- Familia UI: `Can`, `Cannot`, `Gate`, `PermissionBoundary`, `PermissionExplain`.
- Simulador de permisos para usuarios/roles/tenants.
- DSL o builder de politicas.
- Plantillas de roles y permisos por dominio.
- Batch prefetch y cache de permisos.
- Explicabilidad: por que se permite o deniega una accion.
- Integracion PEP/PDP clara para cliente, servidor y rutas.
### cache
Estado: runtime avanzado, pero hay una inversion de capas porque `arts/cache` importa desde `svrs/cache`.
Mejoras v2:
- Corregir frontera: motor compartido en `libs/cache` o separar claramente cliente/servidor.
- Adapters persistentes con `storage`.
- Integracion oficial con `http`.
- Invalidacion por tags, dependencias y eventos de `bus`.
- Prefetch, optimistic updates y rollback.
- Cache inspector en `uix`.
- Metricas: hit rate, stale, evictions, memory, errores de adapter.
### storage
Estado: adapters sincronicos. El propio codigo deja async adapters para v2.
Mejoras v2:
- Contrato async para IndexedDB, OPFS y backends remotos.
- Migraciones versionadas de datos.
- Namespaces y cuotas por modulo.
- Estrategias de eviction.
- Cifrado opcional para datos sensibles, dejando claro que storage no es una caja fuerte.
- Sincronizacion con session/prefs/cache.
- Inspector de storage en devtools.
### prefs
Estado: preparado para persistencia y preferencias, con senales de pending/error para async.
Mejoras v2:
- Hydration async con estados oficiales.
- Sincronizacion entre tabs y perfiles.
- Presets de preferencias por usuario/equipo.
- Merge server/client con resolucion de conflictos.
- Registro de preferencias por modulo.
- UI `PrefsPanel` generada desde metadata.
- Integracion completa con frontend/lang/format/storage.
### frontend
Estado: servicio de estado visual; hay desalineacion con density de prefs.
Mejoras v2:
- Sistema de design tokens: color, spacing, radius, shadow, typography, z-index.
- Theming SSR-safe para evitar saltos de primer render.
- Density unificada con prefs.
- Direccion LTR/RTL y reduced motion como contrato transversal.
- Breakpoints y viewport state publicos.
- Registro de temas y paquetes visuales.
- Integracion con `uix` como consumidor principal.
### adom
Estado: utilidades DOM, viewport, scroll/focus; buen candidato para sostener la capa UI.
Mejoras v2:
- Layer manager para dialog/popover/toast.
- Focus trap, focus restore e inert.
- Portal manager.
- Observers oficiales: resize, intersection, mutation.
- Scroll restoration por ruta/panel.
- Helpers ARIA y keyboard navigation.
- Test harness de accesibilidad para componentes `uix`.
### lang
Estado: basico y con poca cobertura directa.
Mejoras v2:
- Namespaces lazy por modulo.
- Reporte de claves faltantes y claves no usadas.
- Pseudo-locale para detectar textos rotos.
- MessageFormat 2.0 / MF2 como contrato moderno de mensajes, plurales, selectores y variantes.
- Extraccion de mensajes desde codigo.
- Bundles por idioma/modulo.
- Integracion con `sium` para mensajes de validacion y con `uix` para labels.
### format
Estado: bastante completo en numeros, fechas, monedas y unidades.
Mejoras v2:
- Relative time.
- ListFormat, DisplayNames y segmentos localizados.
- Perfiles de unidades por dominio.
- Conversiones de unidades donde tenga sentido.
- Integracion con timezone/calendar.
- Formatters declarativos para tablas/formularios.
- Cache de formatters coordinada con lang/prefs.
### logger
Estado: motor fuerte y grande. Falta explotarlo como observabilidad de plataforma.
Mejoras v2:
- Trace context compatible con `http`, `orca`, `bus` y `session`.
- Redaction policies para datos sensibles.
- Sampling y niveles por modulo.
- Transport health y backpressure.
- Live log viewer en `uix`.
- Export a OpenTelemetry o formato compatible.
- Correlacion de errores de build/runtime/devtools.
### bus
Estado: bus central solido.
Mejoras v2:
- Catalogo tipado de eventos por modulo.
- Validacion de payloads con schemas.
- Replay/recording para depuracion.
- Bridges cross-tab y servidor.
- Timeline visual en devtools.
- Politicas de error: retry, dead letter, fallback.
- Integracion con orca para orquestaciones declarativas.
### timer
Estado: servicio estable y probado.
Mejoras v2:
- Schedulers nombrados por prioridad.
- Cron/calendar schedules.
- Timers persistentes.
- Politica para background tabs.
- Integracion con performance budgets.
- Timer inspector en devtools.
- Coordinacion con orca para tareas cancelables.
### orca
Estado: engine avanzado. Ya hay senales de roadmap v2 como `replace-current`.
Mejoras v2:
- Politica `replace-current` / takeLatest con abort real.
- Visual timeline de eventos, acciones, fan-in y cancelaciones.
- Validacion de grafos/orquestaciones antes de runtime.
- Presets de orquestacion por dominio.
- Persistencia/replay de traces.
- Integracion con logger/bus/timer/http.
- Mercado interno de actions reutilizables.
### connection
Estado: modulo grande y avanzado, con tests y refactors recientes.
Mejoras v2:
- Canales multiplexados.
- Presence y heartbeats de estado.
- Offline queue con storage.
- Backpressure y flow control.
- Reauth/rekeying de conexiones vivas.
- Negociacion de protocolo/version.
- Request/reply validado con schemas.
- Dashboard de reconnect, latencia, cola y errores.
### svrs/auth
Estado: servidor amplio, pero con problemas actuales de imports/exports.
Mejoras v2:
- Handlers SvelteKit canonicos.
- Adapters DB oficiales.
- Migraciones y seeds.
- Hooks de auditoria y logger.
- OpenAPI o contratos HTTP generables.
- Test matrix por flujo: login, refresh, logout, MFA, recovery, device revoke.
### svrs/cache
Estado: existe, pero su frontera con `arts/cache` esta contaminada.
Mejoras v2:
- Separar motor comun de servidor.
- Adapters para Redis, memory, KV y edge.
- Invalidation bus server-side.
- Metrics endpoint.
- Politicas multi-tenant.
- Contract tests compartidos con cliente.
### svrs/perm
Estado: servidor funcional con cobertura limitada.
Mejoras v2:
- PDP server-side explicable.
- Adapters DB.
- Policy migrations.
- Batch authorization endpoint.
- Audit log de decisiones.
- Herramientas para simular usuario/tenant/recurso.
### libs
Estado: muchas piezas compartidas, barrels y contratos; algunos nucleos grandes tienen poca prueba directa.
Mejoras v2:
- Separar `public` e `internal`.
- API snapshots para barrels publicos.
- Contract tests por adapter.
- Fixtures reutilizables.
- Versionado semantico interno por modulo.
- Limpieza de exports ambiguos.
- Mayor cobertura directa en `libs/auth`, `libs/cache`, `libs/perm`, `libs/lang`.
### web
Estado: mezcla docs, demo, test pages y rutas temporales. No debe confundirse con producto.
Mejoras v2:
- Docs generadas desde metadata real de modulos.
- Playground unico con `ActiveApp` canonica.
- Route smoke tests.
- Matrix visual de modulos, servicios, dependencias y estado.
- Demos que consuman `uix` y APIs publicas, no implementaciones internas.
- Separar claramente `/docs`, `/playground`, `/test` y `/demo`.
## Integraciones clave de v2
### Formularios completos
`sium` define schema e introspeccion. `uix` renderiza. `lang` traduce. `format` presenta valores. `prefs/frontend` aplican densidad/tema. `http` envia. `logger` traza. `storage` guarda borradores.
Resultado esperado: formularios complejos generados, validables, localizados, accesibles y testeables.
### Seguridad completa
`auth` autentica. `session` mantiene estado. `perm` decide autorizacion. `uix` muestra guards y explicaciones. `logger` audita. `storage` conserva solo lo permitido.
Resultado esperado: flujos reales de login, MFA, sesiones, roles, permisos y auditoria.
### Datos offline/cacheados
`http` habla con servidor. `cache` deduplica e invalida. `storage` persiste. `connection` informa red/offline. `bus` propaga eventos. `orca` coordina flujos.
Resultado esperado: operaciones robustas con retry, offline queue, cache visible y rollback.
### Observabilidad
`logger`, `bus`, `timer`, `orca`, `connection`, `http` y `cache` comparten trace ids.
Resultado esperado: devtools capaces de explicar que paso, cuando, por que fallo y que modulo participo.
## Resultado esperado de Ecosistema 2.0
La v2 deberia dejar de ser solo una coleccion de motores y convertirse en una plataforma integrada:
- Componentes UI reales en `uix`.
- Motores estables en `arts`.
- Contratos compartidos en `libs`.
- Servidores/adapters en `svrs`.
- Demos y docs que consumen APIs publicas.
- Devtools para inspeccionar el sistema entero.
- Integraciones oficiales entre modulos.
El mayor salto no esta en tocar un modulo aislado. Esta en completar las capas que faltan entre ellos: UI, adapters, presets, diagnostico, contratos publicos e integracion transversal.

@ -1,698 +0,0 @@
> ARCHIVED — historical brainstorming. orca v1 is shipped. Items
> listed below as "out of v0" (commit / replace / timeouts /
> transaction / compensate / parallel / abortOn unless / tokens con
> payload / fan-in) all live in the engine today. The current
> contract is `src/arts/orca/README.md`; this file is preserved as a
> record of the design conversation, not as guidance.
Clippy-pet verdict: `orca` es una ballena preciosa, pero si intentamos montarla entera en v0 nos hunde la barca.
Lo correcto:
```txt
v0 = kernel
v1 = enterprise orchestration
```
`orca` está bien pensado porque tiene tres aciertos enormes:
- Desacopla módulos: `orca` no sabe qué es `cach`, `perm`, `sess` o `connection`.
- Convierte flujos críticos en artefactos trazables: `OrcaRunResult`.
- Usa tokens como hechos semánticos per-run, que es la parte diferencial.
Pero ahora mismo el README mezcla kernel y futuro. Para que sea implementable, v0 debería recortarse así:
```txt
entra en v0:
setupOrca
createEngineOrca
App.Orchestration inerte
onEvent
stages
queue/drop
tokens per-run flag
after/provides
OrcaResult success/skipped/error/fatal/interrupted
OrcaRunResult
validate()
diagnostics
sale de v0:
commit()
replace
timeouts
transaction
compensate
parallel
abortOn/unless avanzados si complican
tokens con payload
fan-in
```
La pregunta decisiva no es “¿está bien diseñado?”. Sí, lo está.
La pregunta correcta es: **¿cuál es el núcleo mínimo que demuestra que `orca` merece existir?**
Respuesta: `event -> staged actions -> tokens -> result trace`.
Si eso funciona, lo demás se puede crecer con calma.
como evitamos la reentrada en orca , si no establecemos en el payload del evento los eventos que le preceden en el stack ? al disparar el componente verifica si esta gestionando ya un evento de ese stack y lo omitiria , como lo suelen hacer el evitar loops infinitos ?
La forma sana no es meter “eventos precedentes” en el payload de negocio. Eso contaminaría todos los eventos con metadata de orquestación y acabaría mezclando dominio con runtime.
Lo correcto es que `orca` envuelva cada evento en un **OrcaEnvelope** interno:
```ts
interface OrcaEnvelope<TPayload = unknown> {
readonly event: string;
readonly payload: TPayload;
readonly meta: {
readonly eventId: string;
readonly parentEventId?: string;
readonly runId?: string;
readonly traceId: string;
readonly depth: number;
readonly stack: readonly string[];
readonly emittedBy?: OrcaActionId;
};
}
```
El payload sigue limpio:
```ts
{ previousActorId, nextActorId }
```
Y `orca` conserva el contexto:
```ts
{
event: APP_EVENT_CACHE_CLEARED,
meta: {
parentEventId: 'evt-1',
traceId: 'trace-7',
depth: 2,
stack: [
APP_EVENT_USER_IDENTITY_CHANGED,
APP_EVENT_CACHE_CLEARED
],
emittedBy: ORCA_ACTION_CLEAR_PRIVATE_CACHE
}
}
```
Entonces la prevención de loops vive en `orca`, no en los módulos.
Reglas típicas para evitar loops infinitos:
```txt
1. No ejecutar inline eventos emitidos durante un run.
2. Encolarlos con traceId y parentEventId.
3. Mantener stack interno por trace.
4. Rechazar o saltar eventos si superan maxDepth.
5. Rechazar o saltar si el mismo evento aparece demasiado en el mismo trace.
6. Aplicar dedupeKey opcional.
7. Tener maxEventsPerTrace.
8. Tener diagnostics cuando se corta el loop.
```
Ejemplo:
```ts
const Orca = createEngineOrca({
bus,
timers,
logger,
reentry: {
maxDepth: 16,
maxEventsPerTrace: 128,
repeatedEventPolicy: ORCA_REENTRY_SKIP,
repeatedEventLimit: 2
}
});
```
Si ocurre esto:
```txt
A -> action publica B
B -> action publica A
A -> action publica B
...
```
`orca` ve el trace:
```txt
A, B, A, B, A
```
Y puede cortar:
```ts
ORCA_RESULT_INTERRUPTED
ORCA_REENTRY_REASON_REPEATED_EVENT
```
o marcar el run como:
```ts
ORCA_RUN_ABORTED
```
Los patrones habituales en otros sistemas:
- Redux middleware suele evitar loops por convención y con guards manuales.
- Redux-Saga usa `takeLatest`, cancelación y control de tasks.
- EventEmitter no te protege: si emites recursivo, te comes el loop.
- State machines evitan loops por transición/estado: si ya estás en cierto estado, el evento no transiciona.
- Sistemas workflow usan `trace/correlationId`, depth limits, idempotency keys y dedupe.
Para `orca`, yo fijaría esto:
```txt
payload = dominio, limpio
envelope/meta = runtime, trace, stack, depth
tokens = coordinación intra-run
bus events = comunicación inter-run
```
Y añadiría al contrato v0:
```ts
interface OrcaReentryOptions {
readonly maxDepth?: number; // default 16
readonly maxEventsPerTrace?: number; // default 128
readonly repeatedEventLimit?: number; // default 1 o 2
readonly repeatedEventPolicy?:
| typeof ORCA_REENTRY_SKIP
| typeof ORCA_REENTRY_ABORT_TRACE
| typeof ORCA_REENTRY_ERROR;
}
```
Así evitas reentrada sin ensuciar los payloads y sin obligar a cada módulo/componente a recordar “quién vino antes”.
cual estrategia es mas arquitectonicamente mejor ?
Arquitectónicamente, la mejor estrategia es:
```txt
payload limpio + envelope runtime interno + cola de orca + límites de reentrada
```
No metería el stack en el payload del evento.
Por qué:
- El payload pertenece al dominio: `userId`, `tenantId`, `orderId`, etc.
- El stack pertenece al runtime de orquestación.
- Si mezclas ambos, todos los módulos empiezan a conocer detalles de `orca`.
- Si mañana cambias cómo trazas reentrada, rompes contratos de eventos.
- Los eventos pueden venir de `connection`, `auth`, `sess`, `http`, etc.; no todos deberían cargar metadata de runtime.
La arquitectura correcta sería:
```txt
buss event:
type + payload limpio
orca internal envelope:
eventId
traceId
parentEventId
runId
depth
stack
emittedByAction
dedupeKey
```
Flujo:
```txt
1. buss publica EVENT_A(payload)
2. orca crea envelope A con traceId
3. action de A publica EVENT_B
4. orca no ejecuta B inline
5. orca crea envelope B con parent=A, mismo traceId, depth+1
6. orca comprueba stack/repeated/depth
7. si es seguro, encola B
8. si detecta loop, corta con diagnostic
```
Esto es más limpio que “cada componente mira si ya está gestionando un evento de ese stack”, porque esa opción reparte lógica de orquestación por toda la app. Es justo lo que `orca` intenta evitar.
Estrategia recomendada para v0:
```ts
createEngineOrca({
bus,
logger,
reentry: {
maxDepth: 16,
maxEventsPerTrace: 128,
repeatedEventLimit: 2,
repeatedEventPolicy: ORCA_REENTRY_SKIP
}
});
```
Reglas:
- Eventos publicados durante un run se encolan, no se ejecutan inline.
- El stack es interno de `orca`.
- Si el mismo evento aparece demasiadas veces en el mismo trace, se salta o aborta.
- Si depth supera límite, aborta el trace.
- Si eventsPerTrace supera límite, aborta el trace.
- Se emite diagnostic estructurado.
- El `OrcaRunResult` registra `traceId`, `parentEventId`, `depth`, `interruptedReason`.
Mi voto: **envelope interno + queue + trace guards**. Es la opción más limpia, menos acoplada y más mantenible.
me refiero a los otros sistemas de referencia
En los sistemas de referencia, la estrategia arquitectónicamente mejor **no es meter el stack en el payload**. Casi todos los sistemas buenos separan:
```txt
payload = datos de negocio
runtime context = control, cancelación, stack, tracing, concurrencia
```
**Comparativa**
| Sistema | Cómo evita loops/reentrada | Lectura para `orca` |
|---|---|---|
| RTK Listener Middleware | Usa runtime API: `signal`, `cancelActiveListeners()`, `cancel()`, `take`, `condition`, `delay`, `pause`, `fork`. No mete stack en la action. | Muy buen referente. `orca` debería pasar `signal`, `traceId`, `runId` y control de cancelación en `OrcaActionContext`. Fuente: [RTK listener middleware](https://redux-toolkit.js.org/api/createListenerMiddleware). |
| Redux-Saga | Usa tareas y cancelación: `takeLatest` cancela la tarea anterior; `takeEvery` permite concurrencia; `race`, `cancel`, `fork`, `join`. | Bueno para políticas `queue/drop/replace/parallel`, pero no copiaría generators. Fuente: [Redux-Saga concurrency](https://redux-saga.js.org/docs/advanced/Concurrency/). |
| XState | Evita loops mediante estado, guards y transiciones. Si ya estás en un estado, un evento puede no transicionar. El control vive en la máquina, no en el payload. | Útil para flows cerrados; para `orca`, copiaría `setup()` tipado y la idea de runtime/state como dueño del control. Fuente: [XState setup](https://stately.ai/docs/setup). |
| Effect Workflow | Distingue error/cause/interruption y tiene compensación estructurada. El runtime sabe qué falló y cómo compensar; no lo mete en payload. | Muy buen referente para `OrcaInterrupted`, compensaciones futuras y `Cause`. Fuente: [Effect Workflow compensation](https://effect-ts.github.io/effect/workflow/Workflow.ts.html). |
| EventEmitter simple | No evita loops. Si un listener emite el mismo evento recursivamente, problema del usuario. | Esto es justo lo que `orca` no debe ser. |
**Veredicto**
La mejor estrategia para `orca` es una mezcla de RTK Listener + Saga + XState:
```txt
payload limpio
OrcaEnvelope interno
cola propia de orca
traceId / parentEventId / runId
AbortSignal en OrcaActionContext
política de concurrencia por evento
límites maxDepth / maxEventsPerTrace
diagnostics cuando corta loops
```
No pondría el stack en el payload. Eso es arquitectura más débil porque obliga a todos los módulos a conocer el runtime de `orca`.
La estrategia superior es:
```ts
interface OrcaActionContext {
readonly runId: OrcaRunId;
readonly eventId: OrcaEventId;
readonly traceId: OrcaTraceId;
readonly parentEventId?: OrcaEventId;
readonly depth: number;
readonly signal: AbortSignal;
}
```
Y el envelope interno:
```ts
interface OrcaEnvelope<TPayload> {
readonly event: string;
readonly payload: TPayload;
readonly meta: OrcaEventMeta;
}
```
Así `orca` se parece a los sistemas buenos: el **runtime** controla reentrada, cancelación y concurrencia; el **payload** sigue siendo dominio limpio.
ahora mismo no se parece a eso en nada ?
Correcto: **el diseño actual apunta en esa dirección, pero todavía no se parece lo suficiente en contrato concreto**.
Ahora mismo el README ya dice cosas buenas:
```txt
payload limpio
no ejecutar inline
queue/drop/replace
tokens per-run
RunResult
App.Orchestration inerte
```
Pero faltan las piezas que lo harían parecerse de verdad a RTK/Saga/XState/Effect en lo importante:
```txt
OrcaEnvelope interno
OrcaActionContext con traceId/runId/eventId/depth
AbortSignal en acciones
cola interna explícita de orca
políticas de reentrada: maxDepth, maxEventsPerTrace
reason/result para reentry-skip / interrupted
relación formal buss -> orca listener -> queue
```
Así que mi diagnóstico es:
```txt
Conceptualmente: sí está en el camino correcto.
Contractualmente: todavía no está cerrado.
Implementablemente: aún tendría ambigüedad peligrosa.
```
La sección que habría que añadir al README antes de implementar es algo como:
```ts
interface OrcaEnvelope<TPayload = unknown> {
readonly event: string;
readonly payload: TPayload;
readonly meta: OrcaEventMeta;
}
interface OrcaEventMeta {
readonly eventId: OrcaEventId;
readonly traceId: OrcaTraceId;
readonly parentEventId?: OrcaEventId;
readonly parentRunId?: OrcaRunId;
readonly emittedByAction?: OrcaActionId;
readonly depth: number;
readonly stack: readonly string[];
}
interface OrcaActionContext {
readonly runId: OrcaRunId;
readonly event: string;
readonly eventId: OrcaEventId;
readonly traceId: OrcaTraceId;
readonly depth: number;
readonly signal: AbortSignal;
readonly emit: <TPayload>(event: string, payload: TPayload) => void;
}
```
Y reglas:
```txt
1. Las acciones no llaman bus.publish directamente si quieren preservar trace.
2. Usan ctx.emit() para eventos derivados.
3. ctx.emit() crea envelope hijo.
4. Eventos derivados se encolan, no inline.
5. Orca corta loops con maxDepth/maxEventsPerTrace/repeatedEventLimit.
6. Si una acción llama App.Bus directamente, orca lo trata como evento raíz nuevo.
```
Esto último es importante: **si queremos trazabilidad real, las acciones deben emitir eventos derivados vía `ctx.emit()`**, no vía `App.Bus.publish()` directamente. Ahí está la diferencia entre “tenemos bus” y “tenemos runtime de orquestación”.
Así que sí: ahora mismo aún no está del todo. Tiene el esqueleto filosófico, pero le falta el contrato de runtime/envelope/context para ser una pieza robusta.
ctx deberiamos definirlo, seria otra capa mas ?
Sí, `ctx` hay que definirlo. Pero no lo vería como “otra capa” al nivel de `buss`/`orca`/`timr`; lo vería como **el runtime context de una ejecución de `orca`**.
No es un artefacto nuevo. Es parte del contrato de `orca`.
```txt
orca = engine
run = ejecución de un evento
ctx = contexto que orca entrega a cada action dentro de ese run
```
El `ctx` es donde ponemos todo lo que no debe ir en el payload:
```ts
interface OrcaActionContext {
readonly runId: OrcaRunId;
readonly eventId: OrcaEventId;
readonly traceId: OrcaTraceId;
readonly parentEventId?: OrcaEventId;
readonly depth: number;
readonly signal: AbortSignal;
emit<TPayload>(
event: string,
payload: TPayload,
options?: OrcaEmitOptions
): void;
token(token: OrcaToken): void;
hasToken(token: OrcaToken): boolean;
}
```
La separación queda:
```txt
payload
Datos del evento.
Pertenece al dominio.
ctx
Datos/control de ejecución.
Pertenece a orca.
App modules
Servicios reales.
La action los cierra por closure.
```
Ejemplo:
```ts
App.Orchestration.onEvent(APP_EVENT_USER_IDENTITY_CHANGED, {
id: ORCA_ACTION_CLEAR_PRIVATE_CACHE,
stage: ORCA_STAGE_MAIN,
provides: [ORCA_TOKEN_CACHE_OK, ORCA_TOKEN_CACHE_ERROR],
action: async (payload, ctx) => {
if (ctx.signal.aborted) {
return orcaInterrupted('run aborted');
}
const result = await App.Cache.clearActorScope(payload.previousActorId);
if (!result.ok) {
return orcaError(result.error, {
emits: [ORCA_TOKEN_CACHE_ERROR]
});
}
ctx.emit(APP_EVENT_PRIVATE_CACHE_CLEARED, {
actorId: payload.previousActorId
});
return orcaSuccess({
emits: [ORCA_TOKEN_CACHE_OK]
});
}
});
```
`ctx.emit()` es importante porque preserva traza:
```txt
run A
action clear-cache
ctx.emit(B)
orca crea envelope B:
traceId = A.traceId
parentEventId = A.eventId
emittedByAction = clear-cache
depth = A.depth + 1
```
Si la action llama directamente:
```ts
App.Bus.publish(B, payload)
```
entonces `orca` no sabe que B viene de A salvo que intercepte todo, lo cual es más frágil. Por eso yo pondría esta regla:
```txt
Dentro de acciones orca, usa ctx.emit() para eventos derivados.
Usa App.Bus.publish() solo para eventos raíz externos al run.
```
Entonces no es una capa adicional. Es el equivalente a:
- `listenerApi` en RTK listener middleware.
- `saga effects` en redux-saga.
- `context/event` en XState.
- `workflow context/cause` en Effect/Temporal.
Mi recomendación: sí, definir `OrcaActionContext` como contrato central de v0. Sin `ctx`, `orca` será solo un callback runner. Con `ctx`, empieza a ser runtime de orquestación.
y como sabe una accion de un modulo a la hora de emitir un evento donde lo debe hacer, por ejemplo
auth.onChangeUser (newUser) {
bus.publish(AUTH_CHANGE_EVENT,newUser)
o
ctx.publish(AUTH_CHANGE_EVENT,newUser)
}
La regla tiene que ser muy clara:
```txt
Un módulo nunca usa ctx.
Una acción de orca sí puede usar ctx.
```
Tu ejemplo:
```ts
auth.onChangeUser((newUser) => {
bus.publish(AUTH_CHANGE_EVENT, newUser);
});
```
Eso está bien si ocurre **dentro del módulo auth**. `auth` no debe saber que existe `orca`, por tanto no puede recibir ni usar `ctx`.
En cambio esto:
```ts
auth.onChangeUser((newUser) => {
ctx.publish(AUTH_CHANGE_EVENT, newUser);
});
```
Eso estaría mal si `auth.onChangeUser` es código del módulo. Meterías `orca` dentro de `auth`.
La separación correcta:
```txt
Módulo -> Bus
Orca action -> Ctx
```
Ejemplo completo:
```ts
// Dentro de auth
function setUser(nextUser: User) {
currentUser = nextUser;
bus.publish(AUTH_EVENT_USER_CHANGED, {
userId: nextUser.id
});
}
```
`auth` publica en `bus`. Punto. No conoce `orca`.
Ahora una acción de `orca`:
```ts
App.Orchestration.onEvent(AUTH_EVENT_USER_CHANGED, {
id: ORCA_ACTION_CLEAR_PRIVATE_STATE,
stage: ORCA_STAGE_MAIN,
action: async (payload, ctx) => {
await App.Cache.clearActorScope(payload.userId);
ctx.emit(APP_EVENT_PRIVATE_STATE_CLEARED, {
userId: payload.userId
});
return orcaSuccess({
emits: [ORCA_TOKEN_CACHE_OK]
});
}
});
```
Aquí sí usamos `ctx.emit()` porque estamos dentro de una action y queremos preservar trace.
Entonces hay dos tipos de emisión:
```txt
bus.publish()
Para módulos, componentes, servidor, código externo a orca.
Crea evento raíz desde la perspectiva de orca.
ctx.emit()
Solo dentro de una OrcaAction.
Crea evento hijo dentro del trace actual.
```
Si un módulo emite durante una acción, por ejemplo:
```ts
action: async () => {
await App.Auth.changeUser(nextUser); // Auth internamente hace bus.publish
}
```
`orca` lo verá como evento publicado durante un run, pero no sabrá con tanta precisión qué acción lo emitió salvo que tenga un contexto activo global interno. Hay dos opciones:
**Opción A, simple y limpia v0:**
```txt
Módulo publica por bus.
Orca detecta que hay run activo y lo encola como child event genérico.
parentRunId sí, emittedByAction quizá no.
```
**Opción B, más precisa:**
`orca` ejecuta cada action dentro de un “current action scope” interno. Si durante esa ejecución algún módulo hace `bus.publish`, `orca` intercepta el publish y lo marca como:
```ts
emittedByAction: ORCA_ACTION_CLEAR_PRIVATE_STATE
```
Eso requiere integración fuerte entre `buss` y `orca`.
Mi recomendación:
```txt
v0:
módulos siempre bus.publish
actions pueden ctx.emit
si un módulo publica durante un run, orca lo trata como evento derivado del run si puede detectarlo, pero no exigimos emittedByAction perfecto
v1:
integración bus/orca con current action scope para trazabilidad completa
```
Regla para desarrolladores:
```txt
Si estás escribiendo código de módulo: usa bus.publish.
Si estás escribiendo una OrcaAction: usa ctx.emit para eventos derivados del flujo.
```
Así evitamos acoplar módulos a `orca`, pero damos a las acciones una vía correcta para preservar trazabilidad.

311
package-lock.json generated

@ -28,6 +28,7 @@
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@types/node": "^22",
"@vitest/browser-playwright": "^4.1.3",
"concurrently": "^9.2.1",
"eslint": "^10.2.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.17.0",
@ -1581,6 +1582,32 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/aria-query": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
@ -1652,6 +1679,36 @@
"node": ">=18"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chalk/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
@ -1668,6 +1725,21 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@ -1677,6 +1749,51 @@
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/concurrently": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "4.1.2",
"rxjs": "7.8.2",
"shell-quote": "1.8.3",
"supports-color": "8.1.1",
"tree-kill": "1.2.2",
"yargs": "17.7.2"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@ -1823,6 +1940,13 @@
"integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==",
"license": "MIT"
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/entities": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
@ -1843,6 +1967,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@ -2219,6 +2353,16 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@ -2245,6 +2389,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
@ -2288,6 +2442,16 @@
"node": ">=0.10.0"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@ -3185,6 +3349,16 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@ -3257,6 +3431,16 @@
}
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/sade": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
@ -3326,6 +3510,19 @@
"node": ">=8"
}
},
"node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@ -3372,6 +3569,50 @@
"dev": true,
"license": "MIT"
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/svelte": {
"version": "5.55.4",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.4.tgz",
@ -3615,6 +3856,16 @@
"node": ">=20"
}
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@ -3633,8 +3884,7 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"optional": true
"license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
@ -4037,6 +4287,24 @@
"node": ">=0.10.0"
}
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
@ -4075,6 +4343,45 @@
"dev": true,
"license": "MIT"
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

@ -26,7 +26,9 @@
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"dating:server": "node servers/dating/server.mjs",
"dating:server": "node demos/dating/server/server.mjs",
"dating:web": "vite dev",
"dating:dev": "concurrently --names \"server,web\" --prefix-colors \"magenta,cyan\" --kill-others-on-fail \"npm run dating:server\" \"npm run dating:web\"",
"dev:conn-chat": "node scripts/conn-chat-server.mjs",
"lint": "prettier --check . && eslint .",
"format": "prettier --write .",
@ -46,6 +48,7 @@
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@types/node": "^22",
"@vitest/browser-playwright": "^4.1.3",
"concurrently": "^9.2.1",
"eslint": "^10.2.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.17.0",

@ -1,988 +0,0 @@
REVISIÓN COMPLETA DE LA PARTE I
Fundamentos de la semántica perceptiva de la interfaz
Versión de revisión para Claude
OBJETIVO DEL DOCUMENTO
======================
Este documento corrige la revisión anterior de la Parte I. No es una lista breve de inserciones sobre Russell ni un parche aislado del Capítulo 10. Es una revisión integral de la Parte I como bloque editorial.
El problema detectado es estructural:
- las familias semánticas podían parecer categorías de diseño inventadas;
- los intents podían parecer una taxonomía arbitraria;
- los canales podían parecer capas técnicas;
- la crítica al semáforo podía quedar reducida a un resumen demasiado pobre;
- los fundamentos psicoperceptivos estaban presentes en la teoría, pero insuficientemente visibles en el manuscrito.
La revisión debe hacer explícito que el libro se apoya en:
- organización perceptiva y Gestalt;
- figura/fondo;
- affordances y acción situada;
- atención y saliencia;
- appraisal y evaluación del evento;
- Russell y el espacio valencia/activación;
- multisensorialidad;
- correspondencias crossmodales;
- esquemas sensoriomotores;
- accesibilidad como prueba de distribución semántica.
La tesis editorial de la revisión es:
> Este libro no inventa categorías.
> Nombra estructuras que ya participan en la forma en que los usuarios perciben, atienden, actúan, evalúan y comprenden los cambios de una interfaz.
REGLA DE INTEGRACIÓN
====================
No repetir autores en cada capítulo.
No convertir cada idea en cita académica.
No escribir como paper.
La distribución correcta es:
1. Capítulo 3 declara el mapa completo de fundamentos.
2. Capítulos 4–8 aplican esos fundamentos al evento, gramática, interacción semántica, criterios y familias.
3. Capítulo 9 aplica appraisal, figura/fondo y affordances a la diferencia valencial/transicional.
4. Capítulo 10 ancla los intents en Russell, valencia/activación y la crítica histórica al semáforo.
5. Parte II y Parte III retoman esos fundamentos sin volver a explicarlos desde cero.
ESTRUCTURA REVISADA DE LA PARTE I
=================================
Introducción — La pregunta que aparece al programar interfaces
Capítulo 1 — Qué entendemos por interfaz
Capítulo 2 — Por qué preguntamos ahora
Capítulo 3 — Fundamentos psicoperceptivos de la interfaz
Capítulo 4 — El evento interactivo como unidad mínima de significado
Capítulo 5 — Por qué una gramática del evento
Capítulo 6 — Qué es una interacción semántica
Capítulo 7 — Criterios para identificar una interacción semántica
Capítulo 8 — De las clases de evento a las familias semánticas
Capítulo 9 — Semánticas valenciales y transicionales
Capítulo 10 — Intent: más allá del semáforo
Cierre de Parte I
INTRODUCCIÓN
La pregunta que aparece al programar interfaces
===============================================
DIAGNÓSTICO
-----------
La introducción funciona bien si conserva el tono humilde del programador que se pregunta por qué todo está fragmentado: componentes, estilos, estados, motion, sonido, accesibilidad y lógica viven en capas distintas, mientras el usuario percibe una experiencia unificada.
PROBLEMA DETECTADO
------------------
Faltaba explicitar dos cosas:
1. El libro usa la web como campo principal de ejemplos.
2. La teoría no se limita a la web.
INSERCIÓN RECOMENDADA
---------------------
Ubicación: después de presentar la fragmentación entre componentes, estilos, motion, sonido, háptica y accesibilidad.
Texto:
Este libro usará principalmente la web como campo de observación y ejemplo.
No porque la teoría pertenezca solo a la web, sino porque en la web la fragmentación se vuelve especialmente visible. El desarrollador trabaja con DOM, CSS, JavaScript, eventos técnicos, componentes, estados, media queries, Web Audio API, preferencias de usuario y accesibilidad como capas separadas. Cada una tiene su propia lógica. Cada una se documenta de forma distinta. Cada una suele resolverse con herramientas distintas.
Pero el usuario no experimenta esas capas por separado.
El usuario no percibe “un cambio de clase CSS”, “un listener de click”, “una transición”, “un aria-live” o “un token de color”. Percibe que algo respondió, que algo se abrió, que algo quedó guardado, que algo reclama atención, que algo se perdió, que algo sigue ocurriendo.
La web será, por tanto, nuestro laboratorio principal. Pero no el límite de la teoría.
Un usuario puede percibir contacto, espera, pérdida, confirmación, señal, manipulación o cambio de marco en una app nativa, en un wearable, en una interfaz espacial, en un sistema embebido o en un panel físico. Lo que cambia es la realización técnica: latencia, fluidez, calidad háptica, acceso al sonido, rendimiento, políticas de plataforma y preferencias del usuario.
La teoría se formula para interfaces en general.
Los ejemplos partirán sobre todo de la web porque ahí la necesidad es especialmente clara.
Principio añadido:
> La web será el campo principal de ejemplos, no el límite de la teoría.
CAPÍTULO 1
Qué entendemos por interfaz
Hacia una definición operativa de la interfaz
=============================================
DIAGNÓSTICO
-----------
Este capítulo ya es fuerte. Redefine la interfaz como algo más que superficie visible y la formula como dimensión perceptible de un contexto operacional. Esa idea debe mantenerse.
PROBLEMA DETECTADO
------------------
El capítulo contiene intuiciones psicoperceptivas, pero todavía no anuncia suficientemente que después habrá un capítulo de fundamentos. Debe preparar el Capítulo 3 sin cargarlo todavía de teoría.
FUNCIÓN CORREGIDA DEL CAPÍTULO
------------------------------
Debe responder:
- qué ha significado tradicionalmente “interfaz”;
- por qué esa definición sigue siendo útil;
- por qué se queda corta si solo pensamos en componentes;
- por qué el usuario percibe eventos, no solo objetos;
- por qué la interfaz debe entenderse como dimensión perceptible de un contexto operacional.
INSERCIÓN 1: PUENTE HACIA FUNDAMENTOS
-------------------------------------
Ubicación: al final de la sección “La interfaz no es solo visual”.
Texto:
Esta idea se desarrollará con más precisión en el capítulo de fundamentos psicoperceptivos. Allí veremos que la interfaz no solo se organiza visualmente; también se apoya en percepción de campo, affordances, atención, evaluación, movimiento, sonido, multisensorialidad y experiencia corporal.
Por ahora basta con fijar una tesis:
> La interfaz no es visual más algunos extras.
> Es perceptiva.
INSERCIÓN 2: SUAVIZAR NEUROAFIRMACIONES
---------------------------------------
Si aparece una frase del tipo “cada canal tiene sus circuitos propios”, sustituir por:
Cada canal tiene condiciones perceptivas propias, formas propias de comunicar significado y formas propias de fallar. No se trata de reducir la interfaz a neurofisiología, sino de reconocer que color, forma, movimiento, sonido, háptica, tiempo y profundidad no son equivalentes.
INSERCIÓN 3: CIERRE REFORZADO
-----------------------------
Añadir en la síntesis:
La redefinición de interfaz como dimensión perceptible de un contexto operacional prepara el desplazamiento central del libro: del componente al evento. El componente sigue siendo necesario, pero ya no basta como unidad principal de significado. Cuando la interfaz cambia, responde, espera, interrumpe, confirma o pierde algo, el usuario no interpreta solo objetos: interpreta eventos.
CAPÍTULO 2
Por qué preguntamos ahora
Sedimentación, dependencia del camino y carga paradigmática
==========================================================
DIAGNÓSTICO
-----------
Este capítulo funciona como legitimación histórica. Explica por qué ciertas convenciones persistieron sin examen. Debe mantenerse.
PROBLEMA DETECTADO
------------------
Antes mezclaba justificación histórica con fundamentos científicos. Ahora, al existir Capítulo 3, debe dejar de desarrollar los fundamentos y limitarse a anunciarlos.
FUNCIÓN CORREGIDA
-----------------
Debe responder:
- por qué las convenciones actuales no son simples errores;
- cómo se sedimentan;
- cómo la dependencia del camino las estabiliza;
- cómo el paradigma dominante impide formular ciertas preguntas;
- por qué ahora sí tiene sentido revisar esas premisas.
INSERCIÓN RECOMENDADA: CIERRE HACIA EL CAPÍTULO 3
-------------------------------------------------
Ubicación: final de “Por qué ahora”.
Texto:
Lo que ha cambiado no es que hayamos descubierto de pronto la percepción humana. La psicología de la percepción, la teoría de affordances, la atención, los modelos de evaluación afectiva, la multisensorialidad y los esquemas corporales llevan décadas ofreciendo herramientas para pensar mejor la interacción.
Lo que ha cambiado es la necesidad de reunir esos marcos en una teoría de interfaz.
Los sistemas de diseño han madurado. La web y las plataformas nativas tienen más canales disponibles. Las interfaces son más dinámicas, más adaptativas, más autónomas y más temporales. Las convenciones heredadas empiezan a mostrar sus límites.
Por eso el siguiente capítulo hará una pausa antes de entrar en evento, gramática e interacción semántica. Presentará los fundamentos psicoperceptivos que sostendrán el resto del libro.
No para convertir el diseño en psicología aplicada mecánicamente.
Sino para evitar que la teoría semántica de la interfaz parezca una colección de intuiciones personales.
CORRECCIÓN DE TONO
------------------
Evitar afirmaciones como:
- “la neurociencia ha demostrado que…”;
- “esto es consenso absoluto…”;
- “el cerebro procesa…”.
Preferir:
- “este marco ayuda a explicar…”;
- “la literatura sobre percepción sugiere…”;
- “esta distinción es coherente con…”;
- “para los fines de este libro, resulta útil…”.
CAPÍTULO 3
Fundamentos psicoperceptivos de la interfaz
De qué está hecha la percepción interactiva
===========================================
DIAGNÓSTICO
-----------
Este capítulo debe convertirse en el gran mapa científico del libro. No basta con mencionar teorías. Debe decir para qué sirve cada una dentro de la arquitectura.
PROBLEMA DETECTADO
------------------
La versión anterior mencionaba fundamentos, pero no los distribuía con suficiente precisión en el sistema. Debe ser reescrito como capítulo ancla.
FUNCIÓN CORREGIDA
-----------------
Debe explicar:
- qué fundamentos existen;
- qué parte de la teoría sostiene cada uno;
- qué no pretende demostrar;
- cómo se usará después.
ESTRUCTURA REVISADA DEL CAPÍTULO
--------------------------------
1. Por qué necesitamos fundamentos perceptivos
2. Organización perceptiva: Gestalt, agrupación y figura/fondo
3. Affordances y acción situada
4. Atención, saliencia y carga cognitiva
5. Appraisal: evaluación del evento
6. El espacio evaluativo: valencia y activación
7. Movimiento, causalidad y continuidad
8. Sonido y correspondencias crossmodales
9. Integración multisensorial
10. Esquemas sensoriomotores y cognición corporeizada
11. Accesibilidad como prueba de distribución semántica
12. Cómo usará este libro estos fundamentos
SECCIÓN NUEVA OBLIGATORIA: EL ESPACIO EVALUATIVO
------------------------------------------------
Ubicación: después de Appraisal y antes de Movimiento.
Texto:
## El espacio evaluativo: valencia y activación
Hasta ahora hemos descrito cómo el usuario percibe, atiende y actúa. Falta una dimensión esencial: cómo evalúa lo que ocurre.
Cuando un evento interactivo sucede, el usuario no solo lo reconoce. También lo interpreta:
- ¿esto es bueno o malo para mí?
- ¿requiere acción inmediata?
- ¿puedo ignorarlo?
- ¿ya ocurrió o aún puedo evitarlo?
- ¿puedo corregirlo?
- ¿se resolvió algo que estaba pendiente?
Una forma útil de organizar estas evaluaciones es el espacio propuesto por James A. Russell, que describe los estados afectivos mediante dos dimensiones:
- valencia: positivo / negativo;
- activación: baja / alta.
Este modelo no pretende capturar toda la complejidad emocional. No dice todo lo que hay que decir sobre la experiencia afectiva. Pero resulta suficiente para distinguir diferencias que una interfaz necesita comunicar de forma consistente.
Por ejemplo:
- no es lo mismo algo negativo urgente que algo negativo ya ocurrido;
- no es lo mismo una confirmación leve que una culminación;
- no es lo mismo informar que advertir;
- no es lo mismo pedir atención que registrar una pérdida.
Estas diferencias no son estilísticas. Son operativas.
Este libro utilizará ese espacio como base para definir los intents, no como una teoría psicológica completa, sino como una estructura mínima para organizar la evaluación del evento.
FIGURA OBLIGATORIA
------------------
## Figura — Espacio valencia/activación aplicado a intents
Brief para Claude:
Crear un plano cartesiano.
Eje horizontal:
valencia negativa ←→ valencia positiva
Eje vertical:
activación baja ↑ activación alta
Colocar:
- threat: negativa / alta activación
- risk: negativa / activación media
- loss: negativa / baja activación
- neutral: centro / baja activación
- affirm: positiva / baja activación
- fulfill: positiva / activación media-alta
Pie:
Los intents no son colores. Son regiones evaluativas del evento.
TABLA CENTRAL OBLIGATORIA
-------------------------
## Tabla — Fundamentos y función dentro del libro
| Fundamento | Qué explica | Qué sostiene en la teoría |
|---|---|---|
| Gestalt | agrupación, figura/fondo, campo perceptivo | presencia, profundidad, emerge, shift |
| Gibson / affordances | acción posible | contexto operacional, contact, handle |
| Atención / saliencia | orientación del foco y carga cognitiva | signal, proporción, fatiga |
| Appraisal | evaluación respecto a metas, control y consecuencia | valencial/transicional, intent |
| Russell | valencia y activación | seis intents |
| Motion / causalidad | continuidad, agencia, trayectoria | contact, handle, emerge, shift |
| Spence / crossmodalidad | correspondencias entre canales | sonido, coordinación multicanal |
| Multisensorialidad | integración de señales | canales, sincronía, accesibilidad |
| Embodiment / Johnson | esquemas corporales | handle, contact, sustain, loss |
| Accesibilidad | variabilidad perceptiva | migración semántica |
CIERRE RECOMENDADO
------------------
Estos fundamentos no dictan una taxonomía cerrada. No prueban que solo existan siete familias ni seis intents. Lo que hacen es otra cosa: ofrecen criterios para que las categorías del libro no parezcan arbitrarias.
A partir de aquí, cuando hablemos de evento, gramática, interacción semántica, familias, intent y canales, no estaremos hablando de nombres inventados desde el diseño. Estaremos nombrando formas recurrentes de percibir, actuar, atender, evaluar e integrar cambios.
CAPÍTULO 4
El evento interactivo como unidad mínima de significado
======================================================
DIAGNÓSTICO
-----------
El capítulo es conceptualmente correcto, pero debe apoyarse explícitamente en el Capítulo 3.
FUNCIÓN CORREGIDA
-----------------
Debe mostrar que el evento no es una ocurrencia técnica, sino una modificación perceptible con relevancia operativa.
INSERCIÓN RECOMENDADA
---------------------
Ubicación: después de la definición de evento interactivo.
Texto:
Esta definición se apoya en los fundamentos anteriores.
Desde la organización perceptiva, un evento modifica el campo: algo aparece, cambia de figura, se subordina, se agrupa o se separa.
Desde las affordances, un evento modifica lo que el usuario puede hacer o cree que puede hacer.
Desde la atención, un evento puede orientar, reclamar, sostener o liberar foco.
Desde el appraisal, un evento puede volverse evaluable: favorable, riesgoso, urgente, perdido o resuelto.
Desde la multisensorialidad, un evento puede distribuirse entre varios canales y aun así percibirse como una sola unidad.
Por eso un evento interactivo no equivale a un click, a un callback ni a un cambio interno de estado. Un evento interactivo existe cuando una modificación perceptible altera la relación operativa entre usuario y sistema.
INSERCIÓN DE CONTRASTE
----------------------
Añadir una tabla breve:
| Plano | Qué ocurre | ¿Es evento interactivo? |
|---|---|---|
| Callback interno | cambia lógica | no necesariamente |
| Cambio visual decorativo | cambia apariencia | no necesariamente |
| Press visible | registra acción | sí, si afecta agencia |
| Estado fijado | cambia consecuencia | sí |
| Alerta | orienta atención | sí |
| Progreso | sostiene espera | sí |
CAPÍTULO 5
Por qué una gramática del evento
================================
DIAGNÓSTICO
-----------
El capítulo justifica la palabra gramática, pero debe apoyarse más en diferenciación perceptiva y carga cognitiva.
FUNCIÓN CORREGIDA
-----------------
Debe responder:
- qué es una gramática;
- por qué una interfaz necesita una gramática de eventos;
- por qué no bastan efectos, componentes o patrones.
INSERCIÓN RECOMENDADA
---------------------
Ubicación: después de definir gramática.
Texto:
La necesidad de una gramática no es solo conceptual. Es perceptiva.
La atención humana no procesa todos los cambios con el mismo peso. La percepción agrupa, separa y jerarquiza. El usuario aprende regularidades por exposición repetida. Si el sistema usa la misma forma perceptiva para eventos distintos, aumenta la carga interpretativa. Si usa formas distintas sin regularidad, impide aprendizaje implícito.
Una gramática del evento intenta reducir esa carga.
Permite que ciertas diferencias se vuelvan reconocibles:
- contacto no es consolidación;
- aparición no es señal;
- señal no es amenaza;
- amenaza no es pérdida;
- proceso no es resultado;
- manipulación no es contacto repetido;
- cambio de marco no es simple aparición.
Sin gramática, el usuario debe reconstruir esas diferencias caso por caso.
Con gramática, el sistema las vuelve perceptivamente disponibles.
PRINCIPIO AÑADIDO
-----------------
> Una gramática no añade complejidad al usuario.
> La retira del momento de interpretación y la desplaza al diseño del sistema.
CAPÍTULO 6
Qué es una interacción semántica
================================
DIAGNÓSTICO
-----------
El capítulo ya tiene una definición fuerte. Debe conectarse mejor con fundamentos y canales.
FUNCIÓN CORREGIDA
-----------------
Debe definir la interacción semántica como puente entre evento y realización perceptiva.
INSERCIÓN RECOMENDADA
---------------------
Ubicación: después de la definición de interacción semántica.
Texto:
Esta definición tiene una consecuencia importante: una interacción semántica no pertenece a un único canal.
Puede apoyarse en organización perceptiva, si necesita cambiar figura/fondo.
Puede apoyarse en affordance, si modifica acción posible.
Puede apoyarse en atención, si reclama foco.
Puede apoyarse en appraisal, si porta evaluación.
Puede apoyarse en motion, sonido, color, forma, presencia, profundidad o háptica.
Lo que la define no es el recurso que usa, sino la pregunta perceptiva que responde.
Una misma semántica puede migrar de un canal a otro sin perder identidad si conserva su función. Un contacto puede ser motion, sonido o háptica. Una señal puede ser color, forma, texto o sonido. Una pérdida puede ser retirada, huella, texto o silencio grave.
Por eso hablaremos de firma perceptiva, no de receta.
CAPÍTULO 7
Criterios para identificar una interacción semántica
===================================================
DIAGNÓSTICO
-----------
El capítulo funciona como disciplina taxonómica. Debe explicitar que cada criterio tiene un fundamento psicoperceptivo.
INSERCIÓN RECOMENDADA
---------------------
Ubicación: después de presentar los criterios.
Texto:
Estos criterios no son solo reglas internas de clasificación. Cada uno se apoya en una dimensión perceptiva:
- la relevancia operativa se apoya en affordances: una diferencia importa si cambia lo que el usuario puede hacer o interpretar;
- la diferenciabilidad se apoya en organización perceptiva y atención: una categoría debe poder percibirse como distinta;
- la recurrencia se apoya en aprendizaje implícito: una semántica necesita repetirse para volverse reconocible;
- la estabilidad se apoya en memoria perceptiva: la identidad debe sobrevivir a variaciones;
- la elasticidad se apoya en adaptación de canales: la misma función puede expresarse de maneras distintas;
- la economía cognitiva se apoya en reducción de carga interpretativa;
- la multicanalidad se apoya en integración sensorial;
- la accesibilidad se apoya en variabilidad perceptiva.
Una interacción semántica no se acepta porque tenga nombre. Se acepta porque nombra una diferencia que el usuario necesita percibir de forma recurrente.
TABLA AÑADIDA
-------------
| Criterio | Fundamento |
|---|---|
| Relevancia operativa | affordances |
| Diferenciabilidad | Gestalt / atención |
| Recurrencia | aprendizaje implícito |
| Estabilidad | memoria perceptiva |
| Elasticidad | variación de canales |
| Multicanalidad | integración multisensorial |
| Evaluabilidad | appraisal |
| Accesibilidad | migración semántica |
CAPÍTULO 8
De las clases de evento a las familias semánticas
=================================================
DIAGNÓSTICO
-----------
El capítulo debe seguir siendo la transición hacia las siete familias, pero debe hacer más explícito que las familias derivan de preguntas perceptivas y fundamentos.
FUNCIÓN CORREGIDA
-----------------
Debe explicar:
- por qué se reduce de semánticas exploratorias a siete familias;
- qué pregunta responde cada una;
- qué fundamento perceptivo la sostiene.
INSERCIÓN RECOMENDADA
---------------------
Ubicación: después de presentar las siete familias.
Texto:
Estas familias no son categorías de diseño. Son categorías perceptivas.
Cada una se apoya en una forma distinta de organizar la experiencia:
- contact se apoya en agencia, causalidad inmediata y esquema de contacto;
- commit se apoya en cierre cognitivo, consecuencia y memoria de estado;
- signal se apoya en atención, saliencia y orientación del foco;
- handle se apoya en acción corporal, control continuo y manipulación;
- emerge se apoya en figura/fondo, presencia y aparición perceptiva;
- shift se apoya en cambio de marco cognitivo y orientación contextual;
- sustain se apoya en continuidad temporal, espera y mantenimiento de confianza.
Dicho de otro modo:
> Las familias no salen de componentes.
> Salen de preguntas perceptivas recurrentes.
TABLA AÑADIDA
-------------
| Familia | Pregunta | Fundamento principal |
|---|---|---|
| contact | ¿me ha sentido? | agencia / causalidad |
| commit | ¿quedó fijado? | cierre / consecuencia |
| signal | ¿debo atender? | atención / saliencia |
| handle | ¿lo controlo? | acción corporal / manipulación |
| emerge | ¿apareció? | figura/fondo / presencia |
| shift | ¿cambió el marco? | modelo mental / orientación |
| sustain | ¿sigue ocurriendo? | continuidad temporal |
CAPÍTULO 9
Semánticas valenciales y transicionales
=======================================
DIAGNÓSTICO
-----------
Este capítulo es muy importante y debe ser reescrito como aplicación de fundamentos, no como introducción de fundamentos desde cero.
PROBLEMA DETECTADO
------------------
La versión anterior introducía appraisal, Gestalt, affordances, atención, afecto y esquemas como si el lector no los conociera. Ahora ya deben haber aparecido en Capítulo 3.
FUNCIÓN CORREGIDA
-----------------
Debe responder:
- qué eventos son mensaje y qué eventos son marco;
- por qué no todos aceptan intent;
- cómo se compone marco + mensaje.
ESTRUCTURA REVISADA
-------------------
1. El problema: colorear el contenedor
2. La distinción central: mensaje y marco
3. Aplicación de appraisal
4. Aplicación figura/fondo
5. Aplicación affordances y atención
6. Aplicación esquemas sensoriomotores
7. Mapa de familias según evaluabilidad
8. Reglas de composición
9. Antipatrones
10. Ejemplo completo
11. Síntesis
INSERCIÓN DE APERTURA REVISADA
------------------------------
Texto:
El Capítulo 3 presentó varios fundamentos que ahora convergen en una distinción central.
Desde el appraisal, algunos eventos se evalúan respecto a metas, control, urgencia y consecuencia. Desde la Gestalt, algunos eventos funcionan como figura y otros como fondo. Desde las affordances, algunos eventos permiten o comunican acción; otros organizan el campo donde la acción ocurre. Desde la atención, algunas señales reclaman foco; otras solo reorientan el contexto. Desde los esquemas sensoriomotores, algunos eventos son agentivos; otros son estructurales.
Todas esas distinciones apuntan a una misma idea:
> Hay eventos que son mensaje.
> Hay eventos que son marco.
Los primeros pueden portar intent.
Los segundos normalmente no.
INSERCIÓN CLAVE
---------------
Ubicación: después de “colorear el contenedor”.
Texto:
El error no es usar intent. El error es aplicarlo al evento equivocado.
Un modal puede contener una amenaza, pero el modal no es la amenaza. Un spinner puede preceder a un fallo, pero el spinner no es el fallo. Una vista puede mostrar un éxito, pero la navegación hasta esa vista no es el éxito.
El intent debe vivir en el evento que porta evaluación.
Regla:
> El intent modula la figura evaluable, no el fondo estructural.
MAPA DE FAMILIAS SEGÚN EVALUABILIDAD
------------------------------------
| Familia | Naturaleza dominante | Relación con intent |
|---|---|---|
| contact | agentiva mínima | leve / anticipatoria |
| commit | evaluable | plena |
| signal | evaluable / atencional | plena |
| handle | mixta | sobre todo en drop |
| emerge | estructural | normalmente no |
| shift | estructural | normalmente no |
| sustain | estructural | normalmente no |
CAPÍTULO 10
Intent: más allá del semáforo
=============================
DIAGNÓSTICO
-----------
Este capítulo era el más afectado. Había quedado demasiado resumido. Debe recuperar la fuerza del documento original “Más allá del semáforo”.
PROBLEMAS DETECTADOS
--------------------
1. La genealogía del semáforo estaba amputada.
2. Russell aparecía poco.
3. El paso de color a espacio afectivo no estaba suficientemente dramatizado.
4. Las líneas de cada intent eran demasiado escuetas.
5. Info no quedaba suficientemente separado de intent.
6. Threat/loss y affirm/fulfill necesitaban más peso.
ESTRUCTURA REVISADA
-------------------
1. El intent como problema no examinado
2. La herencia del semáforo
3. De la carretera a la pantalla
4. Qué codifica realmente la convención heredada
5. Qué debería ser un intent
6. El espacio afectivo: Russell, valencia y activación
7. Las seis regiones evaluativas
8. Lo que la convención heredada pierde
9. Cómo modula el intent
10. Neutralidad no es ausencia de intent
11. Familia e intent
12. Antipatrones
13. Ejemplo narrativo
14. Principios
15. Síntesis
SUSTITUCIÓN AMPLIADA PARA SECCIONES 1–4
---------------------------------------
## 1. El intent como problema no examinado
Existe en el diseño de interfaces una convención tan extendida que ha dejado de parecer convención.
Cuando algo ha ido mal, usamos rojo.
Cuando algo requiere precaución, usamos amarillo.
Cuando algo ha ido bien, usamos verde.
Cuando algo es informativo, usamos azul.
A ese conjunto lo llamamos danger, warning, success, info. O lo llamamos error, positive, negative, notice. Pero la estructura permanece.
Lo notable no es que usemos esa convención. Lo notable es que casi nunca preguntamos de dónde viene, qué cubre, qué omite y qué mezcla.
¿Por qué success debe cubrir tanto una operación importante completada como un autoguardado menor?
¿Por qué danger debe cubrir tanto una amenaza activa como una pérdida ya consumada?
¿Por qué warning mezcla un riesgo corregible, una precaución leve y a veces una amenaza seria?
¿Por qué info aparece junto a los intents si muchas veces no comunica valencia, sino solo saliencia atencional?
¿Por qué el intent se ha definido durante años como color contextual y no como evaluación del evento?
Estas preguntas son sencillas. Pero no aparecen con frecuencia porque la convención está normalizada. Entró en frameworks, tokens, clases CSS, sistemas de diseño, documentación, temas y componentes. Y una vez que algo entra en infraestructura, deja de parecer una decisión.
Parece la forma natural de hacer las cosas.
La pregunta del capítulo es:
> ¿Qué ocurre cuando dejamos de tratar el intent como color y empezamos a tratarlo como evaluación perceptiva del evento?
## 2. La herencia del semáforo
La taxonomía heredada de intents no nació en las interfaces digitales.
Antes de que existieran botones verdes de success, alertas rojas de danger o banners amarillos de warning, esos colores ya cargaban con una larga historia de señalización industrial, ferroviaria y vial.
El rojo, el amarillo y el verde pertenecían a entornos donde una señal debía reconocerse rápido, a distancia y con consecuencias físicas claras. No eran colores decorativos. Eran instrucciones operativas. Detenerse. Proceder con precaución. Avanzar. Prestar atención. Evitar una colisión. No entrar. Continuar.
Esa estructura era adecuada para muchos sistemas físicos de señalización porque resolvía un problema urgente: permitir decisiones rápidas con bajo coste interpretativo.
Pero esa estructura no era una teoría afectiva completa.
No pretendía distinguir entre una amenaza activa y una pérdida consumada. No pretendía separar una confirmación suave de una culminación significativa. No pretendía diferenciar saliencia informativa de evaluación emocional. Su función era mucho más concreta: codificar órdenes y estados operativos mediante colores muy reconocibles.
Cuando las interfaces gráficas empezaron a necesitar comunicar estados del sistema —error, advertencia, éxito, información—, heredaron esa estructura porque ya era comprensible. La cultura ya había aprendido que rojo pesa más que verde, que amarillo pide precaución, que verde permite continuar.
El diseño digital no inventó esa asociación. La recibió.
El problema no fue heredar.
El problema fue olvidar que se estaba heredando.
Con el tiempo, la asociación rojo / amarillo / verde dejó de parecer una convención histórica y empezó a parecer una estructura natural de la interfaz. El rojo ya no era simplemente una señal cromática útil: pasó a llamarse danger, error, destructive. El verde pasó a llamarse success. El amarillo pasó a llamarse warning. Y el azul, que no pertenecía del mismo modo a la lógica del semáforo, terminó ocupando el cajón residual de lo informativo: info.
Ahí ocurrió la mutación decisiva:
> una convención cromática empezó a funcionar como si fuera una taxonomía semántica.
## 3. De la carretera a la pantalla
La web estabilizó esa mutación de forma especialmente clara.
Frameworks como Bootstrap necesitaban resolver un problema práctico: ofrecer clases contextuales simples para botones, alertas, tablas, mensajes y estados. La solución era evidente, reconocible y fácil de usar:
success
warning
danger
info
El éxito se volvió verde.
La advertencia, amarilla.
El peligro, rojo.
La información, azul.
Aquella decisión era razonable. Funcionaba. Era barata cognitivamente y barata técnicamente. Cualquier equipo podía aplicarla sin abrir una discusión teórica sobre afecto, percepción o semántica. Bastaba con asignar una clase.
Pero al entrar en clases CSS, documentación, temas, tokens, componentes y guías, esa convención dejó de parecer un atajo. Se convirtió en vocabulario. Después otros frameworks y sistemas de diseño heredaron la estructura con variaciones menores. Algunos cambiaron nombres. Otros añadieron tonos intermedios. Otros introdujeron primary, secondary, neutral, destructive.
Pero el núcleo siguió siendo el mismo:
positivo
precaución
negativo grave
informativo
neutral
Ese núcleo parecía suficiente mientras el intent solo modificaba color.
Si todo lo que hace danger es pintar un borde en rojo, quizá no necesitamos mucha más teoría. Si todo lo que hace success es poner verde un mensaje, quizá la convención basta.
Pero en una interfaz perceptiva multicanal, el intent ya no cambia solo color. Puede modular:
motion
sonido
duración
ritmo
intensidad
persistencia
profundidad
forma
háptica
silencio
En ese momento, la convención heredada empieza a mostrar sus límites.
Porque una cosa es elegir un color.
Otra muy distinta es decidir cómo debe moverse, sonar, durar, aparecer, persistir o sentirse un evento.
## 4. Qué codifica realmente la convención heredada
Si examinamos con atención success, warning, danger e info, vemos que no forman una taxonomía afectiva completa.
Forman una taxonomía cromática útil.
Eso no las vuelve inútiles. Las vuelve insuficientes.
Danger codifica una región negativa intensa, pero mezcla al menos tres cosas distintas:
amenaza activa
fallo grave
pérdida consumada
Una amenaza activa todavía permite actuar. Una pérdida consumada ya ocurrió. Un fallo grave puede bloquear una operación, pero no siempre equivale a destrucción. Tratar todo eso como danger produce una interfaz afectivamente torpe.
Success codifica una región positiva, pero mezcla también cosas distintas:
confirmación suave
resolución positiva
culminación de una tensión
estado correcto rutinario
Un autoguardado correcto no merece la misma energía perceptiva que completar una subida larga o enviar un formulario importante. Si todo es success, nada se siente realmente cumplido.
Warning codifica precaución, pero no distingue bien entre riesgo corregible, advertencia moderada y amenaza próxima. Algunas advertencias piden revisión tranquila; otras exigen atención inmediata. No son el mismo evento afectivo.
Info es el caso más problemático. Muchas veces no expresa evaluación afectiva. Expresa simplemente que el sistema quiere que algo se note. No responde a:
¿cómo se evalúa esto?
Responde a:
¿debo atender esto?
Eso lo coloca más cerca de signal que del sistema de intents. En esta gramática, muchos usos de info se expresan mejor como:
signal.announce + neutral
signal.inform + neutral
signal.notify + neutral
La familia signal aporta la saliencia.
El intent neutral indica ausencia de juicio fuerte.
Esta es la diferencia central:
> La convención heredada codifica colores útiles.
> La teoría del intent debe codificar regiones evaluativas del evento.
Por eso necesitamos separar:
threat ≠ loss
affirm ≠ fulfill
info ≠ intent
La interfaz puede distinguir esas diferencias. El usuario puede sentir esas diferencias. La gramática también debe poder nombrarlas.
SECCIÓN 6 AMPLIADA: RUSSELL Y ESPACIO AFECTIVO
----------------------------------------------
## El espacio afectivo
El sistema de intents no se deriva de colores heredados, sino de la evaluación del evento dentro de un espacio afectivo.
Este capítulo se apoya en un modelo sencillo: el espacio valencia/activación descrito por James A. Russell. No se utiliza como teoría completa de la emoción, sino como base operativa para distinguir tipos de evaluación que la interfaz necesita expresar.
Los seis intents pueden entenderse como regiones discretas de ese espacio aplicadas al evento interactivo.
La valencia distingue lo positivo de lo negativo.
La activación distingue baja y alta movilización atencional o corporal.
No es lo mismo threat que loss. Ambos son negativos, pero uno tiene alta activación y convoca acción; el otro registra una consecuencia consumada con activación más baja.
Tampoco es lo mismo affirm que fulfill. Ambos son positivos, pero uno confirma suavemente; el otro resuelve una tensión u objetivo.
El espacio afectivo no nos dice qué color usar. Nos dice qué diferencia evaluativa debe preservar la interfaz.
LÍNEAS POR INTENT
-----------------
Incluir debajo de cada intent:
Neutral:
Neutral ocupa la región de baja activación sin valencia fuerte: el evento no requiere evaluación afectiva significativa.
Affirm:
Affirm ocupa la región de valencia positiva con baja activación: confirma sin alterar significativamente el foco atencional ni la energía del sistema.
Fulfill:
Fulfill corresponde a una región positiva con mayor activación: marca la resolución de una tensión o la culminación de una acción relevante.
Risk:
Risk se sitúa en una región negativa de activación media: indica un problema corregible que requiere atención, pero no una reacción inmediata.
Threat:
Threat pertenece a la región de alta activación negativa: exige atención urgente y posible acción inmediata.
Loss:
Loss comparte valencia negativa con threat, pero con menor activación y distinta temporalidad: registra una consecuencia ya consumada.
FRASE OBLIGATORIA AL FINAL DE LA SECCIÓN DE INTENTS
---------------------------------------------------
Los intents no son colores ni estilos. Son regiones evaluativas del evento que pueden expresarse mediante distintos canales perceptivos.
FIGURA OBLIGATORIA
------------------
## Figura — Del evento al intent
Brief:
evento evaluable
→ appraisal / evaluación
→ valencia + activación
→ intent
→ canales perceptivos
Ejemplo:
commit.delete
→ negativo + baja activación + consecuencia consumada
→ loss
→ retirada + huella + color desaturado + posible sonido grave
CIERRE DE PARTE I REVISADO
==========================
Con este capítulo se cierra la Parte I.
La interfaz ya no ha sido definida solo como superficie, sino como dimensión perceptible de un contexto operacional. El evento interactivo ha quedado establecido como unidad mínima de significado. La gramática del evento ha sido presentada como sistema de diferencias, relaciones y reglas de composición. La interacción semántica ha sido definida como configuración perceptiva recurrente. Las familias han sido reducidas a siete preguntas fundamentales. Los eventos evaluables se han separado de los estructurales. Y el intent ha dejado de ser color para convertirse en región evaluativa del evento.
La Parte II podrá entrar ahora en los canales:
tiempo
motion
presencia
profundidad
forma
color
sonido
háptica
accesibilidad
coordinación
Pero esos canales ya no serán efectos. Serán formas de hacer sensible una gramática.
RESUMEN EJECUTIVO DE CORRECCIONES
=================================
1. La Introducción debe aclarar que la web es campo de ejemplos, no límite de la teoría.
2. Capítulo 1 debe preparar el paso hacia fundamentos sin sobrecargarse.
3. Capítulo 2 debe anunciar fundamentos, no desarrollarlos.
4. Capítulo 3 debe ser el mapa completo de fundamentos psicoperceptivos.
5. Capítulo 4 debe conectar evento con percepción, affordance, atención, appraisal y multisensorialidad.
6. Capítulo 5 debe fundamentar gramática en diferenciación perceptiva y reducción de carga cognitiva.
7. Capítulo 6 debe mostrar que interacción semántica no pertenece a un canal único.
8. Capítulo 7 debe relacionar cada criterio con su base psicoperceptiva.
9. Capítulo 8 debe mostrar que las familias son categorías perceptivas.
10. Capítulo 9 debe aplicar fundamentos a mensaje/marco, no introducirlos desde cero.
11. Capítulo 10 debe recuperar la fuerza de “Más allá del semáforo” y anclar claramente Russell.
12. Todo el bloque debe sostener esta idea:
Este libro no inventa categorías; nombra estructuras perceptivas recurrentes.
FIN DEL DOCUMENTO

@ -1,11 +1,15 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { page } from '$app/state';
import { createDatingApp } from './_lib/app';
import { setNexoContext } from './_lib/context';
import { connectDatingRealtime, type DatingRealtimeHandle } from './_lib/realtime';
import type { DatingMatch, DatingSessionResponse } from './_lib/types';
import MessageToast, { type MessageToastEntry } from './_components/MessageToast.svelte';
import { goto } from '$app/navigation';
import { createDatingApp } from '$dating/_lib/app';
import { setNexoContext } from '$dating/_lib/context';
import { connectDatingRealtime, type DatingRealtimeHandle } from '$dating/_lib/realtime';
import type { DatingMatch, DatingSessionResponse } from '$dating/_lib/types';
import MessageToast, { type MessageToastEntry } from '$dating/_components/MessageToast.svelte';
import Icon, { type IconName } from '$dating/_components/Icon.svelte';
import '$dating/_design/tokens.css';
import '$dating/_design/primitives.css';
let { children } = $props();
@ -19,9 +23,6 @@
const { api } = handle;
// One realtime socket for the whole `/dating` subtree. Pages
// subscribe via `nexo.realtime().subscribe(...)`; closing this on
// unmount tears down the connection cleanly.
let realtime: DatingRealtimeHandle | null = null;
function ensureRealtime(): DatingRealtimeHandle {
if (realtime === null) realtime = connectDatingRealtime();
@ -53,21 +54,10 @@
realtime: ensureRealtime
});
// Eagerly probe `/api/session` once on mount so nested pages don't
// each issue their own request. Pages call `nexo.refreshSession()`
// after a login / logout to update the cell.
$effect(() => {
void refreshSession();
});
// ── Global notifications ─────────────────────────────────────────────
//
// The realtime distributor pushes every `dating.message.created`
// event to every match member, including the sender. The chat
// page handles its own thread; for everything else (matches list,
// discover, profile, etc.) we render a floating toast and bump a
// document-title badge so the user notices a new message without
// hunting through the matches tab.
const TOAST_TTL_MS = 5500;
let toasts = $state<MessageToastEntry[]>([]);
let unreadByMatch = $state<Record<string, number>>({});
@ -85,9 +75,6 @@
}
});
// Re-prime the match cache once the user has authenticated so
// toast titles can carry the counterpart's name. Failures are
// silent — the toast still works without a name.
$effect(() => {
if (session?.authenticated !== true) return;
void api
@ -106,12 +93,9 @@
const message = event.message;
if (current.authenticated !== true) return;
if (message.senderId === current.user.id) return;
const onThisChat =
page.url.pathname === `/dating/chat/${message.matchId}`;
const onThisChat = page.url.pathname === `/dating/chat/${message.matchId}`;
if (onThisChat) return;
// Resolve counterpart name from the cached match (best
// effort; fall back to a generic title).
const match = matchCache.get(message.matchId);
const counterpart =
match?.profiles.find((profile) => profile.userId !== current.user.id) ?? null;
@ -120,20 +104,13 @@
const id = `t-${++toastSeq}`;
toasts = [
...toasts,
{
id,
matchId: message.matchId,
title,
body: message.body
}
{ id, matchId: message.matchId, title, body: message.body }
].slice(-3);
unreadByMatch = {
...unreadByMatch,
[message.matchId]: (unreadByMatch[message.matchId] ?? 0) + 1
};
// Refresh the match cache opportunistically so the next
// toast for an unknown match has a name.
if (match === undefined) {
void api
.matches()
@ -148,7 +125,6 @@
return off;
});
// Visiting a chat clears its unread count.
$effect(() => {
const path = page.url.pathname;
const match = path.match(/^\/dating\/chat\/([^/]+)/);
@ -170,64 +146,148 @@
handle.dispose();
});
// Operator-style chrome (the cross-section nav with Discover /
// Matches / Profile / Safety / Devtools links) only makes sense
// once the user is operating an admin / devtools surface. Regular
// user pages own their own navigation context — auth pages render
// a centered card; product pages drive cross-section nav from
// inside their own UI. Activate the chrome only under
// `/dating/admin` and `/dating/devtools`.
const showChrome = $derived(
const isAuthShell = $derived(
page.url.pathname.startsWith('/dating/login') ||
page.url.pathname.startsWith('/dating/register') ||
page.url.pathname.startsWith('/dating/reset') ||
page.url.pathname.startsWith('/dating/onboarding') ||
page.url.pathname.startsWith('/dating/mfa')
);
const isOpsShell = $derived(
page.url.pathname.startsWith('/dating/admin') ||
page.url.pathname.startsWith('/dating/devtools')
);
const showProductChrome = $derived(
!isAuthShell && !isOpsShell && session?.authenticated === true
);
function isCurrent(href: string): boolean {
return page.url.pathname === href || page.url.pathname.startsWith(`${href}/`);
}
const isAuthenticated = $derived(session?.authenticated === true);
const railItems: ReadonlyArray<{ href: string; label: string; icon: IconName }> = [
{ href: '/dating/discover', label: 'Descubre', icon: 'sparkle' },
{ href: '/dating/matches', label: 'Matches', icon: 'message' },
{ href: '/dating/profile', label: 'Perfil', icon: 'user' },
{ href: '/dating/safety', label: 'Seguridad', icon: 'shield' }
];
async function logout() {
try {
await api.logout();
} finally {
session = { authenticated: false };
await goto('/dating/login', { replaceState: true });
}
}
const userName = $derived(
session?.authenticated === true ? session.user.displayName || session.user.email : ''
);
const userInitial = $derived(userName.slice(0, 1).toUpperCase() || '·');
const matchesUnread = $derived(totalUnread);
</script>
<svelte:head>
<title>Nexo</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
/>
</svelte:head>
<div bind:this={appRoot} class="nexo-shell" class:has-chrome={showChrome}>
{#if showChrome}
<header class="nexo-header">
<a class="brand" href="/dating">
<strong>Nexo</strong>
<span class="tagline">consola de moderación</span>
</a>
<div
bind:this={appRoot}
class="nx-shell"
class:shell-product={showProductChrome}
class:shell-auth={isAuthShell}
class:shell-ops={isOpsShell}
>
{#if showProductChrome}
<aside class="rail" aria-label="Nexo">
<div class="rail-brand">
<span class="brand-mark" aria-hidden="true">N</span>
<span class="brand-text">Nexo</span>
</div>
<nav aria-label="Nexo">
<a href="/dating/admin" class="nav-item" aria-current={isCurrent('/dating/admin') ? 'page' : undefined}>
Moderación
</a>
<a href="/dating/devtools" class="nav-item" aria-current={isCurrent('/dating/devtools') ? 'page' : undefined}>
Devtools
</a>
<a href="/dating/discover" class="nav-item">Volver a la app</a>
<nav class="rail-nav" aria-label="Navegación principal">
{#each railItems as item (item.href)}
<a
class="rail-item"
href={item.href}
aria-current={isCurrent(item.href) ? 'page' : undefined}
>
<Icon name={item.icon} size={18} />
<span class="lbl">{item.label}</span>
{#if item.href === '/dating/matches' && matchesUnread > 0}
<span class="badge">{matchesUnread}</span>
{/if}
</a>
{/each}
</nav>
<div class="auth-slot">
{#if isAuthenticated}
<span class="who">{userName}</span>
<button
type="button"
class="ghost"
onclick={async () => {
try {
await api.logout();
} finally {
session = { authenticated: false };
}
}}
>
<div class="rail-footer">
<div class="rail-user">
<span class="avatar" aria-hidden="true">{userInitial}</span>
<div class="who">
<span class="name">{userName}</span>
{#if session?.authenticated === true}
<span class="email">{session.user.email}</span>
{/if}
</div>
</div>
<button type="button" class="nx-btn nx-btn-ghost nx-btn-sm logout-btn" onclick={logout}>
<Icon name="log-out" size={14} />
<span>Cerrar sesión</span>
</button>
</div>
</aside>
<nav class="mobile-tabbar" aria-label="Nexo móvil">
{#each railItems as item (item.href)}
<a
class="m-tab"
href={item.href}
aria-current={isCurrent(item.href) ? 'page' : undefined}
>
<Icon name={item.icon} size={20} />
<span class="m-lbl">{item.label}</span>
{#if item.href === '/dating/matches' && matchesUnread > 0}
<span class="m-badge">{matchesUnread}</span>
{/if}
</a>
{/each}
</nav>
{/if}
{#if isOpsShell}
<header class="ops-header">
<a class="brand" href="/dating">
<span class="brand-mark" aria-hidden="true">N</span>
<span>Nexo</span>
<span class="ops-tag">moderación</span>
</a>
<nav class="ops-nav" aria-label="Operación">
<a
href="/dating/admin"
class="ops-link"
aria-current={isCurrent('/dating/admin') ? 'page' : undefined}
>Moderación</a>
<a
href="/dating/devtools"
class="ops-link"
aria-current={isCurrent('/dating/devtools') ? 'page' : undefined}
>Devtools</a>
<a href="/dating/discover" class="ops-link">Volver a la app</a>
</nav>
<div class="ops-auth">
{#if session?.authenticated === true}
<span class="ops-user">{userName}</span>
<button type="button" class="nx-btn nx-btn-secondary nx-btn-sm" onclick={logout}>
Cerrar sesión
</button>
{/if}
@ -235,15 +295,17 @@
</header>
{/if}
{#if loadError !== null}
<aside class="banner error" role="alert">
<strong>API offline.</strong>
<span>{loadError}</span>
<small>Run <code>npm run dating:server</code> from the repo root.</small>
</aside>
{/if}
<main class="nx-main" class:has-rail={showProductChrome}>
{#if loadError !== null}
<aside class="nx-banner" role="alert">
<Icon name="alert" size={16} />
<div>
<strong>API offline.</strong>
<span>{loadError}</span>
</div>
</aside>
{/if}
<main class="nexo-main">
{@render children?.()}
</main>
</div>
@ -251,95 +313,299 @@
<MessageToast {toasts} dismiss={dismissToast} />
<style>
.nexo-shell {
:global(html),
:global(body) {
margin: 0;
padding: 0;
background: var(--nx-bg);
}
/* Product shell — desktop is rail + main, mobile is main + bottom tabs. */
.shell-product {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
min-height: 100vh;
}
.shell-product .rail {
grid-column: 1;
grid-row: 1;
display: flex;
flex-direction: column;
font-family: system-ui, sans-serif;
gap: 24px;
padding: 20px 16px 16px;
border-right: 1px solid var(--nx-border);
background: var(--nx-bg-sunken);
position: sticky;
top: 0;
height: 100vh;
}
.nexo-header {
.shell-product .nx-main {
grid-column: 2;
grid-row: 1;
}
.shell-product .mobile-tabbar {
display: none;
}
.rail-brand {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 0.75rem 1.25rem;
border-bottom: 1px solid color-mix(in srgb, currentColor 12%, transparent);
background: color-mix(in srgb, canvas 92%, currentColor 8%);
gap: 10px;
padding: 4px 8px 8px;
}
.brand-mark {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: var(--nx-radius-md);
background: var(--nx-accent);
color: var(--nx-accent-fg);
font-weight: 700;
font-size: 14px;
letter-spacing: -0.02em;
}
.brand-text {
font-weight: 600;
font-size: var(--nx-text-md);
letter-spacing: -0.015em;
}
.brand {
.rail-nav {
display: flex;
flex-direction: column;
gap: 2px;
}
.rail-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: var(--nx-radius-md);
color: var(--nx-fg-muted);
font-size: var(--nx-text-sm);
font-weight: 500;
text-decoration: none;
color: inherit;
line-height: 1.1;
transition: all var(--nx-dur-fast) var(--nx-soft-curve);
}
.brand strong {
font-size: 1.1rem;
.rail-item:hover {
background: var(--nx-bg-inset);
color: var(--nx-fg);
}
.brand .tagline {
font-size: 0.78rem;
opacity: 0.7;
.rail-item[aria-current='page'] {
background: var(--nx-bg-elevated);
color: var(--nx-fg);
box-shadow: var(--nx-shadow-xs);
}
nav {
display: flex;
gap: 0.25rem;
.rail-item .lbl {
flex: 1;
}
.nav-item {
text-decoration: none;
color: inherit;
padding: 0.4rem 0.7rem;
border-radius: 6px;
font-size: 0.9rem;
.rail-item .badge {
min-width: 18px;
height: 18px;
padding: 0 6px;
border-radius: var(--nx-radius-pill);
background: var(--nx-accent);
color: var(--nx-accent-fg);
font-size: 10px;
font-weight: 700;
display: grid;
place-items: center;
line-height: 1;
}
.nav-item[aria-current='page'] {
background: color-mix(in srgb, currentColor 10%, transparent);
font-weight: 600;
.rail-footer {
margin-top: auto;
display: flex;
flex-direction: column;
gap: 8px;
}
.auth-slot {
.rail-user {
display: flex;
align-items: center;
gap: 0.5rem;
}
.auth-slot .who {
font-size: 0.85rem;
opacity: 0.7;
}
.auth-slot button {
font: inherit;
font-size: 0.85rem;
padding: 0.35rem 0.7rem;
border-radius: 6px;
border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
cursor: pointer;
background: transparent;
color: inherit;
gap: 10px;
padding: 8px 10px;
border-radius: var(--nx-radius-md);
min-width: 0;
}
.banner {
margin: 0.75rem 1.25rem 0;
padding: 0.75rem 1rem;
border-radius: 8px;
.rail-user .avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--nx-bg-inset);
color: var(--nx-fg);
display: grid;
place-items: center;
font-weight: 600;
flex-shrink: 0;
}
.rail-user .who {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
line-height: 1.2;
}
.rail-user .name {
font-size: var(--nx-text-sm);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rail-user .email {
font-size: var(--nx-text-xs);
color: var(--nx-fg-subtle);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.logout-btn {
justify-content: flex-start;
}
/* Auth shell — full-width, no chrome (each auth page brings its own). */
.shell-auth {
min-height: 100vh;
}
.banner.error {
background: color-mix(in srgb, #d73a49 15%, canvas);
/* Ops shell — header + main. */
.shell-ops {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.ops-header {
display: flex;
align-items: center;
gap: 24px;
padding: 12px 24px;
border-bottom: 1px solid var(--nx-border);
background: var(--nx-bg-elevated);
}
.ops-header .brand {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
text-decoration: none;
color: inherit;
border: 1px solid color-mix(in srgb, #d73a49 35%, transparent);
}
.banner small {
opacity: 0.7;
.ops-tag {
font-size: var(--nx-text-xs);
font-weight: 500;
color: var(--nx-fg-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-left: 4px;
}
.ops-nav {
display: flex;
gap: 4px;
flex: 1;
}
.ops-link {
text-decoration: none;
color: var(--nx-fg-muted);
padding: 6px 12px;
border-radius: var(--nx-radius-md);
font-size: var(--nx-text-sm);
font-weight: 500;
}
.ops-link:hover {
background: var(--nx-bg-inset);
color: var(--nx-fg);
}
.ops-link[aria-current='page'] {
background: var(--nx-bg-inset);
color: var(--nx-fg);
font-weight: 600;
}
.ops-auth {
display: flex;
align-items: center;
gap: 12px;
}
.ops-user {
font-size: var(--nx-text-sm);
color: var(--nx-fg-muted);
}
.nexo-main {
.nx-main {
min-width: 0;
}
.shell-ops .nx-main {
flex: 1;
padding: 1.5rem 1.25rem;
}
/*
* On user-facing surfaces the layout reserves no chrome, so pages
* (Discover, Matches, Chat, Profile…) get the full viewport for
* their own product navigation.
*/
.nexo-shell:not(.has-chrome) .nexo-main {
padding: 1rem;
padding: 32px 24px;
}
/* Mobile / tablet — drop the rail, keep a bottom tab bar instead. */
@media (max-width: 1023px) {
.shell-product {
grid-template-columns: 1fr;
grid-template-rows: minmax(0, 1fr) auto;
}
.shell-product .rail {
display: none;
}
.shell-product .nx-main {
grid-column: 1;
padding-bottom: 72px;
}
.shell-product .mobile-tabbar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: var(--nx-z-nav);
display: grid;
grid-template-columns: repeat(4, 1fr);
background: var(--nx-bg-elevated);
border-top: 1px solid var(--nx-border);
padding: 6px 4px calc(6px + env(safe-area-inset-bottom, 0px));
}
.m-tab {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 8px 4px;
color: var(--nx-fg-subtle);
text-decoration: none;
font-size: 10px;
font-weight: 500;
border-radius: var(--nx-radius-sm);
position: relative;
}
.m-tab[aria-current='page'] {
color: var(--nx-accent);
}
.m-lbl {
font-size: 10px;
}
.m-badge {
position: absolute;
top: 4px;
left: 50%;
transform: translateX(8px);
min-width: 14px;
height: 14px;
padding: 0 4px;
border-radius: var(--nx-radius-pill);
background: var(--nx-accent);
color: var(--nx-accent-fg);
font-size: 9px;
font-weight: 700;
display: grid;
place-items: center;
line-height: 1;
border: 2px solid var(--nx-bg-elevated);
}
.ops-header {
gap: 12px;
padding: 10px 16px;
flex-wrap: wrap;
}
.ops-nav {
order: 3;
width: 100%;
}
}
</style>

@ -1,5 +1,5 @@
/**
* Nexo runs against a local standalone server (`servers/dating`) and
* Nexo runs against a local standalone server (`demos/dating/server`) and
* authenticates via cookies, so static prerender of the demo shell
* doesn't make sense — every request needs the live API. Skip
* prerender for everything under `/dating`.

@ -1,6 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getNexoContext } from './_lib/context';
import { getNexoContext } from '$dating/_lib/context';
const nexo = getNexoContext();

@ -1,124 +0,0 @@
<!--
Bottom-tab navigation for the product surface (Discover, Matches,
Profile, Safety, plus a logout). Pages opt-in by mounting it; the
operator-style header is rendered by the layout only on
admin/devtools, so this is the regular user's only cross-section
nav.
Bottom-fixed on mobile, sticky on desktop. Tab labels stay short
so the bar is finger-sized on phones.
-->
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { getNexoContext } from '../_lib/context';
const nexo = getNexoContext();
const items = [
{ href: '/dating/discover', label: 'Descubre', icon: '✦' },
{ href: '/dating/matches', label: 'Matches', icon: '♡' },
{ href: '/dating/profile', label: 'Perfil', icon: '◔' },
{ href: '/dating/safety', label: 'Seguridad', icon: '⚐' }
] as const;
function isCurrent(href: string): boolean {
return page.url.pathname === href || page.url.pathname.startsWith(`${href}/`);
}
async function logout() {
try {
await nexo.api.logout();
} finally {
nexo.setSession({ authenticated: false });
await goto('/dating/login', { replaceState: true });
}
}
</script>
<nav class="appnav" aria-label="Nexo">
<ul>
{#each items as item (item.href)}
<li>
<a href={item.href} aria-current={isCurrent(item.href) ? 'page' : undefined}>
<span class="icon" aria-hidden="true">{item.icon}</span>
<span class="label">{item.label}</span>
</a>
</li>
{/each}
<li class="spacer" aria-hidden="true"></li>
<li>
<button type="button" class="logout" onclick={logout}>
<span class="icon" aria-hidden="true">⏻</span>
<span class="label">Salir</span>
</button>
</li>
</ul>
</nav>
<style>
.appnav {
position: sticky;
bottom: 0;
z-index: 20;
margin: 1.5rem -1rem -1rem;
background: color-mix(in srgb, canvas 92%, currentColor 8%);
border-top: 1px solid color-mix(in srgb, currentColor 12%, transparent);
}
.appnav ul {
list-style: none;
margin: 0;
padding: 0.4rem 0.6rem;
display: flex;
gap: 0.15rem;
max-width: 40rem;
margin-inline: auto;
}
.spacer {
flex: 1;
}
.appnav a,
.appnav button {
font: inherit;
text-decoration: none;
color: inherit;
background: transparent;
border: none;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.1rem;
padding: 0.45rem 0.7rem;
border-radius: 8px;
font-size: 0.78rem;
min-width: 3.6rem;
}
.appnav a[aria-current='page'] {
background: color-mix(in srgb, currentColor 10%, transparent);
font-weight: 600;
}
.appnav .icon {
font-size: 1.1rem;
line-height: 1;
}
.appnav .logout {
opacity: 0.7;
}
.appnav .logout:hover {
opacity: 1;
}
@media (min-width: 800px) {
.appnav {
position: static;
margin: 1.5rem 0 0;
border-top: none;
border: 1px solid color-mix(in srgb, currentColor 10%, transparent);
border-radius: 12px;
background: color-mix(in srgb, canvas 96%, currentColor 4%);
}
.appnav ul {
padding: 0.4rem 0.5rem;
}
}
</style>

@ -1,103 +0,0 @@
<!--
Shared auth shell. Centers a card on the page, hosts a brand line on
top, and renders the consumer's form via children. Used by login,
register, reset and mfa so visual rhythm stays consistent and the
pages stay focused on their own form/state.
-->
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
title: string;
subtitle?: string;
children: Snippet;
footer?: Snippet;
}
let { title, subtitle, children, footer }: Props = $props();
</script>
<section class="auth-frame">
<div class="auth-card">
<header>
<p class="brand">Nexo</p>
<h1>{title}</h1>
{#if subtitle}<p class="subtitle">{subtitle}</p>{/if}
</header>
<div class="body">
{@render children()}
</div>
{#if footer}
<footer>
{@render footer()}
</footer>
{/if}
</div>
</section>
<style>
.auth-frame {
min-height: 100%;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 2rem 1rem 4rem;
}
.auth-card {
width: 100%;
max-width: 26rem;
background: color-mix(in srgb, canvas 96%, currentColor 4%);
border: 1px solid color-mix(in srgb, currentColor 12%, transparent);
border-radius: 14px;
padding: 1.5rem 1.5rem 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
box-shadow: 0 1px 0 color-mix(in srgb, currentColor 6%, transparent);
}
header {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.brand {
font-size: 0.78rem;
letter-spacing: 0.18em;
text-transform: uppercase;
opacity: 0.6;
margin: 0;
}
h1 {
font-size: 1.4rem;
margin: 0;
}
.subtitle {
margin: 0;
opacity: 0.75;
font-size: 0.92rem;
}
.body {
display: flex;
flex-direction: column;
gap: 0.85rem;
}
footer {
font-size: 0.88rem;
display: flex;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
padding-top: 0.75rem;
border-top: 1px solid color-mix(in srgb, currentColor 12%, transparent);
}
footer :global(a) {
color: #c44a78;
text-decoration: none;
font-weight: 600;
}
footer :global(a:hover) {
text-decoration: underline;
}
footer :global(span) {
opacity: 0.75;
}
</style>

@ -1,72 +0,0 @@
<!--
Form field primitive. Wraps a `<label>` + control + per-field error
in a single block so every form across the demo has the same focus
ring, spacing and error wiring without each page re-implementing it.
-->
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
id: string;
label: string;
hint?: string;
error?: string;
children: Snippet;
}
let { id, label, hint, error, children }: Props = $props();
const errorId = $derived(error ? `${id}-error` : undefined);
</script>
<div class="field" class:has-error={Boolean(error)}>
<label for={id}>{label}</label>
{@render children()}
{#if error}
<p id={errorId} class="error" role="alert">{error}</p>
{:else if hint}
<p class="hint">{hint}</p>
{/if}
</div>
<style>
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
label {
font-size: 0.85rem;
font-weight: 600;
}
:global(.field input),
:global(.field textarea),
:global(.field select) {
font: inherit;
font-size: 0.95rem;
padding: 0.6rem 0.75rem;
border-radius: 8px;
border: 1px solid color-mix(in srgb, currentColor 38%, transparent);
background: canvas;
color: inherit;
}
:global(.field input:focus-visible),
:global(.field textarea:focus-visible),
:global(.field select:focus-visible) {
outline: 2px solid color-mix(in srgb, #0366d6 70%, transparent);
outline-offset: 1px;
border-color: transparent;
}
.has-error :global(input),
.has-error :global(textarea),
.has-error :global(select) {
border-color: color-mix(in srgb, #d73a49 50%, transparent);
}
.hint {
font-size: 0.78rem;
opacity: 0.7;
margin: 0;
}
.error {
font-size: 0.82rem;
color: color-mix(in srgb, #d73a49 70%, currentColor);
margin: 0;
}
</style>

@ -1,159 +0,0 @@
<!--
Floating toast for incoming chat messages received while the user
is not on that conversation. Stacks up to a small max so a chatty
counterpart doesn't fill the screen, and each toast auto-dismisses
after a few seconds.
Lives in the layout: the realtime subscription decides when to
queue a toast and gives us a typed list. The component itself is
pure presentation — it doesn't know about the network.
-->
<script lang="ts">
import { goto } from '$app/navigation';
export interface MessageToastEntry {
readonly id: string;
readonly matchId: string;
readonly title: string;
readonly body: string;
}
interface Props {
toasts: MessageToastEntry[];
dismiss: (id: string) => void;
}
let { toasts, dismiss }: Props = $props();
async function open(toast: MessageToastEntry) {
dismiss(toast.id);
await goto(`/dating/chat/${toast.matchId}`);
}
</script>
{#if toasts.length > 0}
<div class="toast-stack" aria-live="polite" aria-label="Notificaciones">
{#each toasts as toast (toast.id)}
<button type="button" class="toast" onclick={() => open(toast)}>
<span class="ico" aria-hidden="true">✉</span>
<span class="meta">
<strong>{toast.title}</strong>
<small>{toast.body}</small>
</span>
<span
class="close"
aria-label="Descartar"
onclick={(event) => {
event.stopPropagation();
dismiss(toast.id);
}}
role="button"
tabindex="0"
onkeydown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
dismiss(toast.id);
}
}}
>×</span>
</button>
{/each}
</div>
{/if}
<style>
.toast-stack {
position: fixed;
top: 1rem;
right: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
z-index: 60;
max-width: calc(100vw - 2rem);
pointer-events: none;
}
.toast {
pointer-events: auto;
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.7rem;
align-items: center;
min-width: 16rem;
max-width: 24rem;
padding: 0.7rem 0.85rem;
border-radius: 12px;
background: canvas;
color: inherit;
border: 1px solid color-mix(in srgb, currentColor 14%, transparent);
box-shadow: 0 8px 24px color-mix(in srgb, currentColor 12%, transparent);
font: inherit;
text-align: left;
cursor: pointer;
animation: slide-in 0.18s ease-out;
}
.toast:hover {
border-color: color-mix(in srgb, #c44a78 50%, transparent);
}
.toast:focus-visible {
outline: 2px solid #c44a78;
outline-offset: 2px;
}
.ico {
width: 32px;
height: 32px;
border-radius: 999px;
background: color-mix(in srgb, #c44a78 18%, canvas);
color: #c44a78;
display: grid;
place-items: center;
font-size: 1rem;
}
.meta {
display: flex;
flex-direction: column;
min-width: 0;
line-height: 1.25;
}
.meta strong {
font-size: 0.92rem;
}
.meta small {
font-size: 0.82rem;
opacity: 0.78;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.close {
font-size: 1.1rem;
opacity: 0.55;
cursor: pointer;
padding: 0 0.25rem;
}
.close:hover {
opacity: 1;
}
@keyframes slide-in {
from {
transform: translateY(-6px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@media (max-width: 600px) {
.toast-stack {
top: auto;
bottom: 5rem; /* clear the AppNav bottom-tab bar */
left: 1rem;
right: 1rem;
}
.toast {
min-width: 0;
max-width: 100%;
}
}
</style>

@ -1,157 +0,0 @@
<!--
Tarjeta canónica de perfil para Discover. Muestra foto principal,
nombre+edad, intent, ubicación aproximada, intereses y bio. Es
intencionalmente "dating-specific": vivirá bajo `_components/`
hasta que el plan-implementacion la promocione a `uix`.
-->
<script lang="ts">
import type { DatingProfile } from '../_lib/types';
interface Props {
profile: DatingProfile;
}
let { profile }: Props = $props();
const main = $derived(
profile.photoUrls.find((entry) => entry.filename === profile.primaryPhoto) ??
profile.photoUrls[0]
);
const intentLabel = $derived(
({
dating: 'Conocer gente',
long_term: 'Algo serio',
casual: 'Algo casual',
friends: 'Amistad',
unsure: 'Sin etiqueta'
} as const)[profile.intent]
);
</script>
<article class="card" data-intent={profile.intent}>
<div class="photo">
{#if main}
<img src={main.card} alt={`Foto de ${profile.displayName}`} loading="lazy" />
{:else}
<div class="photo-placeholder" aria-hidden="true">
<span>{profile.displayName.slice(0, 1).toUpperCase()}</span>
</div>
{/if}
<span class="intent-pill">{intentLabel}</span>
</div>
<div class="body">
<header>
<h2>
<span class="name">{profile.displayName}</span>
<span class="age">{profile.age}</span>
</h2>
{#if profile.approxLocation !== ''}
<p class="location">{profile.approxLocation}</p>
{/if}
</header>
{#if profile.bio !== ''}
<p class="bio">{profile.bio}</p>
{/if}
{#if profile.interests.length > 0}
<ul class="chips" aria-label="Intereses">
{#each profile.interests.slice(0, 8) as interest (interest)}
<li>{interest}</li>
{/each}
</ul>
{/if}
</div>
</article>
<style>
.card {
display: flex;
flex-direction: column;
border-radius: 14px;
overflow: hidden;
border: 1px solid color-mix(in srgb, currentColor 12%, transparent);
background: color-mix(in srgb, canvas 96%, currentColor 4%);
box-shadow: 0 1px 0 color-mix(in srgb, currentColor 6%, transparent);
max-width: 24rem;
width: 100%;
}
.photo {
position: relative;
aspect-ratio: 4 / 5;
background: color-mix(in srgb, currentColor 6%, transparent);
}
.photo img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.photo-placeholder {
width: 100%;
height: 100%;
display: grid;
place-items: center;
font-size: 4rem;
font-weight: 600;
opacity: 0.4;
}
.intent-pill {
position: absolute;
bottom: 0.6rem;
left: 0.6rem;
padding: 0.25rem 0.6rem;
font-size: 0.75rem;
border-radius: 999px;
background: color-mix(in srgb, canvas 80%, transparent);
backdrop-filter: blur(6px);
font-weight: 600;
}
.body {
padding: 1rem 1.1rem 1.2rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
header h2 {
display: flex;
align-items: baseline;
gap: 0.5rem;
font-size: 1.25rem;
margin: 0;
}
header .age {
font-weight: 400;
opacity: 0.7;
}
.location {
margin: 0;
font-size: 0.85rem;
opacity: 0.7;
}
.bio {
margin: 0;
font-size: 0.92rem;
line-height: 1.45;
display: -webkit-box;
-webkit-line-clamp: 4;
line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
}
.chips {
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
padding: 0;
margin: 0;
}
.chips li {
font-size: 0.78rem;
padding: 0.18rem 0.55rem;
border-radius: 999px;
background: color-mix(in srgb, currentColor 8%, transparent);
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@ -1,34 +1,31 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getNexoContext } from '../_lib/context';
import { DatingApiError } from '../_lib/api';
import AuthLayout from '../_components/AuthLayout.svelte';
import Field from '../_components/Field.svelte';
import { getNexoContext } from '$dating/_lib/context';
import { DatingApiError } from '$dating/_lib/api';
import AuthLayout from '$dating/_components/AuthLayout.svelte';
import Field from '$dating/_components/Field.svelte';
import Icon from '$dating/_components/Icon.svelte';
const nexo = getNexoContext();
let email = $state('');
let password = $state('');
let showPw = $state(false);
let submitting = $state(false);
let error = $state<string | null>(null);
let fieldErrors = $state<{ email?: string; password?: string }>({});
// Local validation: keep it minimal — the server is authoritative
// and returns structured codes (`weak_password`, etc.) that we map
// to localised messages below. Doing the heavy lifting here would
// just duplicate the server's contract.
function validate(): boolean {
const next: typeof fieldErrors = {};
if (email.trim() === '') next.email = 'Introduce tu email.';
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
next.email = 'Email no válido.';
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) next.email = 'Email no válido.';
if (password === '') next.password = 'Introduce tu contraseña.';
fieldErrors = next;
return Object.keys(next).length === 0;
}
function messageFor(error: DatingApiError): string {
switch (error.code) {
function messageFor(err: DatingApiError): string {
switch (err.code) {
case 'invalid_credentials':
return 'Email o contraseña incorrectos. Si no tienes cuenta, regístrate primero.';
case 'account_restricted':
@ -40,18 +37,13 @@
case 'missing_field':
return 'Faltan campos obligatorios.';
}
// PocketBase rejects bad credentials with HTTP 400 and a numeric
// `code: 400`. The dating server passes the envelope through
// verbatim, so any 400 with a generic auth-failed message ends
// up here — translate it instead of leaking the upstream
// English text into the UI.
if (
error.status === 400 &&
(error.message === 'Failed to authenticate.' || String(error.code) === '400')
err.status === 400 &&
(err.message === 'Failed to authenticate.' || String(err.code) === '400')
) {
return 'Email o contraseña incorrectos. Si no tienes cuenta, regístrate primero.';
}
return error.message || 'Algo ha ido mal. Vuelve a intentarlo.';
return err.message || 'Algo ha ido mal. Vuelve a intentarlo.';
}
async function submit(event: SubmitEvent) {
@ -63,11 +55,6 @@
error = null;
try {
await nexo.api.login({ email: email.trim(), password });
// Re-issue the session probe so the cell sees the freshly
// authenticated user AND their profile (which the
// `/api/auth/login` response intentionally does not
// include — it's loaded by `getMyProfile()` inside
// `api.session()`).
const session = await nexo.refreshSession();
const next =
session?.authenticated !== true ||
@ -97,18 +84,23 @@
<title>Iniciar sesión — Nexo</title>
</svelte:head>
<AuthLayout title="Iniciar sesión" subtitle="Bienvenido de vuelta a Nexo.">
<AuthLayout title="Bienvenido de vuelta." subtitle="Inicia sesión para seguir donde lo dejaste.">
<form onsubmit={submit} novalidate>
{#if error}
<p class="form-error" role="alert">{error}</p>
<div class="nx-banner err" role="alert">
<Icon name="alert" size={16} />
<span>{error}</span>
</div>
{/if}
<Field id="login-email" label="Email" error={fieldErrors.email}>
<input
class="nx-field-input"
id="login-email"
type="email"
autocomplete="email"
inputmode="email"
placeholder="tu@email.com"
required
bind:value={email}
disabled={submitting}
@ -116,23 +108,43 @@
</Field>
<Field id="login-password" label="Contraseña" error={fieldErrors.password}>
<input
id="login-password"
type="password"
autocomplete="current-password"
required
bind:value={password}
disabled={submitting}
/>
<div class="pw-wrap">
<input
class="nx-field-input"
id="login-password"
type={showPw ? 'text' : 'password'}
autocomplete="current-password"
required
bind:value={password}
disabled={submitting}
/>
<button
type="button"
class="pw-toggle"
onclick={() => (showPw = !showPw)}
aria-label={showPw ? 'Ocultar contraseña' : 'Mostrar contraseña'}
tabindex="-1"
>
<Icon name={showPw ? 'eye-off' : 'eye'} size={16} />
</button>
</div>
</Field>
<button type="submit" class="primary" disabled={submitting}>
<div class="forgot">
<a href="/dating/reset">¿Has olvidado la contraseña?</a>
</div>
<button
type="submit"
class="nx-btn nx-btn-primary nx-btn-block nx-btn-lg submit"
disabled={submitting}
>
{submitting ? 'Comprobando…' : 'Entrar'}
</button>
</form>
{#snippet footer()}
<a href="/dating/reset">¿Has olvidado la contraseña?</a>
<span class="nx-text-muted">¿Aún no tienes cuenta?</span>
<a href="/dating/register">Crear cuenta</a>
{/snippet}
</AuthLayout>
@ -141,33 +153,45 @@
form {
display: flex;
flex-direction: column;
gap: 0.85rem;
gap: 16px;
}
.form-error {
background: color-mix(in srgb, #d73a49 12%, canvas);
border: 1px solid color-mix(in srgb, #d73a49 35%, transparent);
color: inherit;
padding: 0.6rem 0.8rem;
border-radius: 8px;
margin: 0;
font-size: 0.9rem;
.nx-banner.err {
margin-bottom: 4px;
}
.primary {
font: inherit;
padding: 0.75rem 1rem;
border-radius: 8px;
.pw-wrap {
position: relative;
}
.pw-toggle {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
display: grid;
place-items: center;
width: 28px;
height: 28px;
border: none;
background: #c44a78;
color: white;
font-weight: 600;
background: transparent;
color: var(--nx-fg-muted);
cursor: pointer;
margin-top: 0.25rem;
border-radius: var(--nx-radius-sm);
}
.pw-toggle:hover {
color: var(--nx-fg);
}
.forgot {
text-align: right;
font-size: var(--nx-text-sm);
margin-top: -4px;
}
.forgot a {
color: var(--nx-fg-muted);
text-decoration: none;
}
.primary:hover:not(:disabled) {
background: #b03f6c;
.forgot a:hover {
color: var(--nx-accent);
}
.primary:disabled {
opacity: 0.55;
cursor: not-allowed;
.submit {
margin-top: 4px;
}
</style>

@ -1,19 +1,16 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { untrack } from 'svelte';
import { getNexoContext } from '../_lib/context';
import { DatingApiError } from '../_lib/api';
import type { DatingMatch, DatingProfile } from '../_lib/types';
import AppNav from '../_components/AppNav.svelte';
import { getNexoContext } from '$dating/_lib/context';
import { DatingApiError } from '$dating/_lib/api';
import type { DatingMatch, DatingProfile } from '$dating/_lib/types';
import Icon from '$dating/_components/Icon.svelte';
const nexo = getNexoContext();
let matches = $state<DatingMatch[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
// `seenAt` snapshot of `match.updated` when the user last looked at
// the row. A row whose `updated` is newer than its entry here gets
// the "nuevo" badge.
let seenAt = $state<Record<string, string>>({});
let initialised = false;
@ -29,11 +26,6 @@
untrack(() => void load(true));
});
// Refresh the list whenever the realtime distributor pushes a new
// message — the server broadcasts `dating.message.created` to every
// match member, so seeing one is the cue that some match's
// `updated` timestamp moved and a "nuevo" badge should appear on
// that row.
$effect(() => {
const session = nexo.session();
if (session?.authenticated !== true) return;
@ -52,7 +44,6 @@
try {
const next = await nexo.api.matches();
if (initial) {
// First load — assume the user has "seen" everything.
const fresh: Record<string, string> = {};
for (const match of next) fresh[match.id] = match.updated;
seenAt = fresh;
@ -66,7 +57,6 @@
error = caught instanceof Error ? caught.message : String(caught);
}
}
// Silent on poll failures — the next tick will retry.
} finally {
if (initial) loading = false;
}
@ -78,10 +68,6 @@
return match.updated > seen;
}
function markSeen(match: DatingMatch) {
seenAt = { ...seenAt, [match.id]: match.updated };
}
function counterpart(match: DatingMatch): DatingProfile | null {
const session = nexo.session();
if (session?.authenticated !== true) return null;
@ -89,7 +75,7 @@
return match.profiles.find((profile) => profile.userId !== me) ?? null;
}
function avatar(match: DatingMatch): string | null {
function avatarFor(match: DatingMatch): string | null {
const profile = counterpart(match);
if (!profile) return null;
const main =
@ -98,29 +84,32 @@
return main?.thumb ?? null;
}
function name(match: DatingMatch): string {
function nameFor(match: DatingMatch): string {
return counterpart(match)?.displayName || 'Perfil';
}
function summary(match: DatingMatch): string {
function summaryFor(match: DatingMatch): string {
const profile = counterpart(match);
if (!profile) return '';
if (profile.approxLocation === '') return profile.bio.slice(0, 80);
return `${profile.approxLocation} · ${profile.bio.slice(0, 60)}`;
if (profile.bio !== '') return profile.bio.slice(0, 90);
if (profile.approxLocation !== '') return profile.approxLocation;
return 'Aún no hay mensajes';
}
function relative(iso: string): string {
function relativeTime(iso: string): string {
if (iso === '') return '';
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return '';
const diffMs = Date.now() - then;
const minutes = Math.floor(diffMs / 60_000);
if (minutes < 1) return 'ahora';
if (minutes < 60) return `hace ${minutes} min`;
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `hace ${hours} h`;
if (hours < 24) return `${hours}h`;
const days = Math.floor(hours / 24);
return `hace ${days} d`;
if (days < 7) return `${days}d`;
const weeks = Math.floor(days / 7);
return `${weeks}sem`;
}
</script>
@ -128,185 +117,222 @@
<title>Matches — Nexo</title>
</svelte:head>
<section class="wrap">
<header>
<h1>Tus matches</h1>
<p class="sub">Cuando dos personas se gustan en Discover, aparecen aquí.</p>
<div class="page">
<header class="nx-page-header">
<div class="titles">
<h1 class="nx-h1">Matches</h1>
<p class="lede">Conversaciones con la gente con la que has hecho match.</p>
</div>
</header>
{#if error !== null}
<p class="banner error" role="alert">{error}</p>
{/if}
{#if loading}
<p class="empty pulse">Cargando matches…</p>
{:else if matches.length === 0}
<div class="empty">
<h2>Aún no tienes matches.</h2>
<p>Da likes en Discover para empezar a conectar con otras personas.</p>
<a class="primary" href="/dating/discover">Ir a Discover</a>
<div class="nx-banner" role="alert">
<Icon name="alert" size={16} />
<span>{error}</span>
</div>
{:else}
<ul class="list">
{#each matches as match (match.id)}
<li>
<a
class="row"
class:unread={hasUnread(match)}
href={`/dating/chat/${match.id}`}
onclick={() => markSeen(match)}
>
<span class="avatar" aria-hidden="true">
{#if avatar(match)}
<img src={avatar(match)} alt="" />
{:else}
<span>{name(match).slice(0, 1).toUpperCase()}</span>
{/if}
</span>
<span class="meta">
<span class="who">
<strong>{name(match)}</strong>
<small>{relative(match.updated || match.created)}</small>
</span>
<span class="summary">{summary(match)}</span>
</span>
{#if hasUnread(match)}
<span class="dot" aria-label="Nuevos mensajes" title="Nuevos mensajes"></span>
{/if}
</a>
</li>
{/each}
</ul>
{/if}
</section>
<AppNav />
<section class="list-frame">
{#if loading}
<ul class="list">
{#each [0, 1, 2, 3] as i (i)}
<li class="row skel-row">
<div class="nx-skel skel-pic"></div>
<div class="skel-body">
<div class="nx-skel skel-line short"></div>
<div class="nx-skel skel-line"></div>
</div>
</li>
{/each}
</ul>
{:else if matches.length === 0}
<div class="nx-empty">
<div class="nx-empty-art"><Icon name="message" size={24} /></div>
<h3>Aún no tienes matches.</h3>
<p>Cuando tú y otra persona os deis like en Descubre, la conversación aparecerá aquí.</p>
<a class="nx-btn nx-btn-primary nx-btn-sm" href="/dating/discover">
<Icon name="sparkle" size={14} />
<span>Ir a Descubre</span>
</a>
</div>
{:else}
<ul class="list">
{#each matches as match (match.id)}
<li>
<a class="row" class:unread={hasUnread(match)} href={`/dating/chat/${match.id}`}>
<div class="avatar" aria-hidden="true">
{#if avatarFor(match)}
<img src={avatarFor(match)} alt="" />
{:else}
<span>{nameFor(match).slice(0, 1).toUpperCase()}</span>
{/if}
{#if hasUnread(match)}
<span class="dot" aria-hidden="true"></span>
{/if}
</div>
<div class="meta">
<div class="meta-top">
<span class="name">{nameFor(match)}</span>
<span class="time">{relativeTime(match.updated || match.created)}</span>
</div>
<p class="snippet">{summaryFor(match)}</p>
</div>
<div class="chev" aria-hidden="true">
<Icon name="chevron-right" size={18} />
</div>
</a>
</li>
{/each}
</ul>
{/if}
</section>
</div>
<style>
.wrap {
max-width: 36rem;
.page {
max-width: 880px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
header h1 {
font-size: 1.5rem;
margin: 0;
}
header .sub {
margin: 0;
opacity: 0.75;
}
.banner.error {
padding: 0.6rem 0.85rem;
border-radius: 8px;
background: color-mix(in srgb, #d73a49 12%, canvas);
border: 1px solid color-mix(in srgb, #d73a49 35%, transparent);
font-size: 0.9rem;
margin: 0;
}
.empty {
display: flex;
flex-direction: column;
gap: 0.6rem;
align-items: center;
text-align: center;
padding: 2rem 1rem;
opacity: 0.85;
}
.empty.pulse {
animation: pulse 1.4s ease-in-out infinite;
padding: 0 32px 48px;
}
@keyframes pulse {
50% {
opacity: 0.4;
}
}
.empty .primary {
font: inherit;
font-weight: 600;
padding: 0.55rem 1rem;
border-radius: 8px;
background: #c44a78;
color: white;
text-decoration: none;
.list-frame {
border: 1px solid var(--nx-border);
border-radius: var(--nx-radius-lg);
background: var(--nx-bg-elevated);
overflow: hidden;
}
.list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.list li {
border-bottom: 1px solid var(--nx-border);
}
.list li:last-child {
border-bottom: none;
}
.row {
display: flex;
gap: 0.85rem;
padding: 0.7rem 0.9rem;
border-radius: 12px;
background: color-mix(in srgb, canvas 96%, currentColor 4%);
border: 1px solid color-mix(in srgb, currentColor 8%, transparent);
display: grid;
grid-template-columns: 56px 1fr auto;
gap: 16px;
align-items: center;
padding: 16px 20px;
text-decoration: none;
color: inherit;
transition: background 0.15s ease;
align-items: center;
transition: background var(--nx-dur-fast) var(--nx-soft-curve);
}
.row:hover {
background: color-mix(in srgb, canvas 90%, currentColor 10%);
}
.row.unread {
border-color: color-mix(in srgb, #c44a78 50%, transparent);
background: color-mix(in srgb, #c44a78 6%, canvas);
}
.row.unread strong {
font-weight: 700;
}
.dot {
width: 10px;
height: 10px;
border-radius: 999px;
background: #c44a78;
flex: none;
box-shadow: 0 0 0 3px color-mix(in srgb, #c44a78 25%, transparent);
background: var(--nx-bg-inset);
}
.avatar {
position: relative;
width: 48px;
height: 48px;
border-radius: 999px;
background: color-mix(in srgb, currentColor 12%, transparent);
border-radius: 50%;
background: var(--nx-bg-inset);
overflow: hidden;
display: grid;
place-items: center;
overflow: hidden;
flex: none;
font-weight: 600;
font-size: var(--nx-text-md);
color: var(--nx-fg);
justify-self: center;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar .dot {
position: absolute;
top: -2px;
right: -2px;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--nx-accent);
border: 2px solid var(--nx-bg-elevated);
}
.meta {
min-width: 0;
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
gap: 4px;
}
.who {
.meta-top {
display: flex;
justify-content: space-between;
gap: 0.5rem;
align-items: baseline;
gap: 8px;
}
.who small {
opacity: 0.6;
font-size: 0.78rem;
}
.summary {
opacity: 0.75;
font-size: 0.85rem;
.name {
font-weight: 600;
font-size: var(--nx-text-md);
color: var(--nx-fg);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.time {
font-size: var(--nx-text-xs);
color: var(--nx-fg-subtle);
flex-shrink: 0;
}
.snippet {
margin: 0;
font-size: var(--nx-text-sm);
color: var(--nx-fg-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row.unread .name {
color: var(--nx-fg);
}
.row.unread .snippet {
color: var(--nx-fg);
font-weight: 500;
}
.row.unread .time {
color: var(--nx-accent);
font-weight: 600;
}
.chev {
color: var(--nx-fg-subtle);
opacity: 0;
transition: opacity var(--nx-dur-fast) var(--nx-soft-curve);
}
.row:hover .chev {
opacity: 1;
}
.skel-row {
display: grid;
grid-template-columns: 56px 1fr;
gap: 16px;
padding: 16px 20px;
}
.skel-pic {
width: 48px;
height: 48px;
border-radius: 50%;
justify-self: center;
}
.skel-body {
display: flex;
flex-direction: column;
gap: 8px;
}
.skel-line {
height: 12px;
}
.skel-line.short {
width: 30%;
}
@media (max-width: 1023px) {
.page {
padding: 0 16px 24px;
}
}
</style>

@ -1,15 +1,13 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getNexoContext } from '../_lib/context';
import { DatingApiError } from '../_lib/api';
import { DATING_INTENTS, type DatingIntent } from '../_lib/types';
import Field from '../_components/Field.svelte';
import { getNexoContext } from '$dating/_lib/context';
import { DatingApiError } from '$dating/_lib/api';
import { DATING_INTENTS, type DatingIntent } from '$dating/_lib/types';
import Field from '$dating/_components/Field.svelte';
import Icon from '$dating/_components/Icon.svelte';
const nexo = getNexoContext();
// Guard the route — anonymous users go to login. The layout has
// already kicked off the session probe, so we just react to the
// cell.
$effect(() => {
const session = nexo.session();
if (session === null) return;
@ -45,12 +43,9 @@
const initialSession = nexo.session();
const initialProfile =
initialSession?.authenticated === true ? initialSession.profile : null;
const initialUser =
initialSession?.authenticated === true ? initialSession.user : null;
const initialUser = initialSession?.authenticated === true ? initialSession.user : null;
let displayName = $state(
initialProfile?.displayName || initialUser?.displayName || ''
);
let displayName = $state(initialProfile?.displayName || initialUser?.displayName || '');
let age = $state(initialProfile?.age && initialProfile.age >= 18 ? initialProfile.age : 25);
let bio = $state(initialProfile?.bio ?? '');
let interestsRaw = $state(initialProfile?.interests.join(', ') ?? '');
@ -71,6 +66,14 @@
.slice(0, 30)
);
const intentOptions: ReadonlyArray<{ value: DatingIntent; label: string }> = [
{ value: 'long_term', label: 'Algo serio' },
{ value: 'dating', label: 'Conocer gente' },
{ value: 'casual', label: 'Algo casual' },
{ value: 'friends', label: 'Amistad' },
{ value: 'unsure', label: 'Aún no lo sé' }
];
function validateStep(step: StepId): boolean {
const next: Record<string, string> = {};
if (step === 'identity') {
@ -92,7 +95,7 @@
return Object.keys(next).length === 0;
}
function next() {
function nextStep() {
if (!validateStep(currentStep)) return;
stepIndex = Math.min(stepIndex + 1, STEPS.length - 1);
}
@ -118,7 +121,6 @@
async function publish() {
if (submitting) return;
// Validate every step in order so errors land where they belong.
for (const step of STEPS) {
if (!validateStep(step.id)) {
stepIndex = STEPS.findIndex((s) => s.id === step.id);
@ -154,295 +156,445 @@
submitting = false;
}
}
function intentLabel(value: DatingIntent): string {
return intentOptions.find((entry) => entry.value === value)?.label ?? value;
}
function visibilityLabel(value: string): string {
switch (value) {
case 'visible':
return 'Visible para otros';
case 'hidden':
return 'Oculto';
case 'paused':
return 'Pausado';
default:
return value;
}
}
</script>
<svelte:head>
<title>Onboarding — Nexo</title>
</svelte:head>
<section class="wrap">
<header class="head">
<p class="brand">Nexo</p>
<h1>Crea tu perfil</h1>
<p class="sub">{STEPS[stepIndex].hint}</p>
</header>
<div class="page">
<aside class="sidebar">
<a class="brand" href="/dating">
<span class="brand-mark" aria-hidden="true">N</span>
<span class="brand-text">Nexo</span>
</a>
<header class="side-head">
<h1 class="nx-h2">Crea tu perfil.</h1>
<p>Cuatro pasos breves. Lo verán las personas que aparecen en Descubre.</p>
</header>
<ol class="steps">
{#each STEPS as step, i (step.id)}
<li
class="step"
class:active={i === stepIndex}
class:done={i < stepIndex}
aria-current={i === stepIndex ? 'step' : undefined}
>
<span class="num">
{#if i < stepIndex}
<Icon name="check" size={14} />
{:else}
{i + 1}
{/if}
</span>
<div>
<div class="step-label">{step.label}</div>
<div class="step-hint">{step.hint}</div>
</div>
</li>
{/each}
</ol>
</aside>
<ol class="stepper" aria-label="Progreso de onboarding">
{#each STEPS as step, i (step.id)}
<li
class:active={i === stepIndex}
class:done={i < stepIndex}
aria-current={i === stepIndex ? 'step' : undefined}
>
<span class="dot">{i + 1}</span>
<span class="label">{step.label}</span>
</li>
{/each}
</ol>
<main class="content">
<header class="content-head">
<span class="nx-eyebrow">Paso {stepIndex + 1} de {STEPS.length}</span>
<h2 class="nx-h1">{STEPS[stepIndex].label}</h2>
<p class="lede">{STEPS[stepIndex].hint}</p>
</header>
{#if topError}
<p class="form-error" role="alert">{topError}</p>
{/if}
{#if topError}
<div class="nx-banner" role="alert">
<Icon name="alert" size={16} />
<span>{topError}</span>
</div>
{/if}
<div class="step">
{#if currentStep === 'identity'}
<Field id="ob-name" label="Nombre visible" error={fieldErrors.displayName}>
<input
id="ob-name"
type="text"
autocomplete="nickname"
maxlength="80"
required
bind:value={displayName}
/>
</Field>
<Field
id="ob-age"
label="Edad"
hint="Solo personas adultas. La demo no permite menores."
error={fieldErrors.age}
>
<input id="ob-age" type="number" min="18" max="120" required bind:value={age} />
</Field>
{:else if currentStep === 'about'}
<Field
id="ob-bio"
label="Bio"
hint="Cuéntanos algo en 2-3 frases. Máximo 500 caracteres."
error={fieldErrors.bio}
>
<textarea id="ob-bio" rows={4} maxlength="500" bind:value={bio}></textarea>
</Field>
<Field
id="ob-interests"
label="Intereses"
hint="Sepáralos por comas. Hasta 30 entradas."
error={fieldErrors.interestsRaw}
>
<input
<section class="step-body">
{#if currentStep === 'identity'}
<div class="row-2">
<Field id="ob-name" label="Nombre visible" error={fieldErrors.displayName}>
<input
class="nx-field-input"
id="ob-name"
type="text"
autocomplete="nickname"
maxlength="80"
placeholder="Cómo quieres que te llamen"
required
bind:value={displayName}
/>
</Field>
<Field
id="ob-age"
label="Edad"
hint="Solo personas adultas."
error={fieldErrors.age}
>
<input
class="nx-field-input"
id="ob-age"
type="number"
min="18"
max="120"
required
bind:value={age}
/>
</Field>
</div>
{:else if currentStep === 'about'}
<Field
id="ob-bio"
label="Bio"
hint="2-3 frases. Máximo 500 caracteres."
error={fieldErrors.bio}
>
<textarea
class="nx-field-textarea"
id="ob-bio"
rows={5}
maxlength="500"
placeholder="Cuenta algo concreto que te diferencie."
bind:value={bio}
></textarea>
</Field>
<Field
id="ob-interests"
type="text"
placeholder="cine, senderismo, jazz, café"
bind:value={interestsRaw}
/>
</Field>
{#if interests.length > 0}
<ul class="chips" aria-label="Vista previa de intereses">
{#each interests as tag (tag)}
<li>{tag}</li>
{/each}
</ul>
{/if}
{:else if currentStep === 'preferences'}
<Field id="ob-intent" label="¿Qué buscas?" error={fieldErrors.intent}>
<select id="ob-intent" bind:value={intent}>
<option value="dating">Algo serio</option>
<option value="long_term">Relación a largo plazo</option>
<option value="casual">Algo casual</option>
<option value="friends">Amistad</option>
<option value="unsure">Aún no lo sé</option>
</select>
</Field>
<Field
id="ob-location"
label="Ubicación aproximada"
hint="Texto libre — no se guarda tu ubicación exacta."
error={fieldErrors.approxLocation}
>
<input
id="ob-location"
type="text"
maxlength="120"
placeholder="Madrid, ES"
bind:value={approxLocation}
/>
</Field>
<Field
id="ob-visibility"
label="Visibilidad inicial"
hint="Puedes ocultar el perfil en cualquier momento desde Profile."
>
<select id="ob-visibility" bind:value={visibility}>
<option value="visible">Visible para otros</option>
<option value="hidden">Oculto (solo tú lo ves)</option>
<option value="paused">Pausado temporalmente</option>
</select>
</Field>
{:else if currentStep === 'review'}
<dl class="review">
<div><dt>Nombre</dt><dd>{displayName}</dd></div>
<div><dt>Edad</dt><dd>{age}</dd></div>
<div><dt>Bio</dt><dd>{bio || '—'}</dd></div>
<div>
<dt>Intereses</dt>
<dd>{interests.length === 0 ? '—' : interests.join(', ')}</dd>
label="Intereses"
hint="Sepáralos por comas. Hasta 30."
error={fieldErrors.interestsRaw}
>
<input
class="nx-field-input"
id="ob-interests"
type="text"
placeholder="cine, senderismo, jazz, café"
bind:value={interestsRaw}
/>
</Field>
{#if interests.length > 0}
<ul class="preview">
{#each interests as tag (tag)}
<li class="nx-tag">{tag}</li>
{/each}
</ul>
{/if}
{:else if currentStep === 'preferences'}
<div class="prefs-block">
<span class="nx-field-label" id="intent-label">¿Qué buscas?</span>
<div class="pills" role="group" aria-labelledby="intent-label">
{#each intentOptions as option (option.value)}
<button
type="button"
class="nx-pill"
aria-pressed={intent === option.value}
onclick={() => (intent = option.value)}
>
{option.label}
</button>
{/each}
</div>
{#if fieldErrors.intent}
<p class="nx-field-error">{fieldErrors.intent}</p>
{/if}
</div>
<div><dt>Buscando</dt><dd>{intent}</dd></div>
<div><dt>Ubicación</dt><dd>{approxLocation || '—'}</dd></div>
<div><dt>Visibilidad</dt><dd>{visibility}</dd></div>
</dl>
{/if}
</div>
<footer class="actions">
<button type="button" class="ghost" onclick={back} disabled={submitting || stepIndex === 0}>
Atrás
</button>
{#if isLast}
<button type="button" class="primary" onclick={publish} disabled={submitting}>
{submitting ? 'Publicando…' : 'Publicar perfil'}
</button>
{:else}
<button type="button" class="primary" onclick={next} disabled={submitting}>
Siguiente
<div class="row-2">
<Field
id="ob-location"
label="Ubicación aproximada"
hint="Texto libre — no se guarda tu ubicación exacta."
error={fieldErrors.approxLocation}
>
<input
class="nx-field-input"
id="ob-location"
type="text"
maxlength="120"
placeholder="Madrid, ES"
bind:value={approxLocation}
/>
</Field>
<Field
id="ob-visibility"
label="Visibilidad inicial"
hint="Puedes ocultar el perfil después desde Perfil."
>
<select class="nx-field-select" id="ob-visibility" bind:value={visibility}>
<option value="visible">Visible para otros</option>
<option value="hidden">Oculto (solo tú lo ves)</option>
<option value="paused">Pausado temporalmente</option>
</select>
</Field>
</div>
{:else if currentStep === 'review'}
<dl class="review">
<div><dt>Nombre</dt><dd>{displayName}</dd></div>
<div><dt>Edad</dt><dd>{age}</dd></div>
<div>
<dt>Bio</dt>
<dd>{bio || '—'}</dd>
</div>
<div>
<dt>Intereses</dt>
<dd>{interests.length === 0 ? '—' : interests.join(', ')}</dd>
</div>
<div><dt>Buscando</dt><dd>{intentLabel(intent)}</dd></div>
<div><dt>Ubicación</dt><dd>{approxLocation || '—'}</dd></div>
<div><dt>Visibilidad</dt><dd>{visibilityLabel(visibility)}</dd></div>
</dl>
{/if}
</section>
<footer class="content-foot">
<button
type="button"
class="nx-btn nx-btn-ghost"
onclick={back}
disabled={submitting || stepIndex === 0}
>
<Icon name="arrow-left" size={14} />
<span>Atrás</span>
</button>
{/if}
</footer>
</section>
{#if isLast}
<button
type="button"
class="nx-btn nx-btn-primary"
onclick={publish}
disabled={submitting}
>
{submitting ? 'Publicando…' : 'Publicar perfil'}
</button>
{:else}
<button
type="button"
class="nx-btn nx-btn-primary"
onclick={nextStep}
disabled={submitting}
>
<span>Siguiente</span>
<Icon name="arrow-right" size={14} />
</button>
{/if}
</footer>
</main>
</div>
<style>
.wrap {
max-width: 38rem;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.25rem;
.page {
min-height: 100vh;
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
}
.head {
.sidebar {
background: var(--nx-bg-sunken);
border-right: 1px solid var(--nx-border);
padding: 28px 28px 32px;
display: flex;
flex-direction: column;
gap: 0.25rem;
gap: 28px;
}
.brand {
font-size: 0.78rem;
letter-spacing: 0.18em;
text-transform: uppercase;
opacity: 0.6;
margin: 0;
display: inline-flex;
align-items: center;
gap: 10px;
text-decoration: none;
color: inherit;
}
h1 {
font-size: 1.5rem;
margin: 0;
.brand-mark {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: var(--nx-radius-md);
background: var(--nx-accent);
color: var(--nx-accent-fg);
font-weight: 700;
}
.sub {
margin: 0;
opacity: 0.75;
.brand-text {
font-weight: 600;
font-size: var(--nx-text-md);
letter-spacing: -0.015em;
}
.stepper {
.side-head p {
margin: 8px 0 0;
font-size: var(--nx-text-sm);
color: var(--nx-fg-muted);
line-height: 1.55;
}
.steps {
list-style: none;
padding: 0;
margin: 0;
display: flex;
gap: 0.4rem;
flex-direction: column;
gap: 4px;
}
.stepper li {
flex: 1;
display: flex;
gap: 0.5rem;
align-items: center;
padding: 0.5rem 0.6rem;
border-radius: 8px;
font-size: 0.85rem;
background: color-mix(in srgb, currentColor 4%, transparent);
opacity: 0.65;
}
.stepper li.active {
opacity: 1;
background: color-mix(in srgb, currentColor 12%, transparent);
.step {
display: grid;
grid-template-columns: 28px 1fr;
gap: 10px;
align-items: flex-start;
padding: 10px 12px;
border-radius: var(--nx-radius-md);
color: var(--nx-fg-muted);
}
.step.active {
background: var(--nx-bg-elevated);
color: var(--nx-fg);
box-shadow: var(--nx-shadow-xs);
}
.step.done {
color: var(--nx-fg);
}
.step .num {
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 50%;
border: 1px solid var(--nx-border-strong);
background: var(--nx-bg-elevated);
font-size: var(--nx-text-xs);
font-weight: 600;
color: var(--nx-fg-muted);
}
.stepper li.done {
opacity: 0.85;
.step.active .num {
background: var(--nx-accent);
color: var(--nx-accent-fg);
border-color: var(--nx-accent);
}
.stepper .dot {
width: 22px;
height: 22px;
border-radius: 999px;
background: color-mix(in srgb, currentColor 18%, transparent);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.78rem;
.step.done .num {
background: var(--nx-fg);
color: var(--nx-bg);
border-color: var(--nx-fg);
}
.stepper li.active .dot {
background: color-mix(in srgb, currentColor 80%, transparent);
color: canvas;
.step-label {
font-weight: 600;
font-size: var(--nx-text-sm);
color: var(--nx-fg);
}
.step {
.step:not(.active):not(.done) .step-label {
color: var(--nx-fg-muted);
}
.step-hint {
font-size: var(--nx-text-xs);
color: var(--nx-fg-muted);
margin-top: 2px;
}
.content {
padding: 56px 56px 32px;
max-width: 720px;
display: flex;
flex-direction: column;
gap: 0.85rem;
padding: 1.25rem;
border-radius: 12px;
border: 1px solid color-mix(in srgb, currentColor 12%, transparent);
background: color-mix(in srgb, canvas 96%, currentColor 4%);
gap: 24px;
}
.chips {
.content-head {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
flex-direction: column;
gap: 4px;
}
.lede {
margin: 0;
font-size: var(--nx-text-md);
color: var(--nx-fg-muted);
}
.step-body {
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px;
border: 1px solid var(--nx-border);
border-radius: var(--nx-radius-lg);
background: var(--nx-bg-elevated);
}
.row-2 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
}
.preview {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 0;
margin: 0;
list-style: none;
}
.chips li {
font-size: 0.78rem;
padding: 0.2rem 0.55rem;
border-radius: 999px;
background: color-mix(in srgb, currentColor 8%, transparent);
.prefs-block {
display: flex;
flex-direction: column;
gap: 6px;
}
.pills {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.review {
margin: 0;
display: grid;
grid-template-columns: 1fr 2fr;
gap: 0.4rem 1rem;
display: flex;
flex-direction: column;
}
.review div {
display: contents;
display: grid;
grid-template-columns: 8rem 1fr;
gap: 16px;
padding: 12px 0;
border-bottom: 1px solid var(--nx-border);
font-size: var(--nx-text-sm);
}
.review div:last-child {
border-bottom: none;
}
.review dt {
font-weight: 600;
opacity: 0.7;
font-size: 0.85rem;
color: var(--nx-fg-muted);
margin: 0;
}
.review dd {
margin: 0;
font-size: 0.95rem;
color: var(--nx-fg);
font-weight: 500;
text-wrap: pretty;
}
.actions {
.content-foot {
display: flex;
justify-content: space-between;
gap: 0.75rem;
}
.actions .primary {
font: inherit;
font-weight: 600;
padding: 0.6rem 1.1rem;
border-radius: 8px;
border: none;
background: #c44a78;
color: white;
cursor: pointer;
}
.actions .ghost {
font: inherit;
padding: 0.6rem 1rem;
border-radius: 8px;
border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
background: transparent;
color: inherit;
cursor: pointer;
}
.actions button:disabled {
opacity: 0.55;
cursor: not-allowed;
gap: 12px;
padding-top: 16px;
border-top: 1px solid var(--nx-border);
}
.form-error {
background: color-mix(in srgb, #d73a49 12%, canvas);
border: 1px solid color-mix(in srgb, #d73a49 35%, transparent);
padding: 0.6rem 0.8rem;
border-radius: 8px;
margin: 0;
font-size: 0.9rem;
@media (max-width: 1023px) {
.page {
grid-template-columns: 1fr;
}
.sidebar {
border-right: none;
border-bottom: 1px solid var(--nx-border);
}
.content {
padding: 32px 24px 24px;
}
}
</style>

@ -1,17 +1,17 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { untrack } from 'svelte';
import { getNexoContext } from '../_lib/context';
import { DatingApiError } from '../_lib/api';
import { getNexoContext } from '$dating/_lib/context';
import { DatingApiError } from '$dating/_lib/api';
import {
DATING_INTENTS,
DATING_VISIBILITIES,
type DatingIntent,
type DatingProfile,
type DatingVisibility
} from '../_lib/types';
import Field from '../_components/Field.svelte';
import AppNav from '../_components/AppNav.svelte';
} from '$dating/_lib/types';
import Field from '$dating/_components/Field.svelte';
import Icon from '$dating/_components/Icon.svelte';
const nexo = getNexoContext();
@ -30,9 +30,6 @@
let topError = $state<string | null>(null);
let fieldErrors = $state<Record<string, string>>({});
// `hydrate()` populates many reactive cells. Without `untrack`,
// the effect's own writes would re-trigger it and clobber any
// edits the user makes between renders.
let hydratedFor = '';
$effect(() => {
const session = nexo.session();
@ -74,6 +71,31 @@
.slice(0, 30)
);
const intentLabel: Record<DatingIntent, string> = {
long_term: 'Algo serio',
dating: 'Conocer gente',
casual: 'Casual',
friends: 'Amistad',
unsure: 'Sin etiqueta'
};
const visibilityLabel: Record<DatingVisibility, string> = {
visible: 'Visible',
hidden: 'Oculto',
paused: 'Pausado'
};
const visibilityHelp: Record<DatingVisibility, string> = {
visible: 'Apareces en Descubre.',
hidden: 'No apareces para nadie. Tus matches actuales se mantienen.',
paused: 'Conservas matches pero no recibes nuevos perfiles.'
};
const heroAvatar = $derived.by(() => {
const list = profile?.photoUrls ?? [];
const main = list.find((entry) => entry.filename === profile?.primaryPhoto) ?? list[0];
return main?.thumb ?? null;
});
const heroInitial = $derived(displayName.slice(0, 1).toUpperCase() || '·');
function markDirty() {
dirty = true;
savedAt = null;
@ -134,205 +156,392 @@
<title>Perfil — Nexo</title>
</svelte:head>
<section class="wrap">
<header class="head">
<div>
<h1>Mi perfil</h1>
<p class="sub">Edita tu información pública y la visibilidad del perfil.</p>
<div class="page">
<header class="nx-page-header">
<div class="titles">
<h1 class="nx-h1">Tu perfil</h1>
<p class="lede">Lo que ven los demás cuando aparecen tu tarjeta en Descubre.</p>
</div>
<div class="actions">
<a class="nx-btn nx-btn-secondary" href="/dating/profile/photos">
<Icon name="camera" size={16} />
<span>Gestionar fotos</span>
</a>
</div>
<a class="ghost" href="/dating/profile/photos">Gestionar fotos</a>
</header>
{#if topError !== null}
<p class="banner error" role="alert">{topError}</p>
{/if}
{#if savedAt !== null && !dirty}
<p class="banner ok" role="status">Cambios guardados.</p>
<div class="nx-banner banner-pad" role="alert">
<Icon name="alert" size={16} />
<span>{topError}</span>
</div>
{/if}
<form
class="form"
onsubmit={(event) => {
event.preventDefault();
void save();
}}
>
<Field id="prof-name" label="Nombre visible" error={fieldErrors.displayName}>
<input
id="prof-name"
type="text"
maxlength="80"
bind:value={displayName}
oninput={markDirty}
disabled={saving}
/>
</Field>
<aside class="hero-col">
<div class="hero-card">
<div class="hero-photo" aria-hidden="true">
{#if heroAvatar}
<img src={heroAvatar} alt="" />
{:else}
<span class="hero-initial">{heroInitial}</span>
{/if}
</div>
<div class="hero-name">{displayName || 'Tu perfil'}</div>
<div class="hero-sub">
{age ? `${age} años · ` : ''}{intentLabel[intent]}
</div>
<div class="hero-status" data-vis={visibility}>
<span class="dot" aria-hidden="true"></span>
<span>{visibilityLabel[visibility]}</span>
</div>
<p class="hero-help">{visibilityHelp[visibility]}</p>
</div>
</aside>
<Field id="prof-age" label="Edad" error={fieldErrors.age}>
<input
id="prof-age"
type="number"
min="18"
max="120"
bind:value={age}
oninput={markDirty}
disabled={saving}
/>
</Field>
<div class="form-col">
<section class="form-section">
<header class="section-head">
<h2 class="nx-h3">Identidad</h2>
<p>Cómo te llaman y la edad que verán tus matches.</p>
</header>
<div class="row-2">
<Field id="prof-name" label="Nombre visible" error={fieldErrors.displayName}>
<input
class="nx-field-input"
id="prof-name"
type="text"
maxlength="80"
bind:value={displayName}
oninput={markDirty}
disabled={saving}
/>
</Field>
<Field id="prof-age" label="Edad" error={fieldErrors.age}>
<input
class="nx-field-input"
id="prof-age"
type="number"
min="18"
max="120"
bind:value={age}
oninput={markDirty}
disabled={saving}
/>
</Field>
</div>
</section>
<Field id="prof-bio" label="Bio" hint="Hasta 500 caracteres." error={fieldErrors.bio}>
<textarea
id="prof-bio"
rows={4}
maxlength="500"
bind:value={bio}
oninput={markDirty}
disabled={saving}
></textarea>
</Field>
<section class="form-section">
<header class="section-head">
<h2 class="nx-h3">Sobre ti</h2>
<p>Tres frases que digan algo que el resto no diga.</p>
</header>
<Field id="prof-bio" label="Bio" hint="Hasta 500 caracteres." error={fieldErrors.bio}>
<textarea
class="nx-field-textarea"
id="prof-bio"
rows={4}
maxlength="500"
bind:value={bio}
oninput={markDirty}
disabled={saving}
></textarea>
</Field>
<Field
id="prof-interests"
label="Intereses"
hint="Sepáralos por comas. Hasta 30."
error={fieldErrors.interestsRaw}
>
<input
class="nx-field-input"
id="prof-interests"
type="text"
placeholder="cine, jazz, café…"
bind:value={interestsRaw}
oninput={markDirty}
disabled={saving}
/>
</Field>
{#if interests.length > 0}
<ul class="preview">
{#each interests as tag (tag)}
<li class="nx-tag">{tag}</li>
{/each}
</ul>
{/if}
</section>
<Field
id="prof-interests"
label="Intereses"
hint="Sepáralos por comas. Hasta 30."
error={fieldErrors.interestsRaw}
>
<input
id="prof-interests"
type="text"
bind:value={interestsRaw}
oninput={markDirty}
disabled={saving}
/>
</Field>
<section class="form-section">
<header class="section-head">
<h2 class="nx-h3">Preferencias</h2>
<p>Filtran a quién apareces y a quién ves.</p>
</header>
<div class="row-2">
<div>
<span class="nx-field-label" id="intent-label">¿Qué buscas?</span>
<div class="pills" role="group" aria-labelledby="intent-label">
{#each DATING_INTENTS as option (option)}
<button
type="button"
class="nx-pill"
aria-pressed={intent === option}
onclick={() => {
intent = option;
markDirty();
}}
disabled={saving}
>
{intentLabel[option]}
</button>
{/each}
</div>
</div>
<div>
<span class="nx-field-label" id="vis-label">Visibilidad</span>
<div class="pills" role="group" aria-labelledby="vis-label">
{#each DATING_VISIBILITIES as option (option)}
<button
type="button"
class="nx-pill"
aria-pressed={visibility === option}
onclick={() => {
visibility = option;
markDirty();
}}
disabled={saving}
>
{visibilityLabel[option]}
</button>
{/each}
</div>
</div>
</div>
<Field
id="prof-location"
label="Ubicación aproximada"
hint="Texto libre — no se guarda tu ubicación exacta."
error={fieldErrors.approxLocation}
>
<input
class="nx-field-input"
id="prof-location"
type="text"
maxlength="120"
placeholder="Madrid, ES"
bind:value={approxLocation}
oninput={markDirty}
disabled={saving}
/>
</Field>
</section>
<Field id="prof-intent" label="¿Qué buscas?">
<select id="prof-intent" bind:value={intent} onchange={markDirty} disabled={saving}>
{#each DATING_INTENTS as option (option)}
<option value={option}>{option}</option>
{/each}
</select>
</Field>
<Field
id="prof-location"
label="Ubicación aproximada"
error={fieldErrors.approxLocation}
>
<input
id="prof-location"
type="text"
maxlength="120"
bind:value={approxLocation}
oninput={markDirty}
disabled={saving}
/>
</Field>
<Field
id="prof-visibility"
label="Visibilidad"
hint="`hidden` te oculta de Discover; `paused` mantiene matches pero pausa el feed."
>
<select
id="prof-visibility"
bind:value={visibility}
onchange={markDirty}
disabled={saving}
>
{#each DATING_VISIBILITIES as option (option)}
<option value={option}>{option}</option>
{/each}
</select>
</Field>
<div class="actions">
<button
type="button"
class="ghost"
onclick={resetChanges}
disabled={!dirty || saving}
>
Descartar cambios
</button>
<button type="submit" class="primary" disabled={!dirty || saving}>
{saving ? 'Guardando…' : 'Guardar cambios'}
</button>
<footer class="form-footer">
{#if savedAt !== null && !dirty}
<span class="saved-marker">
<Icon name="check" size={14} />
<span>Guardado</span>
</span>
{:else if dirty}
<span class="dirty-marker">Cambios sin guardar</span>
{/if}
<div class="form-actions">
<button
type="button"
class="nx-btn nx-btn-ghost"
onclick={resetChanges}
disabled={!dirty || saving}
>
Descartar
</button>
<button type="submit" class="nx-btn nx-btn-primary" disabled={!dirty || saving}>
{saving ? 'Guardando…' : 'Guardar cambios'}
</button>
</div>
</footer>
</div>
</form>
</section>
<AppNav />
</div>
<style>
.wrap {
max-width: 36rem;
.page {
max-width: 1080px;
margin: 0 auto;
padding: 0 32px 48px;
}
.banner-pad {
margin-bottom: 16px;
}
.form {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
gap: 32px;
align-items: start;
}
.hero-col {
position: sticky;
top: 24px;
}
.hero-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 24px 20px;
border: 1px solid var(--nx-border);
border-radius: var(--nx-radius-lg);
background: var(--nx-bg-elevated);
text-align: center;
}
.hero-photo {
width: 120px;
height: 120px;
border-radius: 50%;
background: var(--nx-bg-inset);
overflow: hidden;
display: grid;
place-items: center;
margin-bottom: 4px;
}
.hero-photo img {
width: 100%;
height: 100%;
object-fit: cover;
}
.hero-initial {
font-size: 3rem;
font-weight: 600;
color: var(--nx-fg-subtle);
}
.hero-name {
font-family: var(--nx-font-display);
font-size: var(--nx-text-xl);
font-weight: 600;
letter-spacing: -0.015em;
}
.hero-sub {
font-size: var(--nx-text-sm);
color: var(--nx-fg-muted);
}
.hero-status {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: var(--nx-radius-pill);
background: var(--nx-bg-inset);
font-size: var(--nx-text-xs);
font-weight: 600;
margin-top: 4px;
}
.hero-status .dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--nx-success);
}
.hero-status[data-vis='hidden'] .dot {
background: var(--nx-fg-subtle);
}
.hero-status[data-vis='paused'] .dot {
background: var(--nx-warning);
}
.hero-help {
margin: 8px 0 0;
font-size: var(--nx-text-xs);
color: var(--nx-fg-muted);
max-width: 24ch;
}
.form-col {
display: flex;
flex-direction: column;
gap: 1.25rem;
gap: 16px;
}
.head {
.form-section {
padding: 24px 28px;
border: 1px solid var(--nx-border);
border-radius: var(--nx-radius-lg);
background: var(--nx-bg-elevated);
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-end;
flex-wrap: wrap;
flex-direction: column;
gap: 16px;
}
.head h1 {
font-size: 1.5rem;
margin: 0;
.section-head {
display: flex;
flex-direction: column;
gap: 4px;
padding-bottom: 12px;
border-bottom: 1px solid var(--nx-border);
}
.head .sub {
.section-head p {
margin: 0;
opacity: 0.75;
font-size: var(--nx-text-sm);
color: var(--nx-fg-muted);
}
.row-2 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
}
form {
.pills {
display: flex;
flex-direction: column;
gap: 0.85rem;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
}
.actions {
.preview {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.5rem;
}
.primary {
font: inherit;
padding: 0.6rem 1.1rem;
border-radius: 8px;
border: none;
background: #c44a78;
color: white;
font-weight: 600;
cursor: pointer;
}
.ghost {
font: inherit;
padding: 0.55rem 1rem;
border-radius: 8px;
border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
background: transparent;
color: inherit;
cursor: pointer;
text-decoration: none;
}
button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.banner {
padding: 0.55rem 0.85rem;
border-radius: 8px;
font-size: 0.9rem;
flex-wrap: wrap;
gap: 6px;
padding: 0;
margin: 0;
list-style: none;
}
.form-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 16px 0 0;
}
.banner.error {
background: color-mix(in srgb, #d73a49 12%, canvas);
border: 1px solid color-mix(in srgb, #d73a49 35%, transparent);
.form-actions {
display: flex;
gap: 8px;
margin-left: auto;
}
.saved-marker {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: var(--nx-text-sm);
color: var(--nx-success);
font-weight: 500;
}
.banner.ok {
background: color-mix(in srgb, #2da44e 14%, canvas);
border: 1px solid color-mix(in srgb, #2da44e 35%, transparent);
.dirty-marker {
font-size: var(--nx-text-sm);
color: var(--nx-warning);
font-weight: 500;
}
@media (max-width: 1023px) {
.page {
padding: 0 16px 24px;
}
.form {
grid-template-columns: 1fr;
}
.hero-col {
position: static;
}
}
</style>

@ -1,13 +1,14 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getNexoContext } from '../../_lib/context';
import { DatingApiError } from '../../_lib/api';
import type { DatingProfile } from '../../_lib/types';
import { getNexoContext } from '$dating/_lib/context';
import { DatingApiError } from '$dating/_lib/api';
import type { DatingProfile } from '$dating/_lib/types';
import Icon from '$dating/_components/Icon.svelte';
const nexo = getNexoContext();
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'] as const;
const MAX_FILE_BYTES = 5 * 1024 * 1024; // 5 MB per the auth-y-fotos.md spec
const MAX_FILE_BYTES = 5 * 1024 * 1024;
const MAX_PHOTOS = 6;
let profile = $state<DatingProfile | null>(null);
@ -28,10 +29,6 @@
void goto('/dating/onboarding', { replaceState: true });
return;
}
// Mirror the session-owned profile only when it actually
// changes — assigning the same reference still triggers
// reactivity in Svelte's `$state` proxies, which would re-run
// every consumer of `profile`.
if (profile?.id !== session.profile.id || profile?.updated !== session.profile.updated) {
profile = session.profile;
}
@ -101,12 +98,10 @@
function friendly(caught: unknown): string {
if (caught instanceof DatingApiError) {
if (caught.code === 'too_many_photos')
return `Has llegado al límite de ${MAX_PHOTOS} fotos.`;
if (caught.code === 'too_many_photos') return `Has llegado al límite de ${MAX_PHOTOS} fotos.`;
if (caught.code === 'invalid_photo')
return 'El servidor ha rechazado la foto. Prueba con otro archivo.';
if (caught.code === 'permission_denied')
return 'No tienes permiso para gestionar fotos.';
if (caught.code === 'permission_denied') return 'No tienes permiso para gestionar fotos.';
if (caught.code === 'network_error') return 'Sin conexión con Nexo.';
return caught.message;
}
@ -174,40 +169,58 @@
void uploadFiles(event.dataTransfer.files);
}
}
const remaining = $derived(Math.max(0, MAX_PHOTOS - (profile?.photos.length ?? 0)));
const canUpload = $derived(remaining > 0 && !working);
</script>
<svelte:head>
<title>Fotos — Nexo</title>
</svelte:head>
<section class="wrap">
<header class="head">
<a class="back" href="/dating/profile" aria-label="Volver al perfil">←</a>
<div>
<h1>Fotos del perfil</h1>
<p class="sub">
Hasta {MAX_PHOTOS} fotos en JPEG / PNG / WebP, 5 MB cada una. La foto principal es la
que se muestra en Discover.
<div class="page">
<header class="nx-page-header">
<div class="titles">
<h1 class="nx-h1">Fotos</h1>
<p class="lede">
Hasta {MAX_PHOTOS} fotos en JPEG, PNG o WebP. La primera es la que aparece en
Descubre.
</p>
</div>
<div class="actions">
<a class="nx-btn nx-btn-ghost" href="/dating/profile">
<Icon name="arrow-left" size={16} />
<span>Volver al perfil</span>
</a>
</div>
</header>
{#if topError !== null}
<p class="banner error" role="alert">{topError}</p>
<div class="nx-banner banner-pad" role="alert">
<Icon name="alert" size={16} />
<span>{topError}</span>
</div>
{/if}
{#if perFileError !== null}
<p class="banner warn" role="alert">{perFileError}</p>
<div class="nx-banner nx-banner-warning banner-pad" role="alert">
<Icon name="alert" size={16} />
<span>{perFileError}</span>
</div>
{/if}
{#if info !== null && topError === null}
<p class="banner ok" role="status">{info}</p>
<div class="nx-banner nx-banner-success banner-pad" role="status">
<Icon name="check" size={16} />
<span>{info}</span>
</div>
{/if}
<label
class="dropzone"
class:dragover={dragOver}
class:disabled={!canUpload}
ondragover={(event) => {
event.preventDefault();
dragOver = true;
if (canUpload) dragOver = true;
}}
ondragleave={() => (dragOver = false)}
ondrop={onDrop}
@ -216,231 +229,233 @@
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
disabled={working}
disabled={!canUpload}
onchange={onFileChange}
/>
<span>
<strong>Arrastra fotos</strong> o haz clic para seleccionar.
<small>Quedan {Math.max(0, MAX_PHOTOS - (profile?.photos.length ?? 0))} huecos.</small>
</span>
<div class="dz-icon" aria-hidden="true">
<Icon name="image-plus" size={24} />
</div>
<div class="dz-text">
<strong>Arrastra fotos o haz clic para seleccionar</strong>
<small>{remaining} hueco{remaining === 1 ? '' : 's'} disponible{remaining === 1 ? '' : 's'} · 5 MB máx</small>
</div>
</label>
{#if profile === null}
<p class="empty pulse">Cargando perfil…</p>
<div class="nx-empty">
<div class="nx-empty-art"><Icon name="camera" size={20} /></div>
<h3>Cargando…</h3>
</div>
{:else if profile.photos.length === 0}
<p class="empty">Aún no tienes fotos. Sube la primera para empezar.</p>
<div class="nx-empty">
<div class="nx-empty-art"><Icon name="camera" size={20} /></div>
<h3>Aún no tienes fotos</h3>
<p>Sube la primera para empezar — será la que se muestre en Descubre.</p>
</div>
{:else}
<ul class="grid">
{#each profile.photoUrls as photo, index (photo.filename)}
{@const isPrimary = photo.filename === profile.primaryPhoto}
<li class:primary={isPrimary}>
<li class="cell" class:is-primary={isPrimary}>
<img src={photo.thumb} alt={`Foto ${index + 1}`} loading="lazy" />
<div class="badges">
{#if isPrimary}
<span class="badge primary-badge">Principal</span>
{/if}
<span class="badge order">#{index + 1}</span>
</div>
<div class="photo-actions">
{#if isPrimary}
<span class="primary-badge">Principal</span>
{/if}
<div class="cell-toolbar">
{#if !isPrimary}
<button
type="button"
class="cell-btn"
onclick={() => setPrimary(photo.filename)}
disabled={working}
title="Marcar como principal"
aria-label="Marcar como principal"
>
★
<Icon name="star" size={14} />
</button>
{/if}
<button
type="button"
class="cell-btn"
onclick={() => move(photo.filename, -1)}
disabled={working || index === 0}
title="Subir"
title="Mover hacia adelante"
aria-label="Mover hacia adelante"
>
↑
<Icon name="arrow-up" size={14} />
</button>
<button
type="button"
class="cell-btn"
onclick={() => move(photo.filename, 1)}
disabled={working || index === profile.photos.length - 1}
title="Bajar"
title="Mover hacia atrás"
aria-label="Mover hacia atrás"
>
↓
<Icon name="arrow-down" size={14} />
</button>
<button
type="button"
class="danger"
class="cell-btn danger"
onclick={() => deletePhoto(photo.filename)}
disabled={working}
title="Eliminar"
aria-label="Eliminar"
>
×
<Icon name="trash" size={14} />
</button>
</div>
</li>
{/each}
</ul>
{/if}
</section>
</div>
<style>
.wrap {
max-width: 44rem;
.page {
max-width: 1080px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0 32px 48px;
}
.head {
display: flex;
gap: 0.85rem;
align-items: flex-start;
}
.head h1 {
margin: 0 0 0.2rem;
font-size: 1.5rem;
}
.head .sub {
margin: 0;
opacity: 0.75;
}
.back {
font-size: 1.2rem;
text-decoration: none;
color: inherit;
padding: 0.2rem 0.45rem;
border-radius: 6px;
}
.banner {
padding: 0.55rem 0.85rem;
border-radius: 8px;
font-size: 0.9rem;
margin: 0;
}
.banner.error {
background: color-mix(in srgb, #d73a49 12%, canvas);
border: 1px solid color-mix(in srgb, #d73a49 35%, transparent);
}
.banner.warn {
background: color-mix(in srgb, #ffae42 16%, canvas);
border: 1px solid color-mix(in srgb, #ffae42 45%, transparent);
}
.banner.ok {
background: color-mix(in srgb, #2da44e 14%, canvas);
border: 1px solid color-mix(in srgb, #2da44e 35%, transparent);
.banner-pad {
margin-bottom: 16px;
}
.dropzone {
display: flex;
justify-content: center;
align-items: center;
text-align: center;
min-height: 7rem;
border-radius: 12px;
border: 2px dashed color-mix(in srgb, currentColor 22%, transparent);
background: color-mix(in srgb, canvas 96%, currentColor 4%);
gap: 16px;
padding: 24px;
border: 1.5px dashed var(--nx-border-strong);
border-radius: var(--nx-radius-lg);
background: var(--nx-bg-elevated);
cursor: pointer;
padding: 1rem;
transition:
background var(--nx-dur-fast) var(--nx-soft-curve),
border-color var(--nx-dur-fast) var(--nx-soft-curve);
}
.dropzone:hover:not(.disabled) {
border-color: var(--nx-accent);
}
.dropzone.dragover {
background: color-mix(in srgb, #c44a78 14%, canvas);
border-color: color-mix(in srgb, #c44a78 60%, transparent);
background: var(--nx-accent-soft);
border-color: var(--nx-accent);
}
.dropzone.disabled {
cursor: not-allowed;
opacity: 0.6;
}
.dropzone input {
display: none;
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.dz-icon {
width: 44px;
height: 44px;
border-radius: var(--nx-radius-md);
background: var(--nx-accent-soft);
color: var(--nx-accent);
display: grid;
place-items: center;
flex-shrink: 0;
}
.dropzone span {
.dz-text {
display: flex;
flex-direction: column;
gap: 0.25rem;
gap: 2px;
}
.dropzone small {
opacity: 0.7;
font-size: 0.8rem;
.dz-text strong {
font-size: var(--nx-text-md);
font-weight: 600;
}
.empty {
text-align: center;
opacity: 0.75;
padding: 1rem;
margin: 0;
}
.empty.pulse {
animation: pulse 1.4s ease-in-out infinite;
}
@keyframes pulse {
50% {
opacity: 0.35;
}
.dz-text small {
color: var(--nx-fg-muted);
font-size: var(--nx-text-xs);
}
.grid {
list-style: none;
padding: 0;
margin: 0;
margin: 24px 0 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr));
gap: 0.75rem;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
}
.grid li {
.cell {
position: relative;
aspect-ratio: 4 / 5;
border-radius: 10px;
border-radius: var(--nx-radius-lg);
overflow: hidden;
background: color-mix(in srgb, currentColor 8%, transparent);
border: 1px solid color-mix(in srgb, currentColor 12%, transparent);
}
.grid li.primary {
outline: 2px solid color-mix(in srgb, #c44a78 80%, transparent);
outline-offset: -2px;
background: var(--nx-bg-inset);
border: 1px solid var(--nx-border);
}
.grid img {
.cell img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.badges {
.cell.is-primary {
outline: 2px solid var(--nx-accent);
outline-offset: -2px;
}
.primary-badge {
position: absolute;
top: 0.4rem;
left: 0.4rem;
top: 8px;
left: 8px;
padding: 4px 8px;
border-radius: var(--nx-radius-sm);
background: var(--nx-accent);
color: var(--nx-accent-fg);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.cell-toolbar {
position: absolute;
bottom: 8px;
right: 8px;
display: flex;
gap: 0.3rem;
gap: 4px;
opacity: 0;
transition: opacity var(--nx-dur-fast) var(--nx-soft-curve);
}
.badge {
font-size: 0.7rem;
padding: 0.15rem 0.45rem;
border-radius: 999px;
background: color-mix(in srgb, canvas 80%, transparent);
backdrop-filter: blur(4px);
.cell:hover .cell-toolbar,
.cell:focus-within .cell-toolbar {
opacity: 1;
}
.badge.primary-badge {
background: color-mix(in srgb, #c44a78 80%, transparent);
.cell-btn {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: var(--nx-radius-sm);
border: none;
background: rgba(9, 9, 11, 0.7);
color: white;
cursor: pointer;
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
transition: background var(--nx-dur-fast) var(--nx-soft-curve);
}
.photo-actions {
position: absolute;
bottom: 0.3rem;
right: 0.3rem;
display: flex;
gap: 0.2rem;
.cell-btn:hover:not(:disabled) {
background: rgba(9, 9, 11, 0.9);
}
.photo-actions button {
font: inherit;
width: 26px;
height: 26px;
border-radius: 6px;
border: none;
background: color-mix(in srgb, canvas 80%, transparent);
backdrop-filter: blur(4px);
color: inherit;
cursor: pointer;
font-size: 0.85rem;
.cell-btn.danger:hover:not(:disabled) {
background: var(--nx-danger);
}
.photo-actions button:disabled {
.cell-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.photo-actions .danger {
background: color-mix(in srgb, #d73a49 70%, transparent);
color: white;
@media (max-width: 1023px) {
.page {
padding: 0 16px 24px;
}
}
</style>

@ -1,9 +1,10 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getNexoContext } from '../_lib/context';
import { DatingApiError } from '../_lib/api';
import AuthLayout from '../_components/AuthLayout.svelte';
import Field from '../_components/Field.svelte';
import { getNexoContext } from '$dating/_lib/context';
import { DatingApiError } from '$dating/_lib/api';
import AuthLayout from '$dating/_components/AuthLayout.svelte';
import Field from '$dating/_components/Field.svelte';
import Icon from '$dating/_components/Icon.svelte';
const nexo = getNexoContext();
@ -33,19 +34,15 @@
if (displayName.trim().length < 2) next.displayName = 'Al menos 2 caracteres.';
else if (displayName.trim().length > 80) next.displayName = 'Demasiado largo (80 máx).';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
next.email = 'Introduce un email válido.';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) next.email = 'Introduce un email válido.';
if (password.length < 8) next.password = 'Mínimo 8 caracteres.';
if (password !== passwordConfirm)
next.passwordConfirm = 'Las contraseñas no coinciden.';
if (password !== passwordConfirm) next.passwordConfirm = 'Las contraseñas no coinciden.';
if (!adultConfirmed)
next.adultConfirmed = 'Debes confirmar que eres mayor de edad.';
if (!adultConfirmed) next.adultConfirmed = 'Debes confirmar que eres mayor de edad.';
if (!acceptedRules)
next.acceptedRules = 'Debes aceptar las reglas de la demo.';
if (!acceptedRules) next.acceptedRules = 'Debes aceptar las reglas de la demo.';
fieldErrors = next;
return Object.keys(next).length === 0;
@ -60,9 +57,7 @@
case 'password_mismatch':
return { field: ['passwordConfirm', 'Las contraseñas no coinciden.'] };
case 'adult_confirmation_required':
return {
field: ['adultConfirmed', 'Debes confirmar que eres mayor de edad.']
};
return { field: ['adultConfirmed', 'Debes confirmar que eres mayor de edad.'] };
case 'network_error':
return { top: 'No se ha podido conectar con el servidor.' };
default:
@ -85,10 +80,6 @@
passwordConfirm,
adultConfirmed
});
// Refresh the session cell so the layout sees the freshly
// created user (the register endpoint sets the session
// cookie but doesn't send the profile, which doesn't exist
// yet anyway).
await nexo.refreshSession();
await goto('/dating/onboarding', { replaceState: true });
} catch (caught) {
@ -112,21 +103,23 @@
<title>Crear cuenta — Nexo</title>
</svelte:head>
<AuthLayout
title="Crear cuenta"
subtitle="La demo crea cuentas locales contra el servidor de Nexo. No se envían emails reales."
>
<AuthLayout title="Crea tu cuenta." subtitle="Sin scrolls infinitos, sin gimmicks. Empezamos.">
<form onsubmit={submit} novalidate>
{#if topError}
<p class="form-error" role="alert">{topError}</p>
<div class="nx-banner" role="alert">
<Icon name="alert" size={16} />
<span>{topError}</span>
</div>
{/if}
<Field id="reg-name" label="Nombre visible" error={fieldErrors.displayName}>
<input
class="nx-field-input"
id="reg-name"
type="text"
autocomplete="nickname"
maxlength="80"
placeholder="Cómo quieres que te llamen"
required
bind:value={displayName}
disabled={submitting}
@ -135,10 +128,12 @@
<Field id="reg-email" label="Email" error={fieldErrors.email}>
<input
class="nx-field-input"
id="reg-email"
type="email"
autocomplete="email"
inputmode="email"
placeholder="tu@email.com"
required
bind:value={email}
disabled={submitting}
@ -148,10 +143,13 @@
<Field
id="reg-password"
label="Contraseña"
hint={password === '' ? 'Mínimo 8 caracteres.' : `Fuerza: ${passwordStrength.label}`}
hint={password === ''
? 'Al menos 8 caracteres, una mayúscula, un número.'
: `Fuerza: ${passwordStrength.label}`}
error={fieldErrors.password}
>
<input
class="nx-field-input"
id="reg-password"
type="password"
autocomplete="new-password"
@ -163,11 +161,18 @@
</Field>
{#if password !== ''}
<div
class="strength-meter"
aria-label="Fuerza de la contraseña"
data-score={passwordStrength.score}
class="nx-strength"
aria-label={`Fuerza: ${passwordStrength.score}/4`}
>
<span></span><span></span><span></span><span></span>
{#each [1, 2, 3, 4] as i (i)}
<div
class="nx-strength-bar"
class:on-1={i <= passwordStrength.score && passwordStrength.score === 1}
class:on-2={i <= passwordStrength.score && passwordStrength.score === 2}
class:on-3={i <= passwordStrength.score && passwordStrength.score === 3}
class:on-4={i <= passwordStrength.score && passwordStrength.score === 4}
></div>
{/each}
</div>
{/if}
@ -177,6 +182,7 @@
error={fieldErrors.passwordConfirm}
>
<input
class="nx-field-input"
id="reg-password-confirm"
type="password"
autocomplete="new-password"
@ -186,31 +192,40 @@
/>
</Field>
<label class="check">
<input type="checkbox" bind:checked={adultConfirmed} disabled={submitting} />
<span>Confirmo que soy mayor de edad.</span>
</label>
{#if fieldErrors.adultConfirmed}
<p class="error" role="alert">{fieldErrors.adultConfirmed}</p>
{/if}
<label class="check">
<input type="checkbox" bind:checked={acceptedRules} disabled={submitting} />
<span>
Acepto que es una demo local con datos ficticios y reglas de privacidad simuladas.
</span>
</label>
{#if fieldErrors.acceptedRules}
<p class="error" role="alert">{fieldErrors.acceptedRules}</p>
{/if}
<button type="submit" class="primary" disabled={submitting}>
<div class="checks">
<label class="nx-check">
<input type="checkbox" bind:checked={adultConfirmed} disabled={submitting} />
<span class="box"></span>
<span>Confirmo que tengo 18 años o más.</span>
</label>
{#if fieldErrors.adultConfirmed}
<p class="nx-field-error inline-error" role="alert">{fieldErrors.adultConfirmed}</p>
{/if}
<label class="nx-check">
<input type="checkbox" bind:checked={acceptedRules} disabled={submitting} />
<span class="box"></span>
<span>
He leído las <strong>reglas de la comunidad</strong> y acepto los
<strong>términos</strong> de la demo.
</span>
</label>
{#if fieldErrors.acceptedRules}
<p class="nx-field-error inline-error" role="alert">{fieldErrors.acceptedRules}</p>
{/if}
</div>
<button
type="submit"
class="nx-btn nx-btn-primary nx-btn-block nx-btn-lg submit"
disabled={submitting}
>
{submitting ? 'Creando cuenta…' : 'Crear cuenta'}
</button>
</form>
{#snippet footer()}
<span>¿Ya tienes cuenta?</span>
<span class="nx-text-muted">¿Ya tienes cuenta?</span>
<a href="/dating/login">Iniciar sesión</a>
{/snippet}
</AuthLayout>
@ -219,66 +234,21 @@
form {
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.form-error {
background: color-mix(in srgb, #d73a49 12%, canvas);
border: 1px solid color-mix(in srgb, #d73a49 35%, transparent);
padding: 0.6rem 0.8rem;
border-radius: 8px;
margin: 0;
font-size: 0.9rem;
gap: 16px;
}
.primary {
font: inherit;
padding: 0.7rem 1rem;
border-radius: 8px;
border: none;
background: #c44a78;
color: white;
font-weight: 600;
cursor: pointer;
margin-top: 0.25rem;
.nx-strength {
margin-top: -8px;
}
.primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.check {
.checks {
display: flex;
gap: 0.5rem;
font-size: 0.88rem;
align-items: flex-start;
}
.check input {
margin-top: 0.2rem;
}
.error {
font-size: 0.82rem;
color: color-mix(in srgb, #d73a49 70%, currentColor);
margin: -0.5rem 0 0 0;
}
.strength-meter {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
margin: -0.45rem 0 0;
}
.strength-meter span {
height: 4px;
background: color-mix(in srgb, currentColor 12%, transparent);
border-radius: 999px;
}
.strength-meter[data-score='1'] span:nth-child(-n + 1),
.strength-meter[data-score='2'] span:nth-child(-n + 2),
.strength-meter[data-score='3'] span:nth-child(-n + 3),
.strength-meter[data-score='4'] span {
background: color-mix(in srgb, #2da44e 80%, transparent);
flex-direction: column;
gap: 8px;
padding: 12px 0;
}
.strength-meter[data-score='1'] span:first-child {
background: color-mix(in srgb, #d73a49 80%, transparent);
.inline-error {
margin-left: 28px;
}
.strength-meter[data-score='2'] span:nth-child(-n + 2) {
background: color-mix(in srgb, #ffae42 90%, transparent);
.submit {
margin-top: 4px;
}
</style>

@ -1,8 +1,9 @@
<script lang="ts">
import { getNexoContext } from '../_lib/context';
import { DatingApiError } from '../_lib/api';
import AuthLayout from '../_components/AuthLayout.svelte';
import Field from '../_components/Field.svelte';
import { getNexoContext } from '$dating/_lib/context';
import { DatingApiError } from '$dating/_lib/api';
import AuthLayout from '$dating/_components/AuthLayout.svelte';
import Field from '$dating/_components/Field.svelte';
import Icon from '$dating/_components/Icon.svelte';
const nexo = getNexoContext();
@ -29,9 +30,6 @@
topError = null;
try {
await nexo.api.resetRequest({ email: email.trim() });
// Anti-enumeration: the server returns the same shape whether
// the email exists or not. The UI mirrors that — we always
// show the "if there is an account, you'll get an email" copy.
sent = true;
} catch (caught) {
topError =
@ -53,44 +51,57 @@
</svelte:head>
<AuthLayout
title="Recuperar acceso"
subtitle="Si esta cuenta existe en la demo, te enviaremos instrucciones."
title="Recuperar acceso."
subtitle="Te enviamos un enlace de un solo uso. No te diremos si la cuenta existe — privacy first."
>
{#if sent}
<div class="success" role="status">
<p>
Si <strong>{email}</strong> tiene cuenta en Nexo, hemos preparado un proceso de recuperación
simulado. La demo no envía emails reales: revisa los logs del servidor para ver el
registro de la solicitud.
</p>
<button type="button" class="ghost" onclick={() => (sent = false)}>
Enviar otra solicitud
</button>
<div class="nx-banner nx-banner-success" role="status">
<Icon name="check" size={16} />
<div>
Si <strong>{email}</strong> tiene cuenta, recibirás un correo en unos minutos. La demo no
envía emails reales — revisa los logs del servidor.
</div>
</div>
<button
type="button"
class="nx-btn nx-btn-secondary nx-btn-block again"
onclick={() => (sent = false)}
>
Enviar otra solicitud
</button>
{:else}
<form onsubmit={submit} novalidate>
{#if topError}
<p class="form-error" role="alert">{topError}</p>
<div class="nx-banner" role="alert">
<Icon name="alert" size={16} />
<span>{topError}</span>
</div>
{/if}
<Field id="reset-email" label="Email" error={fieldError ?? undefined}>
<input
class="nx-field-input"
id="reset-email"
type="email"
autocomplete="email"
inputmode="email"
placeholder="tu@email.com"
required
bind:value={email}
disabled={submitting}
/>
</Field>
<button type="submit" class="primary" disabled={submitting}>
{submitting ? 'Enviando…' : 'Enviar instrucciones'}
<button
type="submit"
class="nx-btn nx-btn-primary nx-btn-block nx-btn-lg submit"
disabled={submitting}
>
{submitting ? 'Enviando…' : 'Enviar enlace'}
</button>
</form>
{/if}
{#snippet footer()}
<a href="/dating/login">Volver a iniciar sesión</a>
<a href="/dating/login">← Volver a iniciar sesión</a>
{/snippet}
</AuthLayout>
@ -98,45 +109,12 @@
form {
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.form-error {
background: color-mix(in srgb, #d73a49 12%, canvas);
border: 1px solid color-mix(in srgb, #d73a49 35%, transparent);
padding: 0.6rem 0.8rem;
border-radius: 8px;
margin: 0;
font-size: 0.9rem;
}
.success {
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.success p {
margin: 0;
font-size: 0.95rem;
opacity: 0.9;
gap: 16px;
}
.primary {
font: inherit;
padding: 0.7rem 1rem;
border-radius: 8px;
border: none;
background: #c44a78;
color: white;
font-weight: 600;
cursor: pointer;
.submit {
margin-top: 4px;
}
.ghost {
font: inherit;
font-size: 0.9rem;
padding: 0.5rem 0.8rem;
border-radius: 8px;
border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
background: transparent;
color: inherit;
cursor: pointer;
align-self: flex-start;
.again {
margin-top: 16px;
}
</style>

@ -35,6 +35,7 @@ const config = {
$lang: 'src/arts/lang',
$logger: 'src/arts/logger',
$orca: 'src/arts/orca',
$dating: 'demos/dating/web',
$perm: 'src/arts/perm',
$prefs: 'src/arts/prefs',
$session: 'src/arts/session',

@ -12,6 +12,29 @@
"strict": true,
"moduleResolution": "bundler"
},
// `include` REPLACES the parent's list when present, so we
// re-state the SvelteKit defaults (ambient + types + vite + src +
// test) and add our self-contained demo trees on top. Without
// `demos/**` here, alias-resolved imports under `$dating/...`
// would resolve at runtime but svelte-check wouldn't see them.
"include": [
"./.svelte-kit/ambient.d.ts",
"./.svelte-kit/non-ambient.d.ts",
"./.svelte-kit/types/**/$types.d.ts",
"./vite.config.js",
"./vite.config.ts",
"./src/**/*.js",
"./src/**/*.ts",
"./src/**/*.svelte",
"./test/**/*.js",
"./test/**/*.ts",
"./test/**/*.svelte",
"./tests/**/*.js",
"./tests/**/*.ts",
"./tests/**/*.svelte",
"./demos/**/web/**/*.ts",
"./demos/**/web/**/*.svelte"
],
"exclude": [
"./node_modules/**",
"./src/service-worker.js",

Loading…
Cancel
Save

Powered by TurnKey Linux.