Neither is visible against localhost, which is why both shipped. **Install button never appeared on `/login`** — a regression from making the login a lazy route. `beforeinstallprompt` fires once per page load and is never replayed; `useInstallPrompt` attached its listener from a `useEffect`, i.e. at mount, and `InstallAppPrompt` now mounts only after an extra round-trip for its chunk. Locally that round-trip is a millisecond so the listener still won a race it should never have been in. Over a real connection the event was long gone, so a user on a slow link lost the install button entirely. The listener now lives in `src/lib/installPrompt.ts` and registers when the module evaluates — `main.tsx` imports it for its side effect before mounting React. The hook only reads from that store. Reproduced deterministically by delaying `/assets/LoginPage-*.js` by 1.5s via `route()`: absent before, present after (also at a 4s delay). `test:pwa` against the HTTPS domain now passes. **Revenue chart did not animate on a real iPhone.** Measured rather than guessed: the path animation does run in WebKit, and it triggers with the chart 100% visible at y=601..715 of an 844px viewport — so neither "broken" nor "fires too early". What fits is that iOS Safari suspends `requestAnimationFrame` during momentum scrolling while framer-motion interpolates against wall-clock time: the animation spends its 1.4s without painting a frame and snaps to the end on resume, which looks exactly like it never ran. The chart's three animations move to CSS keyframes, which keep their own timeline in the engine. The component only decides *when* (a `useInView` setting `data-ld-rev-visible`). `prefers-reduced-motion` resolves in CSS too, and still resolves to the *drawn* state — a line left at `dashoffset: 1px` with no animation would be invisible forever. Verified: `getAnimations()` returns a `CSSAnimation`, and under reduced motion the line renders complete. Not verified: no physical iPhone here, and Playwright WebKit on Windows does not reproduce iOS's rAF suspension. This is the standard mitigation and changes nothing on desktop, but on-device confirmation is still outstanding. Verified: typecheck clean; landing 13/13, PWA passed, responsive 0 findings, visual 62 screens / 0 errors, unit 39/39, e2e 33/33, admin 17/17, booking 12/12. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
774 lines
22 KiB
CSS
774 lines
22 KiB
CSS
@tailwind base;
|
|
@tailwind components;
|
|
@tailwind utilities;
|
|
|
|
: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;
|
|
background: #f6f7fb;
|
|
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;
|
|
}
|
|
|
|
/* Evita el zoom por doble toque y el retardo de 300 ms en controles. */
|
|
button,
|
|
a,
|
|
[role="button"],
|
|
label,
|
|
summary {
|
|
touch-action: manipulation;
|
|
}
|
|
|
|
/* 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 */
|
|
.fc {
|
|
--fc-border-color: #eef0f4;
|
|
--fc-page-bg-color: #ffffff;
|
|
--fc-neutral-bg-color: #fafbfd;
|
|
--fc-today-bg-color: #eef4ff;
|
|
--fc-now-indicator-color: #f17616;
|
|
--fc-button-bg-color: #ffffff;
|
|
--fc-button-border-color: #e5e7eb;
|
|
--fc-button-text-color: #374151;
|
|
--fc-button-hover-bg-color: #f9fafb;
|
|
--fc-button-hover-border-color: #d1d5db;
|
|
--fc-button-active-bg-color: #3b66ff;
|
|
--fc-button-active-border-color: #3b66ff;
|
|
font-family: Inter, system-ui, sans-serif;
|
|
}
|
|
.fc .fc-toolbar-title {
|
|
font-size: 1.15rem;
|
|
font-weight: 700;
|
|
color: #0f172a;
|
|
}
|
|
.fc .fc-toolbar.fc-header-toolbar {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 10;
|
|
background: #ffffff;
|
|
padding: 0.4rem 0.6rem;
|
|
margin: -0.4rem -0.6rem 0;
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem 0.75rem;
|
|
}
|
|
.fc .fc-toolbar-chunk {
|
|
display: flex;
|
|
}
|
|
.fc .fc-toolbar-chunk .fc-button-group {
|
|
flex-wrap: wrap;
|
|
}
|
|
@media (max-width: 640px) {
|
|
.fc .fc-toolbar-title {
|
|
font-size: 1rem;
|
|
width: 100%;
|
|
text-align: left;
|
|
order: -1;
|
|
}
|
|
.fc .fc-toolbar-chunk {
|
|
flex: 1 1 auto;
|
|
}
|
|
.fc .fc-toolbar-chunk:last-child {
|
|
margin-left: auto;
|
|
}
|
|
.fc .fc-button {
|
|
padding: 0.35rem 0.55rem;
|
|
font-size: 0.72rem;
|
|
}
|
|
}
|
|
.fc .fc-button {
|
|
border-radius: 0.6rem;
|
|
padding: 0.4rem 0.75rem;
|
|
font-weight: 600;
|
|
font-size: 0.8rem;
|
|
text-transform: capitalize;
|
|
box-shadow: none !important;
|
|
}
|
|
.fc .fc-button:not(:disabled):not(.fc-button-active):hover {
|
|
background: #f3f4f6;
|
|
}
|
|
.fc .fc-button-primary:not(:disabled).fc-button-active {
|
|
color: #ffffff;
|
|
background: var(--fc-button-active-bg-color, #3b66ff);
|
|
border-color: var(--fc-button-active-border-color, #3b66ff);
|
|
}
|
|
.fc .fc-button-primary:not(:disabled).fc-button-active:hover {
|
|
background: #2447f5;
|
|
border-color: #2447f5;
|
|
color: #ffffff;
|
|
}
|
|
.fc .fc-button:disabled {
|
|
opacity: 0.5;
|
|
}
|
|
.fc .fc-col-header-cell-cushion {
|
|
padding: 0.6rem 0;
|
|
font-weight: 600;
|
|
font-size: 0.72rem;
|
|
letter-spacing: 0.05em;
|
|
text-transform: uppercase;
|
|
color: #6b7280;
|
|
}
|
|
.fc .fc-daygrid-day-number,
|
|
.fc .fc-col-header-cell-cushion {
|
|
text-decoration: none;
|
|
}
|
|
.fc .fc-daygrid-day {
|
|
transition: background 0.12s;
|
|
}
|
|
.fc .fc-daygrid-day:hover {
|
|
background: #fafbfd;
|
|
}
|
|
.fc-event {
|
|
border: none !important;
|
|
border-radius: 0.45rem !important;
|
|
padding: 2px 5px !important;
|
|
font-size: 0.74rem !important;
|
|
font-weight: 600 !important;
|
|
cursor: pointer;
|
|
transition: transform 0.12s, box-shadow 0.12s;
|
|
overflow: hidden;
|
|
}
|
|
.fc-event:hover {
|
|
transform: translateY(-1px);
|
|
box-shadow: 0 4px 12px -4px rgba(0, 0, 0, 0.25);
|
|
}
|
|
.fc-daygrid-event {
|
|
margin-top: 2px;
|
|
}
|
|
.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);
|
|
}
|
|
.fc-timegrid-event-harness {
|
|
margin-right: 2px !important;
|
|
}
|
|
/* When slotEventOverlap is false, FullCalendar puts a small gap between
|
|
side-by-side events; tighten it a touch so each event gets more readable
|
|
width without bleeding into its neighbour. */
|
|
.fc .fc-timegrid-col-events {
|
|
column-gap: 2px;
|
|
}
|
|
|
|
/* List view (great on mobile) */
|
|
.fc .fc-list {
|
|
border: none;
|
|
}
|
|
.fc .fc-list-day-cushion {
|
|
background: #fafbfd;
|
|
font-weight: 700;
|
|
color: #0f172a;
|
|
padding: 0.6rem 0.9rem;
|
|
}
|
|
.fc .fc-list-event-time {
|
|
font-weight: 600;
|
|
color: #4b5563;
|
|
font-size: 0.78rem;
|
|
}
|
|
.fc .fc-list-event-title {
|
|
font-weight: 600;
|
|
color: #0f172a;
|
|
}
|
|
.fc .fc-list-event:hover td {
|
|
background: #f9fafb;
|
|
}
|
|
.fc .fc-list-empty {
|
|
background: transparent;
|
|
color: #94a3b8;
|
|
padding: 2.5rem 1rem;
|
|
font-weight: 500;
|
|
}
|
|
|
|
/* 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: 560px;
|
|
}
|
|
/* Objetivos táctiles más grandes para los eventos */
|
|
.fc-event {
|
|
min-height: 30px;
|
|
padding: 3px 6px !important;
|
|
}
|
|
.fc .fc-button {
|
|
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;
|
|
}
|
|
}
|
|
|
|
/* Utility classes */
|
|
.card {
|
|
background: #ffffff;
|
|
border: 1px solid #eef0f4;
|
|
border-radius: 1rem;
|
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
|
}
|
|
|
|
.btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0.4rem;
|
|
font-weight: 600;
|
|
border-radius: 0.65rem;
|
|
padding: 0.55rem 0.95rem;
|
|
font-size: 0.875rem;
|
|
transition: all 0.15s;
|
|
cursor: pointer;
|
|
border: 1px solid transparent;
|
|
white-space: nowrap;
|
|
}
|
|
.btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
.btn-primary {
|
|
background: #3b66ff;
|
|
color: #ffffff;
|
|
}
|
|
.btn-primary:hover:not(:disabled) {
|
|
background: #2447f5;
|
|
}
|
|
.btn-secondary {
|
|
background: #ffffff;
|
|
color: #374151;
|
|
border-color: #e5e7eb;
|
|
}
|
|
.btn-secondary:hover:not(:disabled) {
|
|
background: #f9fafb;
|
|
border-color: #d1d5db;
|
|
}
|
|
.btn-ghost {
|
|
background: transparent;
|
|
color: #4b5563;
|
|
}
|
|
.btn-ghost:hover:not(:disabled) {
|
|
background: #f3f4f6;
|
|
}
|
|
.btn-danger {
|
|
background: #fee2e2;
|
|
color: #b91c1c;
|
|
}
|
|
.btn-danger:hover:not(:disabled) {
|
|
background: #fecaca;
|
|
}
|
|
/* ---- Safe areas y alturas de viewport ---- */
|
|
|
|
.safe-area-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 {
|
|
.input,
|
|
.select,
|
|
.textarea {
|
|
width: 100%;
|
|
background: #ffffff;
|
|
border: 1px solid #e5e7eb;
|
|
border-radius: 0.65rem;
|
|
padding: 0.55rem 0.75rem;
|
|
font-size: 0.875rem;
|
|
color: #0f172a;
|
|
transition: border-color 0.15s, box-shadow 0.15s;
|
|
outline: none;
|
|
}
|
|
.input:focus,
|
|
.select:focus,
|
|
.textarea:focus {
|
|
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;
|
|
}
|
|
}
|
|
|
|
/* ---- Gráfica de ingresos de la landing ----
|
|
Se anima con CSS y NO con framer-motion a propósito. iOS Safari suspende
|
|
`requestAnimationFrame` mientras dura el scroll por inercia, y framer-motion
|
|
interpola contra el reloj de pared: la animación consume su duración sin pintar un
|
|
solo fotograma y al reanudarse salta al estado final. En un iPhone se ve exactamente
|
|
como si nunca hubiera animado, que es el defecto que se reportó. Una animación CSS
|
|
lleva su propia línea de tiempo y sí se dibuja.
|
|
El easing es el mismo `EASE_EXPO` de src/lib/motion.ts; si cambia uno, cambia el otro. */
|
|
.ld-rev-linea {
|
|
stroke-dasharray: 1px 1px; /* con pathLength="1" las unidades son la longitud total */
|
|
stroke-dashoffset: 1px;
|
|
}
|
|
.ld-rev-area {
|
|
opacity: 0;
|
|
}
|
|
.ld-rev-punto {
|
|
/* `fill-box` para que el centro del círculo sea el origen y no el del lienzo SVG. */
|
|
transform-box: fill-box;
|
|
transform-origin: center;
|
|
transform: scale(0);
|
|
}
|
|
[data-ld-rev-visible="true"] .ld-rev-linea {
|
|
animation: ld-rev-trazo 1.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
|
}
|
|
[data-ld-rev-visible="true"] .ld-rev-area {
|
|
animation: ld-rev-aparecer 0.9s cubic-bezier(0.16, 1, 0.3, 1) 0.5s forwards;
|
|
}
|
|
[data-ld-rev-visible="true"] .ld-rev-punto {
|
|
animation: ld-rev-aterrizar 0.5s cubic-bezier(0.16, 1, 0.3, 1) 1.3s forwards;
|
|
}
|
|
}
|
|
|
|
@keyframes ld-rev-trazo {
|
|
from {
|
|
stroke-dashoffset: 1px;
|
|
}
|
|
to {
|
|
stroke-dashoffset: 0px;
|
|
}
|
|
}
|
|
@keyframes ld-rev-aparecer {
|
|
from {
|
|
opacity: 0;
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
}
|
|
}
|
|
@keyframes ld-rev-aterrizar {
|
|
from {
|
|
transform: scale(0);
|
|
}
|
|
to {
|
|
transform: scale(1);
|
|
}
|
|
}
|
|
|
|
/* Con movimiento reducido la gráfica sale ya dibujada. Igual que el resto de la landing,
|
|
el estado inicial resuelve al FINAL y no se acorta la transición: si dejara la línea en
|
|
`dashoffset: 1px` sin animación, quedaría invisible para siempre. */
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.ld-rev-linea,
|
|
[data-ld-rev-visible="true"] .ld-rev-linea {
|
|
stroke-dashoffset: 0px;
|
|
animation: none;
|
|
}
|
|
.ld-rev-area,
|
|
[data-ld-rev-visible="true"] .ld-rev-area {
|
|
opacity: 1;
|
|
animation: none;
|
|
}
|
|
.ld-rev-punto,
|
|
[data-ld-rev-visible="true"] .ld-rev-punto {
|
|
transform: scale(1);
|
|
animation: none;
|
|
}
|
|
}
|
|
|
|
/* ---- 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;
|
|
font-size: 0.78rem;
|
|
font-weight: 600;
|
|
color: #4b5563;
|
|
margin-bottom: 0.3rem;
|
|
}
|
|
|
|
.chip {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.25rem;
|
|
padding: 0.15rem 0.55rem;
|
|
border-radius: 9999px;
|
|
font-size: 0.72rem;
|
|
font-weight: 600;
|
|
}
|