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:
AgendaPro Dev
2026-07-28 14:25:58 -06:00
co-authored by Claude Opus 5
parent 0b466f33f3
commit 4c19244df9
53 changed files with 7310 additions and 525 deletions
+397 -26
View File
@@ -4,18 +4,39 @@
:root {
color-scheme: light;
/* Insets del sistema (Dynamic Island, notch, home indicator). Con
viewport-fit=cover el contenido se dibuja bajo esas zonas, así que el
chrome de la app tiene que descontarlas explícitamente. */
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
--safe-left: env(safe-area-inset-left, 0px);
--safe-right: env(safe-area-inset-right, 0px);
}
* {
-webkit-tap-highlight-color: transparent;
}
html {
/* iOS reescala el texto al rotar a landscape si no se fija. */
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
html,
body,
#root {
height: 100%;
}
/* El shell del panel vive en un #root de altura fija; la landing, no. Se relaja
solo cuando la landing está montada, así el panel conserva su altura fija.
:has() está soportado en Safari 15.4+, dentro del rango de iOS objetivo. */
body:has(.landing) #root {
height: auto;
min-height: 100%;
}
body {
margin: 0;
font-family: Inter, system-ui, sans-serif;
@@ -23,25 +44,41 @@ body {
color: #0f172a;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
/* Red de seguridad: ningún hijo debe poder desplazar la página en horizontal. */
overflow-x: hidden;
overscroll-behavior-y: none;
}
/* Custom scrollbars */
::-webkit-scrollbar {
width: 10px;
height: 10px;
/* Evita el zoom por doble toque y el retardo de 300 ms en controles. */
button,
a,
[role="button"],
label,
summary {
touch-action: manipulation;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 9999px;
border: 2px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
background-clip: padding-box;
/* Scrollbars visibles solo con puntero fino. En táctil deben ser overlay
(como en iOS nativo): un scrollbar de 10 px roba ancho al layout y
descuadra el cálculo de 100% en pantallas estrechas. */
@media (pointer: fine) {
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 9999px;
border: 2px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
background-clip: padding-box;
}
}
/* FullCalendar overrides */
@@ -161,6 +198,24 @@ body {
.fc .fc-timegrid-slot {
height: 2.4rem;
}
/* En táctil los slots se ajustan por tamaño de pantalla: la grilla de 08:00 a
21:00 son 26 slots, así que una altura fija de 2.4rem produce ~1000 px y
desborda cualquier teléfono. El calendario scrollea internamente (como
Google Calendar) en lugar de estirar la página. */
@media (max-width: 640px) {
.fc .fc-timegrid-slot {
height: 2.1rem;
}
}
@media (min-width: 641px) and (max-width: 1024px) {
.fc .fc-timegrid-slot {
height: 2.25rem;
}
}
/* El scroller interno del timegrid necesita arrastre fluido en iOS. */
.fc .fc-scroller {
-webkit-overflow-scrolling: touch;
}
.fc-timegrid-event {
overflow: hidden;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35);
@@ -204,24 +259,67 @@ body {
font-weight: 500;
}
/* Mobile: prevent squished week/month columns by giving them a min-width.
The card wrapper has overflow-x-auto, so wider grids scroll horizontally. */
/* Móvil: la vista Semana necesita un ancho mínimo o las 7 columnas quedan
ilegibles; el card la envuelve en overflow-auto, así que scrollea dentro de
la tarjeta sin arrastrar la página. La vista Mes sí cabe en un teléfono
(7 columnas de ~50 px), así que no se le fuerza ancho mínimo. */
@media (max-width: 640px) {
.fc-timeGridWeek-view .fc-scrollgrid-section > table,
.fc-timeGridWeek-view .fc-scrollgrid {
min-width: 640px;
}
.fc-dayGridMonth-view .fc-scrollgrid-section > table,
.fc-dayGridMonth-view .fc-scrollgrid {
min-width: 560px;
}
/* bigger touch targets for events */
/* Objetivos táctiles más grandes para los eventos */
.fc-event {
min-height: 28px;
min-height: 30px;
padding: 3px 6px !important;
}
.fc .fc-button {
padding: 0.4rem 0.6rem;
padding: 0.45rem 0.6rem;
}
/* Etiquetas de hora más estrechas: en 390 px cada píxel del eje cuenta. */
.fc .fc-timegrid-axis,
.fc .fc-timegrid-slot-label {
font-size: 0.65rem;
}
.fc .fc-timegrid-axis-frame,
.fc .fc-timegrid-slot-label-frame {
padding-right: 2px;
}
.fc .fc-col-header-cell-cushion {
padding: 0.45rem 0;
font-size: 0.65rem;
}
/* La vista Lista es la más cómoda en teléfono: más aire al tocar. */
.fc .fc-list-event {
font-size: 0.85rem;
}
.fc .fc-list-event td {
padding: 0.7rem 0.6rem;
}
}
/* Tablet (iPad portrait y mini): la semana completa cabe sin scroll lateral,
solo hay que evitar que herede el ancho mínimo del teléfono. */
@media (min-width: 641px) and (max-width: 1024px) {
.fc-timeGridWeek-view .fc-scrollgrid,
.fc-dayGridMonth-view .fc-scrollgrid {
min-width: 0;
}
.fc .fc-toolbar-title {
font-size: 1.05rem;
}
.fc .fc-button {
font-size: 0.75rem;
}
}
/* Toolbar del calendario tocable en cualquier pantalla táctil. Va por tipo de
puntero y no por ancho: un iPad en landscape mide 1180px (tier desktop) pero
se sigue tocando con el dedo. No se hereda el min-height de .btn (44px) para
no disparar el alto de la barra de navegación del calendario. */
@media (pointer: coarse) {
.fc .fc-button {
min-height: 40px;
}
}
@@ -281,8 +379,45 @@ body {
.btn-danger:hover:not(:disabled) {
background: #fecaca;
}
/* ---- Safe areas y alturas de viewport ---- */
.safe-area-bottom {
padding-bottom: max(0.75rem, env(safe-area-inset-bottom));
padding-bottom: max(0.75rem, var(--safe-bottom));
}
/* Chrome superior (headers fijos): descuenta la Dynamic Island / notch. */
.safe-top {
padding-top: var(--safe-top);
}
/* Landscape con notch: el contenido no debe quedar bajo la esquina redondeada. */
.safe-x {
padding-left: var(--safe-left);
padding-right: var(--safe-right);
}
/* Altura de viewport estable en iOS. 100vh NO descuenta la barra de URL
dinámica de Safari, así que el shell queda más alto que el área visible y
la página se puede desplazar. dvh sí la descuenta; vh queda como fallback. */
.h-screen-safe {
height: 100vh;
height: 100dvh;
}
.max-h-screen-safe {
max-height: 100vh;
max-height: 100dvh;
}
.min-h-screen-safe {
min-height: 100vh;
min-height: 100dvh;
}
/* Franjas con scroll lateral (chips de filtro) sin barra visible: en móvil la
barra roba alto y el gesto de arrastre ya comunica que hay más contenido. */
.scrollbar-none {
scrollbar-width: none;
-ms-overflow-style: none;
}
.scrollbar-none::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
@layer components {
@@ -305,6 +440,242 @@ body {
border-color: #3b66ff;
box-shadow: 0 0 0 3px rgba(59, 102, 255, 0.15);
}
/* ---- Landing pública ----
Scope propio porque el panel vive en #f6f7fb y la landing tiene su propia
identidad. Va DENTRO de la capa (el default del repo) para que cualquier
utilidad de Tailwind aplicada en el JSX gane por especificidad de capa.
La paleta NO se inventa: son los colores que la plataforma ya usa. `--ld-azul` y
`--ld-naranja` son exactamente el cuadrado y el punto del logo
(`public/favicon.svg`), la tinta y los fondos son los del panel, y los seis tonos
de acento son los que el seed asigna a los avatares de empleados y a los
servicios (`server/lib/templates.ts`).
Por eso el hilo de la página —«sus colores se vuelven sus números»— es literal:
el abanico del hero trae los mismos hex que la dueña verá en sus fichas. */
.landing {
/* Marca */
--ld-azul: #3b66ff; /* brand-500: primario del logo y de .btn-primary */
--ld-azul-hondo: #1c36dc; /* brand-700: el hover que ya usa el panel */
--ld-naranja: #f17616; /* accent-500: el punto del logo */
/* Acentos: los colores de avatares y servicios del panel, sin cambiar un dígito */
--ld-indigo: #6366f1;
--ld-purpura: #a855f7;
--ld-rosa: #ec4899;
--ld-ambar: #f59e0b;
--ld-esmeralda: #10b981;
--ld-turquesa: #14b8a6;
/* Estructura: idéntica a la del panel */
--ld-tinta: #0f172a;
--ld-papel: #ffffff;
--ld-lienzo: #f6f7fb; /* el fondo del body de la app */
--ld-nube: #eef4ff; /* brand-50: el mismo lavado del "hoy" en el calendario */
--ld-crema: #fff7ed; /* orange-50, ya presente en el panel */
--ld-muted: #64748b;
--ld-hairline: #eef0f4;
min-height: 100vh;
min-height: 100dvh;
overflow-x: clip;
font-family: Inter, system-ui, sans-serif;
color: var(--ld-tinta);
background: var(--ld-papel);
}
/* Lavados de sección. Los tres salen del panel, así que pasar de la landing al
producto no se siente como cambiar de sitio. */
.landing-papel {
background: var(--ld-papel);
}
.landing-nube {
background: var(--ld-nube);
}
.landing-crema {
background: var(--ld-lienzo);
}
/* Display: Fraunces con el eje WONK abierto. Es un serif suave y con carácter,
no una Didone de alto contraste: a 56px+ se lee cálido en vez de editorial-frío,
que es lo que este público necesita. */
.ld-display {
font-family: Fraunces, Georgia, "Times New Roman", serif;
font-variation-settings: "SOFT" 40, "WONK" 1;
font-size: clamp(2.5rem, 7.2vw, 5.5rem);
line-height: 1;
letter-spacing: -0.022em;
font-weight: 800;
}
.ld-title {
font-family: Fraunces, Georgia, "Times New Roman", serif;
font-variation-settings: "SOFT" 40, "WONK" 1;
font-size: clamp(1.875rem, 4.4vw, 3.15rem);
line-height: 1.08;
letter-spacing: -0.015em;
font-weight: 800;
}
/* Cuerpo en Inter, la misma tipografía del panel, a 17px mínimo y 1.6 de
interlínea: el público incluye personas de 50+ y la legibilidad pesa más que la
densidad. */
.ld-lead {
font-size: clamp(1.0625rem, 1.9vw, 1.3125rem);
line-height: 1.6;
font-weight: 500;
}
.ld-body {
font-size: 1.0625rem;
line-height: 1.65;
}
.ld-eyebrow {
font-size: 0.8125rem;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.ld-section {
padding-block: clamp(4.5rem, 10vh, 9rem);
padding-inline: max(1.25rem, var(--safe-left)) max(1.25rem, var(--safe-right));
}
.ld-wrap {
width: 100%;
max-width: 72rem;
margin-inline: auto;
}
/* Canaleta lateral que además descuenta el notch en landscape.
NO combines `.safe-x` con `px-*` en el mismo elemento: `.safe-x` vive fuera de
`@layer` y por tanto gana a las utilidades de Tailwind, así que deja el padding
en 0 siempre que el inset del sistema sea 0 — es decir, en todo lo que no sea un
iPhone en landscape. El resultado es contenido pegado al borde en móvil. Esta
clase resuelve las dos cosas en una sola declaración, como ya hace `.ld-section`. */
.ld-gutter {
padding-inline: max(1.25rem, var(--safe-left)) max(1.25rem, var(--safe-right));
}
/* Números que se interpolan frame a frame. Sin tabular-nums cada dígito cambia
de ancho y la línea entera relayoutea en cada frame. Inter porque Fraunces no
tiene cifras tabulares. */
.ld-num {
font-family: Inter, system-ui, sans-serif;
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum";
}
/* Botón principal: el mismo azul de `.btn-primary` del panel, con el degradado
brand-500 → brand-700 (los dos tonos que la app ya usa en normal y hover). El
naranja del logo no se usa como fondo de acción: en el favicon es el punto que
marca, no la superficie que trabaja, y en la app tampoco es un color de botón. */
.ld-cta {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
min-height: 56px;
padding-inline: 2rem;
border-radius: 9999px;
font-weight: 800;
font-size: 1rem;
color: #fff;
background: linear-gradient(100deg, var(--ld-azul), var(--ld-azul-hondo));
box-shadow: 0 12px 30px -10px rgba(59, 102, 255, 0.5);
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.2s;
}
.ld-cta:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 18px 40px -12px rgba(59, 102, 255, 0.58);
}
.ld-cta:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.ld-cta-ghost {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
min-height: 56px;
padding-inline: 1.75rem;
border-radius: 9999px;
font-weight: 700;
font-size: 1rem;
color: var(--ld-tinta);
background: rgba(255, 255, 255, 0.8);
border: 1.5px solid #e5e7eb;
transition: background 0.2s, border-color 0.2s;
}
.ld-cta-ghost:hover {
background: #fff;
border-color: #d1d5db;
}
/* Foco visible propio: el degradado del CTA tapa el outline por defecto. */
.landing a:focus-visible,
.landing button:focus-visible {
outline: 3px solid var(--ld-azul);
outline-offset: 3px;
}
/* Titular con degradado: naranja→ámbar. Es el papel que el naranja tiene en el
logo —el punto que marca el renglón importante— trasladado a la frase que carga
la promesa. El color sólido va primero como fallback: `color: transparent` sin
`background-clip: text` deja el texto invisible, y ese fallo se llevaría media
frase del hero. */
.ld-gradient-text {
color: var(--ld-naranja);
}
@supports ((-webkit-background-clip: text) or (background-clip: text)) {
.ld-gradient-text {
background-image: linear-gradient(100deg, var(--ld-naranja), var(--ld-ambar));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
}
}
/* ---- Anti auto-zoom de iOS (fuera de @layer para ganar a las utilidades) ----
Mobile Safari hace zoom al enfocar cualquier campo con font-size < 16px y no
revierte al desenfocar: la vista queda escalada y corrida. Ese es el origen
del "arranca con zoom y desplazada" al tocar el campo de correo en el login.
Se fuerzan 16px en todo puntero grueso (iPhone y iPad) en lugar de bloquear
el zoom con maximum-scale=1, que rompería el pinch-zoom de accesibilidad.
`select` incluido: iOS también zoomea al abrir un desplegable pequeño. */
@media (pointer: coarse) {
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="color"]),
select,
textarea,
.input,
.select,
.textarea {
font-size: 16px;
}
/* Objetivos táctiles cómodos (Apple recomienda 44 pt). */
.btn,
.input,
.select,
.textarea {
min-height: 44px;
}
/* Para controles compactos (chips de filtro, grupos segmentados) donde 44px
rompería la densidad de la barra. Se decide por tipo de puntero y no por
ancho de pantalla: un iPad se toca con el dedo igual que un iPhone, así que
un breakpoint `sm:` dejaría fuera justamente a las tablets. */
.tap-target {
min-height: 40px;
}
/* Botones de solo icono (editar, borrar, cerrar, menú): con p-1.5 miden 28px,
por debajo de lo tocable con el pulgar. Se marcan con `.icon-btn` en lugar de
detectarlos por selector: se intentó `button:has(> svg:only-child)` y es
engañoso, porque `:only-child` solo mira hijos ELEMENTO y un botón con
etiqueta ("<Plus/> Nueva cita") también encaja — el texto es un nodo de
texto. Esa regla, al fijar `display`, convirtió los NavLink del menú lateral
en inline-flex y los repartió en dos columnas. */
.icon-btn {
min-height: 40px;
min-width: 40px;
}
/* Se centra el icono, no se cambia el `display` del botón. El preflight de
Tailwind pone `svg { display: block }`, así que el icono ignora `text-align`
y `margin-inline: auto` es lo que lo centra en la caja ampliada. Tocar el
display aquí sería contraproducente: esta regla está fuera de @layer y
ganaría a las utilidades, de modo que un `hidden`/`lg:block` dejaría de
funcionar y el botón de colapsar aparecería en el móvil. */
.icon-btn > svg {
margin-inline: auto;
}
}
.label {
display: block;