feat: public landing page with magic login, brand palette and demo build flag
Adds a public funnel landing at `/` and moves the login to `/login`, rebuilt around the panel's own colors instead of an invented palette. Landing (`src/components/landing/`, one section per file, no props): - Seven funnel sections composed by `LandingPage`. The hero's eight swatches are literally `PIE_COLORS` from `DashboardPage`, the same hex values the seed hands to avatars and services, so "your colors become your numbers" is literal. - `BrandMark` becomes the single source for the logo, replicating `public/favicon.svg`. Blue is the action surface, orange only ever marks. - `NotebookVisual` is the one deliberate exception to the palette: it is what the product replaces. - Copy drops all system vocabulary; motion comes from `src/lib/motion.ts` with a single easing, and reduced-motion resolves `initial` to the final state so a never-firing `whileInView` cannot leave a section invisible forever. Routing and bundle: - `homePathFor` is the single definition of each role's destination. - Landing, login, dashboard and calendar load lazily. Eager, the login dragged framer-motion (~40 KB gz) into every panel load and the landing downloaded recharts + FullCalendar (~187 KB gz) without charting anything. `clsx` is pinned to the `react` chunk because Rollup otherwise assigns it to `charts`, making the entry import 111 KB gz for a 200-byte utility. Responsiveness (iPhone/iPad), verified with `npm run audit:responsive`: - No touch form field below 16px, `dvh` height utilities, safe-area insets, and 40px touch targets keyed off `pointer: coarse` rather than `sm:`. - New `.ld-gutter`: `.safe-x` lives outside `@layer` and beats Tailwind's `px-*`, so it left the login's side padding at 0 on anything but an iPhone in landscape. No overflow check could see it — there was no overflow, just zero margin. The audit now guards it with a `gutter` check. - `shell-height` no longer fires on pages that legitimately scroll; the static `raw-viewport-unit` scan covers those instead. Production build: - The Dockerfile now sets `VITE_DEMO_UI=1` as a build arg. `DEMO` is a build-time constant, so without it Vite eliminated the magic login and the "Ver como…" switcher: the deployed landing promised "no registration" and led to an empty form. Verified by building both ways and diffing the bundle. - Service worker cache bumped to v2 so the orphaned pre-landing chunks get purged from returning visitors' caches. Verified: typecheck clean; unit 39/39, e2e 33/33, admin 17/17, booking 12/12, landing 13/13 (WebKit), PWA passed against the real production build. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0b466f33f3
commit
4c19244df9
@@ -0,0 +1,363 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
> El código, la UI y los mensajes de error de este repo están en **español**. Mantén ese idioma en
|
||||
> strings visibles al usuario y en mensajes de error de la API.
|
||||
|
||||
## Requisitos del entorno
|
||||
|
||||
- **Node.js >= 22.5** (recomendado 24+). El servidor usa `node:sqlite` nativo: no hay dependencia
|
||||
de compilación nativa, pero tampoco funciona en Node < 22.5. `scripts/preflight.mjs` valida esto
|
||||
antes de `dev`, `build` y `start`.
|
||||
- Windows/PowerShell: si `npm.ps1` está bloqueado por execution policy, usa **`npm.cmd`**.
|
||||
- `.npmrc` apunta la caché de npm a `.cache/npm` dentro del proyecto (el caché global no siempre es
|
||||
escribible en las máquinas de este proyecto). Instala con `npm run setup` (`npm ci`), no con
|
||||
`npm install`, para no mutar el lockfile.
|
||||
- `scripts/run-tsx.mjs` envuelve a `tsx` con un fallback de `os.userInfo()`; todo script de servidor
|
||||
se lanza a través de él, nunca con `npx tsx` directo.
|
||||
|
||||
## Comandos
|
||||
|
||||
```bash
|
||||
npm run setup # npm ci con caché local (instalación recomendada)
|
||||
npm run dev # preflight + server (:3000) y vite (:5173) en paralelo
|
||||
npm run dev:background # lo mismo, desacoplado; logs en .cache/runtime/dev.*.log
|
||||
npm run build # tsc -b && vite build -> dist/
|
||||
npm start # producción: sirve API + dist/ en :3000
|
||||
npm run typecheck # tsc -b --noEmit <-- este es el gate real de calidad
|
||||
npm run seed # regenera la DB demo
|
||||
```
|
||||
|
||||
Overrides de puerto sin tocar código (preflight aborta si el puerto está ocupado):
|
||||
|
||||
```bash
|
||||
PORT=3001 VITE_PORT=5174 API_URL=http://127.0.0.1:3001 npm run dev
|
||||
$env:PORT='3001'; $env:VITE_PORT='5174'; $env:API_URL='http://127.0.0.1:3001'; npm.cmd run dev
|
||||
```
|
||||
|
||||
`RESET_DB=1` borra `data/agendapro.db` al arrancar y vuelve a sembrar desde cero.
|
||||
|
||||
### Tests
|
||||
|
||||
```bash
|
||||
npm run test:unit # node:test sobre server/lib/{scheduling,time,metrics}.test.ts (puro, sin servidor)
|
||||
npm run test:e2e # API: login, CRUD, dashboard, roles, aislamiento -> requiere `npm run dev`
|
||||
npm run test:admin # consola admin: alta con plantilla, reset-demo, cascade -> requiere `npm run dev`
|
||||
npm run test:booking # booking público: auto-asignación y 409 -> requiere `npm run dev`
|
||||
npm run audit:visual # Playwright: 10 páginas x 4 viewports -> screenshots/
|
||||
npm run test:pwa # requiere `npm run build` + `npm start` (golpea :3000)
|
||||
```
|
||||
|
||||
Detalle importante: los `.mjs` de e2e/admin/booking/visual apuntan a **`http://localhost:5173`**
|
||||
(pasan por el proxy de Vite), mientras que `pwa-e2e.mjs` apunta a **`:3000`** porque necesita el
|
||||
build servido y el service worker (que solo se registra en `import.meta.env.PROD`).
|
||||
`PWA_BASE_URL` permite apuntarlo a otro host; la validación real de instalabilidad exige un dominio
|
||||
**HTTPS**, no una IP.
|
||||
|
||||
Un solo test unitario:
|
||||
|
||||
```bash
|
||||
node --import tsx --test server/lib/scheduling.test.ts
|
||||
node --import tsx --test --test-name-pattern="autoAssign" server/lib/scheduling.test.ts
|
||||
```
|
||||
|
||||
**`npm run lint` está roto** (ESLint 9 requiere `eslint.config.js` y el repo no tiene ninguno; es un
|
||||
hueco preexistente). No lo uses como señal de verificación: usa `typecheck` + los tests.
|
||||
|
||||
## Arquitectura
|
||||
|
||||
### Forma general
|
||||
|
||||
Un solo proceso Node sirve la API y, en producción, el frontend compilado:
|
||||
|
||||
- `server/` — Express + SQLite (`node:sqlite`). Los routers se montan en [server/index.ts](server/index.ts).
|
||||
- `src/` — SPA React 18 + Vite + Tailwind. En dev, Vite proxya `/api` al `:3000`.
|
||||
- `shared/types.ts` — los tipos TS que cruzan cliente/servidor. **Actualízalo junto con el esquema.**
|
||||
- Aliases: `@/*` → `src/*`, `@server/*` → `server/*` (definidos en `tsconfig.json`; Vite solo resuelve `@`).
|
||||
- En producción `server/index.ts` sirve `dist/` estático con fallback SPA (`app.get("*")`) que excluye
|
||||
`/api/`. Los TS del servidor se ejecutan directo con `tsx` — no hay paso de compilación de backend
|
||||
(el `Dockerfile` hace `npx tsx server/index.ts`).
|
||||
|
||||
### Multi-tenancy: la invariante central
|
||||
|
||||
Cada negocio es un tenant. Salvo `businesses` y `users`, **toda** tabla lleva `business_id`, y toda
|
||||
query debe filtrar por `req.user!.business_id`. Nunca aceptes un `business_id` que venga del body.
|
||||
El patrón canónico está en todas las rutas:
|
||||
|
||||
```ts
|
||||
db.prepare(`SELECT * FROM appointments WHERE id = ? AND business_id = ?`)
|
||||
.get(id, req.user!.business_id)
|
||||
```
|
||||
|
||||
Tres roles (`users.role`): `admin` (administrador de plataforma, `business_id` NULL, opera todos los
|
||||
negocios vía `/api/admin`), `owner` y `employee`. Middlewares en [server/lib/auth.ts](server/lib/auth.ts):
|
||||
`authRequired`, `ownerOnly`, `adminOnly`, más el helper `err(res, status, msg)`.
|
||||
|
||||
`src/App.tsx` esconde rutas por rol, pero eso es solo UX: **la autorización real vive en los
|
||||
middlewares del servidor** y los tests de e2e/admin verifican el aislamiento (403 / datos de otro
|
||||
tenant no visibles).
|
||||
|
||||
### Autenticación (demo, deliberadamente trivial)
|
||||
|
||||
El token **es el id del usuario en texto plano** y las contraseñas se comparan sin hashear
|
||||
([server/routes/auth.ts](server/routes/auth.ts)). `authRequired` rehidrata `req.user` desde la DB en
|
||||
cada request. El cliente guarda el token en `localStorage` bajo `ap_token`
|
||||
([src/lib/api.ts](src/lib/api.ts)). Esto habilita el "Ver como…" del `DemoSwitcher`.
|
||||
No lo endurezcas a medias: si se toca, hay que cambiar login, `authRequired`, `api.ts`, el
|
||||
`AuthProvider` y los `.mjs` de test a la vez.
|
||||
|
||||
### Esquema y migraciones
|
||||
|
||||
[server/db.ts](server/db.ts) abre `data/agendapro.db` (WAL + `foreign_keys = ON`) y exporta:
|
||||
|
||||
- `SCHEMA` — el esquema de instalación nueva (`CREATE TABLE IF NOT EXISTS …`).
|
||||
- `runMigrations()` — llama `migrateV1ToV2()` → `migrateV2ToV3()` → `migrateV3ToV4()` **en ese orden**,
|
||||
cada una idempotente y guardada por `getMeta("schema_version")`. Ojo: en el archivo `migrateV3ToV4`
|
||||
está definida *antes* de `migrateV2ToV3`; el orden de ejecución es el de `runMigrations`, no el del
|
||||
archivo. Versión actual: **4**.
|
||||
|
||||
Para cambiar el esquema: añade las columnas/tablas a `SCHEMA` **y** una `migrateV4ToV5()` nueva que
|
||||
haga el backfill, llámala desde `runMigrations()` y termina con `setMeta("schema_version", "5")`.
|
||||
Usa los helpers `tableExists()` / `columnExists()` para que la migración sea re-ejecutable.
|
||||
|
||||
### Siembra y plantillas
|
||||
|
||||
`server/index.ts` llama `ensureSeed()` al arrancar: crea el admin de plataforma
|
||||
(`ensurePlatformAdmin()`) y, si no hay ningún negocio, siembra el demo "Lumière".
|
||||
`seedBusiness({ businessId, template, ownerEmail, ownerName })` de
|
||||
[server/scripts/seed.ts](server/scripts/seed.ts) es reutilizable: lo usa tanto el arranque como el
|
||||
alta/reset de negocios desde `/api/admin`. Las plantillas (`estetica-spa`, `barberia`, `clinica`,
|
||||
`blank`) están en [server/lib/templates.ts](server/lib/templates.ts).
|
||||
|
||||
Cuentas demo (todas con contraseña `demo1234`): `[email protected]`, `[email protected]`, y
|
||||
seis empleados `*@lumiere.mx`. Los `.mjs` de test dependen de estos emails y del dominio
|
||||
`agendamax.demo`.
|
||||
|
||||
### Zonas horarias: no uses `date('now')`
|
||||
|
||||
Es el error más fácil de cometer aquí. Los negocios son de México (UTC-6), así que el `date('now')`
|
||||
de SQLite (UTC) atribuye mal las citas de la tarde/noche. Regla:
|
||||
|
||||
- Las citas se guardan en UTC con formato ISO-Z **`YYYY-MM-DDTHH:MM:SSZ`** (sin milisegundos).
|
||||
- `datetime(columna)` de SQLite devuelve el formato canónico **`YYYY-MM-DD HH:MM:SS`**.
|
||||
- Por eso [server/lib/time.ts](server/lib/time.ts) tiene **dos familias** de helpers:
|
||||
`bizDayBoundsIso()` para comparar contra `start_at` crudo, y `bizDayBoundsSqlite()` para comparar
|
||||
contra `datetime(columna)`. Elegir mal produce rangos que no matchean nada.
|
||||
- La tz sale de `businesses.timezone`, con default `America/Mexico_City` (ver `bizTz()` en
|
||||
[server/routes/me.ts](server/routes/me.ts)). Todos los helpers son puros y aceptan el instante
|
||||
explícito, por eso son testeables (`server/lib/time.test.ts`).
|
||||
|
||||
### Agendado, auto-asignación y guard anti doble-reserva
|
||||
|
||||
[server/lib/scheduling.ts](server/lib/scheduling.ts) está partido en dos mitades a propósito:
|
||||
primero helpers **puros** (`parseWorkingHours`, `specialtyMatch`, `hasConflict`, `scoreCandidate`,
|
||||
`getWorkingHoursForDate`…) cubiertos por `scheduling.test.ts`, y después los que tocan la DB
|
||||
(`getCandidates`, `getExistingBusy`, `isAvailable`, `pickBestSlotEmployee`, `autoAssign`,
|
||||
`runInTransaction`). Si añades lógica pura, ponla en la primera mitad y testéala.
|
||||
|
||||
Las **dos** rutas que crean citas — `POST /api/appointments`
|
||||
([server/routes/appointments.ts](server/routes/appointments.ts)) y `POST /api/public/:slug/book`
|
||||
([server/routes/booking.ts](server/routes/booking.ts)) — deben mantener esta secuencia dentro de
|
||||
`runInTransaction` (`BEGIN IMMEDIATE` … `COMMIT` / `ROLLBACK`):
|
||||
|
||||
1. resolver el empleado (explícito → auto-asignar a sí mismo si el usuario es `employee` → `autoAssign`),
|
||||
2. `isAvailable(db, empId, startMs, endMs)`; si falla, lanzar `{ status: 409, error: … }`,
|
||||
3. `INSERT` de la cita en la **misma** transacción.
|
||||
|
||||
Sacar el chequeo de la transacción reabre la carrera de doble reserva. Los errores se lanzan como
|
||||
objetos `{ status, error }` y el handler los traduce con `err()`.
|
||||
|
||||
Limitación conocida: `getExistingBusy` acota por día vía `start_at`, así que **no detecta citas que
|
||||
cruzan medianoche**; hoy queda neutralizado por el guard de horario laboral. Revisítalo si alguna vez
|
||||
se permite agendar 24h.
|
||||
|
||||
`businesses.working_hours` y `employees.working_hours` guardan un JSON `Record<1..7, {start,end}|null>`
|
||||
(1=Lunes … 7=Domingo); `null` en el empleado significa "hereda del negocio". El lado cliente lo
|
||||
maneja en [src/lib/workingHours.ts](src/lib/workingHours.ts).
|
||||
|
||||
### Frontend
|
||||
|
||||
Rutas públicas y protegidas ([src/App.tsx](src/App.tsx)):
|
||||
|
||||
```
|
||||
/ → LandingPage público; con sesión redirige según rol
|
||||
/login → LoginPage formulario + acceso de un clic a las cuentas demo
|
||||
/b/:slug → BookingPage fuera del AuthProvider; no asumas usuario
|
||||
/dashboard… → panel sin sesión → /login
|
||||
```
|
||||
|
||||
`homePathFor(user)` es la **única** definición de a dónde va cada rol (`admin` → `/admin`,
|
||||
`owner` → `/dashboard`, `employee` → `/calendar`). No la repliques.
|
||||
|
||||
**Cuatro páginas se cargan con `React.lazy`** y el motivo es medible, no estético:
|
||||
|
||||
| Lazy | Por qué |
|
||||
|---|---|
|
||||
| `LandingPage`, `LoginPage` | Son las únicas que usan `framer-motion` (~40 KB gz). Con el login eager, Rollup mete `lib/motion` en el chunk de entrada y el panel paga la librería en cada carga. |
|
||||
| `DashboardPage`, `CalendarPage` | Son las únicas que importan `recharts` (~111 KB gz) y FullCalendar (~76 KB gz). Eager, la landing las descargaba sin graficar ni agendar nada. |
|
||||
|
||||
Su frontera de `Suspense` está en el `<Outlet>` de `AppShell` (fallback `RouteSpinner`).
|
||||
`clsx` está fijado al chunk `react` en `vite.config.ts` a propósito: lo comparten `lib/format.ts` y
|
||||
recharts, y sin fijarlo Rollup lo asigna al chunk `charts`, de modo que el chunk de entrada acaba
|
||||
importando 111 KB de recharts para obtener una utilidad de 200 bytes. Verificable: `npm run build` y
|
||||
comprobar que `index-*.js` no importe `charts-*` ni `framer-*`.
|
||||
|
||||
- [src/lib/api.ts](src/lib/api.ts) es el **único** cliente HTTP de la app autenticada: un `request<T>()`
|
||||
que inyecta el bearer y normaliza errores a `Error & { status }`. Añade endpoints ahí, no `fetch`
|
||||
suelto en componentes. El flujo público usa [src/lib/publicApi.ts](src/lib/publicApi.ts) aparte.
|
||||
- React Query con `staleTime: 15_000`, sin refetch al enfocar, `retry: 1` ([src/main.tsx](src/main.tsx)).
|
||||
- `AppShell` para negocio, `AdminShell` para plataforma; `/b/:slug` (reservas públicas) se monta
|
||||
**fuera** del `AuthProvider` en [src/App.tsx](src/App.tsx) — no asumas usuario en ese árbol.
|
||||
- Calendario con FullCalendar. En móvil abre en vista Día + vista Lista; Semana/Mes se reservan a
|
||||
tablet/desktop a propósito (columnas aplastadas). `vite.config.ts` separa FullCalendar, recharts,
|
||||
react y react-query en chunks manuales.
|
||||
- Estilos de formulario (`.input`, `.select`, `.textarea`) viven en un `@layer components` de
|
||||
`src/index.css`; si los sacas de la capa, la especificidad de Tailwind los pisa.
|
||||
|
||||
### Responsividad (iPhone / iPad) — invariantes que no hay que romper
|
||||
|
||||
Objetivo: iPhone 12 (390px), iPhone 16 Pro Max (440px) e iPad (744-1180px). Verificable con
|
||||
`npm run audit:responsive` (WebKit real; requiere `npm run dev`). Reglas que sostienen el
|
||||
comportamiento actual:
|
||||
|
||||
- **Ningún campo de formulario por debajo de 16px en táctil.** Mobile Safari hace auto-zoom al
|
||||
enfocar un `input`/`select`/`textarea` con `font-size < 16px` y **no revierte** al desenfocar: la
|
||||
vista queda escalada y corrida. Era la causa del "arranca con zoom y desplazada" al tocar el campo
|
||||
de correo. La regla vive en `@media (pointer: coarse)` en [src/index.css](src/index.css) y va por
|
||||
tipo de puntero, no por ancho: un iPad también se toca con el dedo. No se resuelve con
|
||||
`maximum-scale=1` porque eso rompe el pinch-zoom de accesibilidad.
|
||||
- **Alturas de viewport con `dvh`, nunca `100vh` a secas.** `100vh` en iOS no descuenta la barra de
|
||||
URL dinámica. Usa `.h-screen-safe` / `.min-h-screen-safe` / `.max-h-screen-safe`, que declaran
|
||||
`vh` como fallback y `dvh` encima.
|
||||
- **`viewport-fit=cover` obliga a descontar los insets del sistema.** El chrome (headers, sidebars,
|
||||
bottom-sheets) usa `.safe-top`, `.safe-x` y `.safe-area-bottom`, apoyadas en las variables
|
||||
`--safe-*` de `:root`. Sin ellas el contenido queda bajo la Dynamic Island o el home indicator.
|
||||
- **Los objetivos táctiles se deciden por `pointer: coarse`, no por breakpoints `sm:`.** Un `sm:` deja
|
||||
fuera a las tablets, que son táctiles. Hay dos utilidades: `.tap-target` (40px de alto, para chips
|
||||
densos) y `.icon-btn` (40×40, para botones de solo icono).
|
||||
- **`.icon-btn` es una clase explícita a propósito; no intentes detectar los botones de icono por
|
||||
selector.** Se probó `button:has(> svg:only-child)` y es una trampa: `:only-child` solo mira hijos
|
||||
**elemento**, así que un botón o enlace con etiqueta (`<Plus/> Nueva cita`) también encaja, porque el
|
||||
texto es un nodo de texto. Esa regla, al fijar `display: inline-flex`, convirtió los `NavLink` del
|
||||
menú lateral en inline y los repartió en dos columnas en iPad Pro. Dos corolarios: `.icon-btn`
|
||||
**no** toca `display` (está fuera de `@layer` y ganaría a `hidden`/`lg:block`, sacando el botón de
|
||||
colapsar en el móvil), y centra el icono con `margin-inline: auto` sobre el `svg`, porque el
|
||||
preflight de Tailwind lo deja en `display: block` y así ignora `text-align`.
|
||||
- **Scrollbars personalizadas solo en `pointer: fine`.** Una barra de 10px en táctil roba ancho al
|
||||
layout (el ancho útil bajaba de 390 a 380px) y descuadra los cálculos de 100%.
|
||||
- **No combines `.safe-x` con `px-*` en el mismo elemento.** `.safe-x` está declarada **fuera** de
|
||||
`@layer` en [src/index.css](src/index.css), así que gana a las utilidades de Tailwind. Como resuelve
|
||||
`env(safe-area-inset-left, 0px)`, en cualquier dispositivo que no sea un iPhone en landscape deja el
|
||||
padding lateral en **0** y el texto pega con el borde. Ni el check de overflow ni el de zoom lo
|
||||
detectan, porque no hay desborde: hay cero margen. Usa `.ld-gutter` (o el patrón
|
||||
`padding-inline: max(1.25rem, var(--safe-left))` de `.ld-section`), que resuelve las dos cosas en una
|
||||
declaración. `npm run audit:responsive` lo vigila con el check `gutter` en las páginas públicas.
|
||||
- **El check `shell-height` solo aplica a páginas con shell de altura fija** (`[data-app-shell]`, o sea
|
||||
`AppShell` y `AdminShell`). Una landing o un login scrollean a propósito y son legítimamente más
|
||||
altos que el viewport; compararlos contra `innerHeight` no mide un defecto, mide que existe scroll.
|
||||
Para esas páginas el equivalente es el check estático `raw-viewport-unit`, que busca `h-screen`/`100vh`
|
||||
crudos en las fuentes.
|
||||
- **Nada debe poder desplazar la página en horizontal.** `body` lleva `overflow-x: hidden` como red
|
||||
de seguridad, pero las causas se arreglan en origen: por ejemplo, un `grid` sin `grid-cols-1`
|
||||
explícito dimensiona su columna implícita a `max-content` y la estiraba 23px más que la pantalla.
|
||||
|
||||
### Landing pública y marca
|
||||
|
||||
La landing vive en [src/components/landing/](src/components/landing/), una sección por archivo, todas
|
||||
sin props ni estado compartido; [src/pages/LandingPage.tsx](src/pages/LandingPage.tsx) solo las
|
||||
compone. El test de aceptación es `npm run test:landing` (`landing-e2e.mjs`, WebKit), y depende de
|
||||
estos atributos de datos — si los quitas, se rompe: `data-ld-section` (deben ser **7**),
|
||||
`data-ld-hero-title`, `data-ld-closing-cta`, `data-magic-login` + `data-role`.
|
||||
|
||||
**La paleta no se inventa: se hereda del panel.** Los tokens `--ld-*` de `src/index.css` apuntan a los
|
||||
colores que la app ya usa. En particular, las ocho muestras del abanico del hero son `PIE_COLORS` de
|
||||
[src/pages/DashboardPage.tsx](src/pages/DashboardPage.tsx) — los mismos hex de los servicios en la dona
|
||||
y de los avatares que reparte el seed. Por eso el hilo de la página («sus colores se vuelven sus
|
||||
números») es literal. Si cambias `PIE_COLORS`, cambia también el abanico.
|
||||
|
||||
- La marca vive en **un** sitio: [src/components/BrandMark.tsx](src/components/BrandMark.tsx), que
|
||||
replica `public/favicon.svg` (cuadrado `#3b66ff`, renglones blancos, punto `#f17616`). Si tocas esos
|
||||
colores, toca también el favicon y `npm run generate:pwa-icons`, o la pestaña deja de coincidir.
|
||||
- El azul hace de superficie de acción (`.ld-cta` usa brand-500 → brand-700, los dos tonos que
|
||||
`.btn-primary` ya usa en normal y hover) y el naranja marca, nunca es fondo de botón. Es la misma
|
||||
lógica del favicon.
|
||||
- **`NotebookVisual` es la única excepción** y es deliberada: papel crema, renglones y grafito. Es lo
|
||||
que el producto reemplaza; si se pareciera al producto, la sección dejaría de contar un cambio.
|
||||
- Tipografía: Inter de cuerpo (la misma del panel) y **Fraunces** solo en titulares. El cuerpo va a
|
||||
17px mínimo con interlínea 1.6 porque el público objetivo incluye personas de 50+.
|
||||
- Las animaciones salen de [src/lib/motion.ts](src/lib/motion.ts) con un único easing. Con
|
||||
`prefers-reduced-motion` los `initial` resuelven al **estado final**, no se acortan: un
|
||||
`initial={{opacity:0}}` cuyo `whileInView` nunca corre deja el bloque invisible para siempre. El
|
||||
test lo verifica recorriendo las siete secciones.
|
||||
|
||||
### Barra lateral
|
||||
|
||||
Un único menú vertical, colapsable a solo iconos en `lg+` mediante
|
||||
[src/lib/useSidebarCollapsed.ts](src/lib/useSidebarCollapsed.ts) (persistido en `localStorage` bajo
|
||||
`ap_sidebar_collapsed`, sincronizado entre pestañas). Colapsada mide 68px y devuelve **188px** al
|
||||
contenido, que es lo que más se nota en un iPad Pro portrait. `AppShell` y `AdminShell` comparten el
|
||||
patrón: `sidebarContent(mini)` en lugar de un JSX fijo, porque el panel móvil siempre se muestra
|
||||
completo aunque la de escritorio esté colapsada. En modo `mini` cada entrada conserva `title` para no
|
||||
perder su nombre accesible al quedarse sin texto.
|
||||
|
||||
### Calendario responsivo
|
||||
|
||||
Es la parte que más se ha roto históricamente. Tres tamaños vía
|
||||
[src/lib/useBreakpoint.ts](src/lib/useBreakpoint.ts), alineados con los media queries del CSS:
|
||||
|
||||
| | phone (≤640) | tablet (641-1024) | desktop (≥1025) |
|
||||
|---|---|---|---|
|
||||
| Vista inicial | `timeGridDay` | `timeGridThreeDay` | `timeGridWeek` |
|
||||
| Toolbar | prev/next/hoy + Día/Lista | + 3 días/Semana | + Mes |
|
||||
|
||||
El corte tablet/desktop está en 1024/1025 y **no coincide con el `lg:` de Tailwind** (también 1024):
|
||||
un iPad Pro portrait mide justo 1024px, así que a ese ancho hay barra lateral fija *y* vista de 3
|
||||
días. Es intencionado — con la barra desplegada quedan ~712px de calendario, y 7 columnas ahí son
|
||||
~100px por día. Si cambias uno de los dos umbrales, cambia también el otro
|
||||
([src/index.css](src/index.css) tiene los media queries de tablet).
|
||||
|
||||
- **La altura del calendario se calcula contra el viewport, no con `height="100%"`.** FullCalendar mide
|
||||
su contenedor una sola vez al montar y solo re-mide en resize de ventana, así que se quedaba con
|
||||
valores viejos cuando el layout cambiaba después (en un iPhone 12 daba 360px dentro de un contenedor
|
||||
de 525px). Se mide `innerHeight - rect.top` y se pasa como número; ver `CAL_BOTTOM_GAP` /
|
||||
`CAL_MIN_HEIGHT` en [src/pages/CalendarPage.tsx](src/pages/CalendarPage.tsx). El `ResizeObserver`
|
||||
observa la **barra de filtros**, no el `body`: el shell es `overflow-hidden` de altura fija, así que
|
||||
el body nunca cambia de tamaño y observarlo no vuelve a disparar.
|
||||
- **No pongas `contentHeight` fijo.** `.fc-timegrid-slot` mide 2.4rem y la grilla de 08:00-21:00 son 26
|
||||
slots (~1000px), así que un `contentHeight` en píxeles hacía el calendario 1.4× más alto que la
|
||||
pantalla y arrastraba la página. El calendario debe scrollear **dentro** de su tarjeta.
|
||||
- **La tablet usa 3 días a propósito.** Con 7 columnas en 820px y varios especialistas a la misma hora,
|
||||
`slotEventOverlap={false}` parte cada evento en columnas de ~30px y los títulos quedan en `"A.."`.
|
||||
- No añadas bloques (avisos, empty states) como hermanos del calendario dentro de su tarjeta: con la
|
||||
altura ya fijada no caben y se solapan con la grilla.
|
||||
|
||||
### PWA
|
||||
|
||||
`public/sw.js` cachea solo el app shell y **hace bypass explícito de `/api/`** y de cualquier request
|
||||
no-GET o cross-origin. Si tocas el service worker, conserva ese bypass (hay un test dedicado en
|
||||
`pwa-e2e.mjs`) y sube el sufijo de `CACHE_NAME` (`agendamax-shell-vN`) para que el cleanup de
|
||||
`activate` purgue el anterior. Se registra únicamente en producción. Iconos: `npm run generate:pwa-icons`.
|
||||
|
||||
## Convenciones de trabajo
|
||||
|
||||
- **No hagas commits salvo que se pidan explícitamente** (política registrada en
|
||||
`.superpowers/sdd/progress.md`). La verificación por tarea es typecheck + tests, no un commit.
|
||||
- Este repo se desarrolla con un flujo spec-driven: las especificaciones y planes viven en
|
||||
`docs/superpowers/{specs,plans}/` y la bitácora de ejecución con hallazgos de review en
|
||||
`.superpowers/sdd/`. Léelos antes de retomar un feature a medias — `progress.md` lista los
|
||||
follow-ups diferidos.
|
||||
- `AUDIT.md` documenta las brechas funcionales frente al producto real que se está emulando; sirve de
|
||||
backlog de producto.
|
||||
- Existe un grafo de conocimiento en `graphify-out/` (ver el skill `graphify`): úsalo para orientarte
|
||||
antes de leer código a ciegas; refréscalo con `graphify update` si quedó desactualizado.
|
||||
- `data/`, `dist/`, `screenshots/`, `.cache/`, `*.log` y `.opencode/` están gitignorados y son
|
||||
regenerables; no los versiones ni los tomes como fuente de verdad.
|
||||
|
||||
## Seguridad
|
||||
|
||||
Esto es una **demo**: token trivial, contraseñas en claro, sin rate-limiting ni sesiones reales, y
|
||||
`cors()` abierto. La validación de entrada es manual y ad-hoc en cada handler: `zod` figura en
|
||||
`dependencies` pero **no se importa en ningún archivo**. Antes de un despliegue real haría falta
|
||||
hashing (bcrypt), JWT firmados, validación estricta, rate-limiting y HTTPS. Tenlo presente antes de
|
||||
proponer este código para producción.
|
||||
Reference in New Issue
Block a user