diff --git a/DESIGN.md b/DESIGN.md index 8c7d169..609e94e 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,228 +1,189 @@ -# Ease Health — Style Reference -> Botanical greenhouse on cream paper +# UABC — Style Reference +> Identidad editorial institucional en clave monoespaciada -**Theme:** light +**Theme:** mixed -Ease Health uses a clinical-botanical language: a warm cream canvas layered with soft sage, keylime, and slate-blue panels that feel like a greenhouse rendered on paper. Typography carries the entire personality — weight 300 serif headlines (Faire Octave) paired against a humanist sans (Suisse Intl) create a hushed, editorial authority unusual for healthtech. A single deep forest green (#0f3e17) anchors every action and heading, reading like ink on watercolor. Surfaces are flat and shadowless; depth comes from layered tinted panels and generous padding, not elevation. Components are rounded (~14px) and pill-shaped for tags, keeping the whole system soft and approachable without losing clinical seriousness. +El sistema conserva la estructura editorial del original —bandas oscuras documentales alternadas con secciones claras de lectura— pero reemplaza por completo la paleta cromática por la colorimetría institucional de la UABC (verde principal, dorados y acentos complementarios) y unifica toda la tipografía en una sola familia monoespaciada, Azeret Mono, que trata la página como una hoja de especificación técnica: encabezados, cuerpo y controles de UI comparten la misma fuente y se diferencian solo por peso y tamaño. El Verde Institucional (#00723F) actúa como el único interruptor de acción —CTAs, enlaces, estados activos— mientras el resto de la paleta (verde claro, dorado, amarillo, verde jardín, azul) aporta soporte cromático secundario sin competir con el verde principal. Los componentes siguen siendo deliberadamente delgados: pills de radio completo, nav flotante translúcido, bordes de 1px, casi sin sombra — la interfaz se retira para que el contenido institucional lleve el peso visual. ## Tokens — Colors | Name | Value | Token | Role | |------|-------|-------|------| -| Forest Ink | `#0f3e17` | `--color-forest-ink` | Primary action buttons, headings, links, icon strokes — deep botanical green reads as ink, the only saturated dark in the system | -| Sage Mist | `#b1dbb8` | `--color-sage-mist` | Mid-tone card backgrounds and decorative washes, panel-level surfaces | -| Keylime Wash | `#e1f4df` | `--color-keylime-wash` | Hero panel background, light card surfaces — the dominant tinted canvas tone | -| Mint Veil | `#cfe7d3` | `--color-mint-veil` | Accent card surfaces, soft section dividers between sage and keylime layers | -| Slate Hush | `#b6ced5` | `--color-slate-hush` | Cool counter-balance panel for product preview and feature blocks — breaks the green monotony with desaturated blue-gray | -| Cream Paper | `#fffefc` | `--color-cream-paper` | Page background, badge backgrounds, button text — warm off-white that softens every green | -| Charcoal | `#222222` | `--color-charcoal` | Navigation text and minor UI strokes — restrained secondary ink | -| Border Mist | `#efeeeb` | `--color-border-mist` | Hairline dividers and subtle borders throughout the layout | -| Forest Shadow | `#0c2f10` | `--color-forest-shadow` | Supporting palette color for small decorative accents when the core palette needs contrast. Do not promote it to the primary CTA color | +| Verde Institucional | `#00723F` | `--color-verde-institucional` | Color institucional principal (Pantone 356) — CTAs primarios, nav activo, íconos, acentos de enlace; el único color con función de "interruptor" en la UI | +| Verde Claro | `#50B848` | `--color-verde-claro` | Estados hover, contornos de acento suaves, eco desaturado del Verde Institucional (Pantone 369) | +| Dorado UABC | `#DD971A` | `--color-dorado-uabc` | Acento cálido secundario — bordes de tags, highlights, contraste cálido frente al verde (Pantone 131) | +| Amarillo Dorado | `#FEBE10` | `--color-amarillo-dorado` | Badges y estados destacados sobre fondo oscuro (Pantone 124) | +| Verde Jardín | `#024731` | `--color-verde-jardin` | Superficie oscura alterna, bordes fuertes sobre fondo oscuro (Pantone 3435) | +| Dorado Oscuro | `#B08208` | `--color-dorado-oscuro` | Fondo translúcido de nav, bordes de fuerza media, superficies UI mudas (Pantone 132) | +| Azul Institucional | `#20419A` | `--color-azul-institucional` | Acento informativo/enlaces alternos — distingue contenido secundario de las acciones primarias en verde (Pantone Reflex Blue) | +| Bone White | `#fffefb` | `--color-bone-white` | Lienzo de página en secciones claras, color de texto en secciones oscuras | +| Linen | `#f3f2ee` | `--color-linen` | Superficie secundaria cálida, fondo de tarjetas elevadas | +| Hairline Gray | `#e5e7eb` | `--color-hairline-gray` | Bordes finos en tarjetas, imágenes, divisores y campos de formulario | +| Graphite | `#333833` | `--color-graphite` | Texto principal y encabezados sobre superficies claras | +| Negro Institucional | `#000000` | `--color-negro-institucional` | Lienzos de sección oscura (Pantone Process Black), texto de máximo contraste, scrims sobre imagen | ## Tokens — Typography -### Faire Octave — Display serif for all section headings and hero copy · `--font-faire-octave` -- **Substitute:** Cormorant Garamond, Playfair Display -- **Weights:** 300 -- **Sizes:** 40px, 56px, 74px -- **Line height:** 1.05–1.35 -- **Letter spacing:** -0.01em at 40px, -0.03em at 56–74px -- **Role:** Display serif for all section headings and hero copy - -### Suisse Intl — Body, navigation, UI labels, buttons, captions — humanist sans at whisper weight for feature lists and normal weight for copy · `--font-suisse-intl` -- **Substitute:** Inter, Söhne, Helvetica Neue -- **Weights:** 300, 400, 600 -- **Sizes:** 10px, 11px, 12px, 14px, 18px, 23px, 28px, 56px -- **Line height:** 1.00–1.61 -- **Letter spacing:** 0.08em uppercase at 11px/600 (eyebrow labels); natural tracking otherwise -- **Role:** Body, navigation, UI labels, buttons, captions — humanist sans at whisper weight for feature lists and normal weight for copy +### Azeret Mono — Fuente única del sistema: encabezados, cuerpo y controles de UI. Al ser monoespaciada en todos los roles, la jerarquía se construye solo con peso y tamaño, reforzando la lectura de "hoja de especificación técnica" en toda la interfaz. · `--font-azeret-mono` +- **Fuente:** Fontsource (`@fontsource/azeret-mono` / fontsource.org/fonts/azeret-mono) +- **Substitute:** JetBrains Mono, IBM Plex Mono, o monoespacio del sistema +- **Weights:** 300 (encabezados y cuerpo), 500 (nav, botones, controles de UI) +- **Sizes:** 10px, 12px, 14px, 16px, 24px, 32px, 72px +- **Line height:** 1.10–1.83 según rol +- **Letter spacing:** -1.44px a 72px, -0.64px a 32px, -0.48px a 24px, -0.32px a 16px (cuerpo), +0.5px a 10px, +0.6px a 12px, +0.7px a 14px (micro/caption) +- **Role — Display/Heading:** peso 300, tamaños 24/32/72px. El peso liviano en tamaños grandes es no negociable — mantiene la autoridad editorial por contención en vez de volumen. +- **Role — Body:** peso 300, tamaños 14–16px, line-height 1.57–1.83. Trata el párrafo como salida de terminal, no como folleto de marketing. +- **Role — UI/Nav/Buttons:** peso 500, tamaños 12–16px. El salto de peso (300 → 500) es la única señal que distingue controles interactivos del texto editorial, ya que comparten la misma fuente. ### Type Scale -| Role | Size | Line Height | Letter Spacing | Token | -|------|------|-------------|----------------|-------| -| caption | 10px | 1.61 | — | `--text-caption` | -| body | 14px | 1.5 | — | `--text-body` | -| subheading | 18px | 1.3 | — | `--text-subheading` | -| heading-sm | 23px | 1.5 | — | `--text-heading-sm` | -| heading | 40px | 1.35 | -0.4px | `--text-heading` | -| heading-lg | 56px | 1.2 | -1.68px | `--text-heading-lg` | -| display | 74px | 1.05 | -2.22px | `--text-display` | +| Role | Size | Line Height | Letter Spacing | Weight | Token | +|------|------|-------------|----------------|--------|-------| +| micro | 10px | 1.6 | 0.5px | 300 | `--text-micro` | +| caption | 12px | 1.63 | 0.6px | 300 | `--text-caption` | +| body-sm | 14px | 1.63 | 0.7px | 300 | `--text-body-sm` | +| body | 16px | 1.57 | -0.32px | 300 | `--text-body` | +| ui | 16px | 1.83 | -0.017em | 500 | `--text-ui` | +| heading | 24px | 1.3 | -0.48px | 300 | `--text-heading` | +| heading-lg | 32px | 1.3 | -0.64px | 300 | `--text-heading-lg` | +| display | 72px | 1.1 | -1.44px | 300 | `--text-display` | ## Tokens — Spacing & Shapes +**Base unit:** 8px + **Density:** comfortable ### Spacing Scale | Name | Value | Token | |------|-------|-------| -| 4 | 4px | `--spacing-4` | -| 7 | 7px | `--spacing-7` | -| 9 | 9px | `--spacing-9` | -| 11 | 11px | `--spacing-11` | -| 14 | 14px | `--spacing-14` | -| 18 | 18px | `--spacing-18` | -| 21 | 21px | `--spacing-21` | -| 28 | 28px | `--spacing-28` | -| 35 | 35px | `--spacing-35` | -| 42 | 42px | `--spacing-42` | -| 49 | 49px | `--spacing-49` | -| 56 | 56px | `--spacing-56` | -| 70 | 70px | `--spacing-70` | -| 76 | 76px | `--spacing-76` | -| 99 | 99px | `--spacing-99` | -| 156 | 156px | `--spacing-156` | +| 8 | 8px | `--spacing-8` | +| 16 | 16px | `--spacing-16` | +| 24 | 24px | `--spacing-24` | +| 32 | 32px | `--spacing-32` | +| 64 | 64px | `--spacing-64` | +| 120 | 120px | `--spacing-120` | +| 128 | 128px | `--spacing-128` | +| 152 | 152px | `--spacing-152` | ### Border Radius | Element | Value | |---------|-------| -| nav | 7px | -| cards | 14px | -| badges | 999px | -| buttons | 14px | -| sections | 9999px | +| nav | 30px | +| tags | 8px | +| cards | 12px | +| buttons | 9999px | ### Layout - **Page max-width:** 1200px -- **Section gap:** 64-96px -- **Card padding:** 28-42px -- **Element gap:** 14-21px +- **Section gap:** 64px +- **Card padding:** 24px +- **Element gap:** 8px ## Components -### Primary Forest Button -**Role:** Filled CTA for demos, explores, and form submits +### Verde Pill CTA +**Role:** Botón de llamada a la acción primaria -Background #0f3e17, text #fffefc, 14px border-radius, padding 14px 21px, Suisse Intl 14px/400. Arrow icon in white at trailing edge. Hover transitions to #0c2f10. +Relleno con #00723F, texto blanco en Azeret Mono peso 500, 16px, 9999px de border-radius, 10px de padding vertical / 24px horizontal. Etiqueta de una sola línea. Sin sombra. La forma de pill es el único elemento con curvatura completa — hace que el CTA sea inconfundible como el interruptor de acción de la página. -### Large Forest Button -**Role:** Hero-scale CTA with generous breathing room +### Nav Flotante Translúcido +**Role:** Navegación superior de página -Same Forest Ink fill and white text, but padding scales to 42px all sides for hero placement. Rounded 14px corners. Used sparingly for maximum-weight calls to action. +Contenedor tipo pill a 30px de border-radius, Dorado Oscuro (#B08208) a baja opacidad sobre la página, conteniendo los enlaces en Azeret Mono peso 500, 12px. -### Pill Badge / Tag -**Role:** Category labels, CRM/EHR/RCM module tags, status indicators +### Mono Display Heading +**Role:** Encabezados de hero y de sección -Background #fffefc, text Forest Ink #0f3e17, 999px border-radius (full pill), padding 9px 14px, Suisse Intl 14px/400. Inverted cream-on-green relationship makes tags feel like paper labels. +Azeret Mono peso 300, 72px en hero, 32–24px en títulos de sección. Line-height 1.10–1.30. Letter-spacing según escala. Color Bone White (#fffefb) sobre secciones oscuras, Graphite (#333833) sobre secciones claras. El peso liviano es no negociable — engrosarlo rompe el efecto editorial. -### Sage Feature Card -**Role:** Heavier-tinted content panel for feature highlights +### Mono Body Block +**Role:** Copy de párrafo en secciones de contenido -Background Sage Mist #b1dbb8, 14px border-radius, 42px padding all sides, no shadow. Hosts illustration or product preview content. Flat surface — depth comes from color not elevation. +Azeret Mono peso 300, 14–16px, line-height 1.63–1.83, letter-spacing -0.32px a 16px. Color Graphite (#333833) sobre superficies claras, Bone White (#fffefb) sobre oscuras. Max-width ~480px para preservar la medida de "lectura de terminal". -### Keylime Soft Card -**Role:** Light-tinted content panel for secondary features +### Two-Column Editorial Section +**Role:** Layout de contenido: encabezado izquierda, cuerpo derecha -Background Keylime Wash #e1f4df, 14px border-radius, 28px padding, no shadow. Tighter padding than sage variant for compact content blocks. +Grid lado a lado con 48–64px de gap. Columna izquierda contiene el Mono Display Heading a 32px. Columna derecha contiene el Mono Body Block a 14–16px. Ambas alineadas arriba. Fondo: Bone White o Linen. Padding de sección: 64px horizontal, 64px vertical. -### Slate Product Panel -**Role:** Cool-toned container for product UI screenshots and demos +### Full-Bleed Documentary Image +**Role:** Banda de imagen atmosférica entre secciones de contenido -Background Slate Hush #b6ced5, 14px border-radius, 42px padding, no shadow. Provides cool visual counterpoint to warm green panels — the only non-green tinted surface. +Fotografía a sangre completa sobre fondo Negro Institucional (#000000). Scrim oscuro opcional (rgba(0,0,0,0.3–0.5)) para contraste de texto. Texto superpuesto en Bone White, Azeret Mono peso 300. Las imágenes son documentales/candid, no de stock pulido. -### Product Preview Card (Inner) -**Role:** White floating card within Slate Product Panel +### Vertical Sidebar Label +**Role:** Indicador de sección en el margen de la página -Background Cream Paper #fffefc, 14px border-radius, 28px padding, no border or shadow. Represents a product screen mockup (CRM, EHR, RCM). Sits on the Slate panel like a printed screenshot. +Texto rotado 90° en el margen izquierdo, posicionado 24–49px del borde. Azeret Mono 10–12px, letter-spacing 0.05em, mayúsculas o tal cual. Color: Graphite sobre secciones claras, Hairline Gray (#e5e7eb) o Bone White sobre oscuras. Funciona como marcador de sección, no como navegación. -### Navigation Bar -**Role:** Top-level site navigation +### Ghost Text Link +**Role:** Enlaces en línea dentro del cuerpo de texto -Transparent over cream canvas, text Charcoal #222222, Suisse Intl 14px/400, 7px radius on interactive items. 'Book a Demo' CTA pill on the right uses Forest Ink fill. +Sin fondo, subrayado de 1px en #00723F o Graphite según contexto. Azeret Mono a 14–16px. El hover aumenta opacidad o cambia a Verde Claro (#50B848). -### Eyebrow Label -**Role:** Section category tag above headings +### Hairline Divider +**Role:** Separador de sección -Suisse Intl 11px/600, letter-spacing 0.08em, uppercase, Forest Ink color. Tiny typographic marker that labels each section's theme (e.g. 'QUICK ANSWER'). +1px sólido en #e5e7eb o #B08208, a todo el ancho o contenido. Sin ornamentación, sin degradado. La división entre secciones se implica por espaciado, no por líneas visuales — esto es un respaldo. -### FAQ Row -**Role:** Two-column question/answer layout +### Image Frame Card +**Role:** Tarjeta de contenido con imagen -Left column: Faire Octave 40px/300 in Forest Ink for question. Right column: Suisse Intl 14px/400 in Charcoal for answer. 1px Border Mist divider between rows. +Borde 1px sólido #e5e7eb, 12px de border-radius, interior Linen (#f3f2ee), 24px de padding. Imagen arriba con borde 1px en #e5e7eb. Texto de cuerpo en Azeret Mono 14px debajo. Elevación mínima — sin drop shadow. -### Investor Logo Strip -**Role:** Social proof row of VC logos +### Compact Pill Tag +**Role:** Badge de categoría o estado -Single horizontal row, monochrome treatment in Charcoal #222222 on cream canvas, generous vertical padding (42px+). No decorative borders — relies on whitespace for rhythm. +8px de border-radius (pill sutil, no curvatura completa), 4–8px de padding vertical / 8–12px horizontal, Azeret Mono peso 500 a 12px. Borde 1px en #e5e7eb o #DD971A. Fondo: Bone White o transparente. Color: Graphite, Verde Institucional o Dorado UABC. ## Do's and Don'ts ### Do -- Use Forest Ink (#0f3e17) for every CTA, heading, and primary link — it is the only saturated dark and must remain singular -- Set all display headings in Faire Octave weight 300 with tight tracking (-0.03em at 56–74px) — never bold the serif -- Pair serif questions with sans-serif answers in FAQ and feature blocks to create typographic contrast -- Layer surfaces using cream → keylime → mint → sage → slate in order to create depth without shadows -- Use 14px border-radius on all cards and buttons, 999px on all badges and tags — consistency is non-negotiable -- Apply 0.08em letter-spacing with uppercase and 600 weight for eyebrow labels only — nowhere else -- Keep the Slate Hush panel as the exclusive home for product screenshots — it provides the cool counterbalance to warm green panels +- Configura todos los CTAs primarios a 9999px de border-radius con relleno #00723F y texto Bone White en Azeret Mono peso 500, 16px. +- Usa Azeret Mono peso 300 para todo encabezado a 24px, 32px y 72px — nunca lo pongas en negrita. +- Escribe el copy de cuerpo en Azeret Mono peso 300 a 14–16px con line-height 1.63–1.83. +- Separa secciones con 64px de padding vertical, no con divisores ni solo cambios de color de fondo. +- Aplica bordes 1px sólidos #e5e7eb a todas las tarjetas e imágenes — nunca uses drop shadows para elevación. +- Usa #fffefb para lienzos claros y #000000 para bandas oscuras; deja que la fotografía llene las bandas oscuras a sangre completa. +- Reserva el Verde Institucional (#00723F) como el único color con función de acción primaria — el resto de la paleta (verde claro, dorado, amarillo, azul) es soporte cromático secundario, nunca compite con él en un mismo CTA. ### Don't -- Never add box-shadow to cards — the system communicates elevation through tinted layers only -- Don't use Forest Ink as a background tint — it is too dark for surface washes; reserve for text and action fills only -- Avoid mixing multiple heading weights — everything is weight 300 in Faire Octave; bold would break the hushed editorial tone -- Don't place colored borders on cards — depth comes from fill contrast, not strokes -- Never use more than one serif at a time — Faire Octave owns display, Suisse Intl owns everything else -- Don't introduce new accent hues — the palette is strictly botanical greens + slate; any other color breaks the greenhouse metaphor -- Avoid tight padding under 21px on cards — generous 28–42px padding is essential to the soft, breathing-room aesthetic -- Don't use the sage or keylime tones for text — they are surface colors only and fail contrast for copy +- No uses pesos negrita (600–700) en encabezados Azeret Mono — el peso 300 es la firma del sistema. +- No introduzcas drop shadows para elevar tarjetas; usa el color de superficie Linen (#f3f2ee) y bordes hairline en su lugar. +- No mezcles otra familia tipográfica proporcional o serif en ningún rol — la unificación en una sola mono es la decisión que define el sistema. +- No apliques el Verde Institucional a fondos, bandas de sección o elementos decorativos — es solo de acción. +- No uses valores de radio fuera de los cuatro tokens: 8px (tags), 12px (cards), 30px (nav), 9999px (buttons). +- No rompas el ritmo de lienzo de dos tonos — no insertes un tercer color de fondo de sección (además de Bone White y Negro Institucional). +- No uses fotografía de stock, ilustraciones o renders 3D — la imaginería debe ser documental y candid. ## Surfaces | Level | Name | Value | Purpose | |-------|------|-------|---------| -| 0 | Cream Paper | `#fffefc` | Base page background — warm off-white dominates the canvas | -| 1 | Keylime Wash | `#e1f4df` | Primary tinted panel — hero left column, light feature blocks | -| 2 | Mint Veil | `#cfe7d3` | Mid-tone section surface between keylime and sage | -| 3 | Sage Mist | `#b1dbb8` | Heavier tinted panels — hero accents, feature highlights | -| 4 | Slate Hush | `#b6ced5` | Cool counter-panel for product showcases and visual contrast | +| 1 | Bone Canvas | `#fffefb` | Secciones claras de lectura, fondo de página por defecto | +| 2 | Linen Card | `#f3f2ee` | Bloques de contenido elevados, paneles secundarios | +| 3 | Dorado Nav | `#B08208` | Superficie de navegación flotante translúcida | +| 4 | Negro Institucional | `#000000` | Secciones documentales oscuras, lienzos de banda de imagen | ## Elevation -The system deliberately avoids shadows entirely — boxShadow=none on all card variants. Depth and hierarchy are communicated through layered tinted surfaces (cream → keylime → sage → slate) rather than elevation. This keeps the interface feeling like watercolor paper rather than glassmorphic UI, matching the botanical-greenhouse metaphor. +El sistema evita deliberadamente los drop shadows. La elevación se expresa mediante cambios de color de superficie (Bone White → Linen) y bordes hairline (#e5e7eb a 1px). El único valor cercano a sombra es un box-shadow tenue teñido con #f3f2ee en botones — apenas perceptible. La interfaz debe sentirse impresa, no flotando. ## Imagery -Imagery is product-first and UI-native: floating product screenshots (CRM cards, EHR patient views, RCM revenue graphs) rendered as crisp white cards on Slate Hush panels. No lifestyle photography, no stock imagery. Iconography is minimal stroke-based, rendered in Forest Ink. Illustrations, where present, use the same sage/keylime palette to stay on-system. The visual density is text-dominant with product mockups serving as proof, not decoration. +Fotografía documental y candid: espacios universitarios, actividad académica real en lugar de stock corporativo escenificado. Las imágenes corren a sangre completa como bandas de sección, a menudo con un scrim oscuro para superposición de texto. Llevan un grado de color apagado y ligeramente desaturado que combina con la paleta institucional. Sin ilustraciones, sin renders 3D, sin capturas de producto — la fotografía ES el contenido visual. La iconografía es mínima: íconos de línea de peso mono en Graphite o Verde Institucional, nunca decorativos. ## Layout -Max-width 1200px centered container with 64–96px vertical section gaps. Hero is a two-column split: left panel tinted Keylime Wash with serif headline + body + CTA; right panel tinted Slate Hush housing three product preview cards (CRM, EHR, RCM) in a row. Below the hero, sections alternate between cream canvas and tinted panels in a botanical rhythm. FAQ uses a two-column text grid (serif question left, sans answer right). The investor logo strip is a single full-width row on cream. Navigation is minimal — left-aligned logo, centered text links, right-aligned CTA pill — no sticky behavior or mega-menu. The overall page rhythm is generous and editorial rather than dense. +La página alterna entre dos estados de lienzo: bandas documentales oscuras a sangre completa (Negro Institucional #000000 con foto) y secciones claras de lectura (Bone White #fffefb). El contenido está centrado a 1200px de max-width. El hero es una imagen oscura a pantalla completa con encabezado Azeret Mono centrado y CTA verde. Las secciones siguientes usan un split editorial de dos columnas — encabezado izquierda, cuerpo mono derecha — con gaps generosos de 64px. Las etiquetas verticales de sidebar se ubican en el margen izquierdo de la página como marcadores de sección. La navegación es una cápsula pill flotante, no una barra de ancho completo. La densidad general es espaciosa; la página está dominada por texto y fotografía, no por grillas de tarjetas. ## Agent Prompt Guide -**Quick Color Reference** -- Text/Heading: #0f3e17 (Forest Ink) -- Page background: #fffefc (Cream Paper) -- Hero/light panel: #e1f4df (Keylime Wash) -- Feature panel: #b1dbb8 (Sage Mist) -- Product showcase panel: #b6ced5 (Slate Hush) -- Border: #efeeeb -- primary action: #0f3e17 (filled action) - -**Example Component Prompts** -1. Create a Primary Action Button: #0f3e17 background, #fffefc text, 9999px radius, compact pill padding. Use this filled treatment for the main CTA. - -2. **Feature card**: Sage Mist #b1dbb8 background, 14px border-radius, 42px padding all sides, no shadow. Eyebrow label in Suisse Intl 11px/600 uppercase with 0.08em tracking, Forest Ink color. Heading in Faire Octave 40px/300, Forest Ink. Body in Suisse Intl 14px/400, Charcoal #222222. - -3. **Pill badge**: Background Cream Paper #fffefc, text Forest Ink #0f3e17, 999px border-radius, padding 9px 14px, Suisse Intl 14px/400. Place above section headings as a category tag. - -4. **FAQ row**: Two-column grid. Left: Faire Octave 40px/300 question in Forest Ink. Right: Suisse Intl 14px/400 answer in Charcoal #222222. Separator: 1px solid Border Mist #efeeeb below each row. - - -## Type Pairing Philosophy - -The serif/sans split is the system's defining typographic move. Faire Octave (or Cormorant Garamond as substitute) handles ALL headings at weight 300 — never bold. Suisse Intl (or Inter) handles ALL UI and body text. The whisper-weight serif creates editorial calm unusual in healthtech, where most competitors use bold geometric sans headlines. Tracking tightens as size grows: -0.01em at 40px, -0.03em at 56–74px. Body text uses natural tracking. The only uppercase treatment is 11px/600 eyebrows at 0.08em — these are navigational markers, not decorative caps. - -## Similar Brands - -- **Headspace** — Same botanical palette approach with sage greens and soft tinted panels, plus serif-leaning editorial calm and generous breathing room -- **Calm** — Nature-inspired muted greens against cream, generous whitespace, flat surfaces without shadows, and a serene clinical-adjacent tone -- **Mercury** — Deep single-color ink (#0f3e17 forest vs Mercury's near-black) against a warm off-white canvas with editorial serif display type at weight 300 -- **Spring Health** — Behavioral health branding with green-dominant palette, soft panel layering, and humanist sans body type paired with serif accents -- **Grow Therapy** — Healthcare SaaS using sage and mint greens against cream, with rounded components and a botanical rather than clinical visual mood +Quick Color Reference:\n- text: #333833 (light) / #fffefb (dark)\n- background: #fffefb (light) / #000000 (dark)\n- border: #e5e7eb (hairline) / #B08208 (medium)\n- accent primario: #00723F (Verde Institucional — CTAs, enlaces, estados activos)\n- accent secundario: #DD971A (Dorado UABC — tags, highlights)\n- primary action: #00723F (acción rellena) ## Quick Start @@ -231,150 +192,164 @@ The serif/sans split is the system's defining typographic move. Faire Octave (or ```css :root { /* Colors */ - --color-forest-ink: #0f3e17; - --color-sage-mist: #b1dbb8; - --color-keylime-wash: #e1f4df; - --color-mint-veil: #cfe7d3; - --color-slate-hush: #b6ced5; - --color-cream-paper: #fffefc; - --color-charcoal: #222222; - --color-border-mist: #efeeeb; - --color-forest-shadow: #0c2f10; + --color-verde-institucional: #00723F; + --color-verde-claro: #50B848; + --color-dorado-uabc: #DD971A; + --color-amarillo-dorado: #FEBE10; + --color-verde-jardin: #024731; + --color-dorado-oscuro: #B08208; + --color-azul-institucional: #20419A; + --color-bone-white: #fffefb; + --color-linen: #f3f2ee; + --color-hairline-gray: #e5e7eb; + --color-graphite: #333833; + --color-negro-institucional: #000000; - /* Typography — Font Families */ - --font-faire-octave: 'Faire Octave', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - --font-suisse-intl: 'Suisse Intl', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + /* Typography — Font Family (única) */ + --font-azeret-mono: 'Azeret Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; /* Typography — Scale */ - --text-caption: 10px; - --leading-caption: 1.61; - --text-body: 14px; - --leading-body: 1.5; - --text-subheading: 18px; - --leading-subheading: 1.3; - --text-heading-sm: 23px; - --leading-heading-sm: 1.5; - --text-heading: 40px; - --leading-heading: 1.35; - --tracking-heading: -0.4px; - --text-heading-lg: 56px; - --leading-heading-lg: 1.2; - --tracking-heading-lg: -1.68px; - --text-display: 74px; - --leading-display: 1.05; - --tracking-display: -2.22px; + --text-micro: 10px; + --leading-micro: 1.6; + --tracking-micro: 0.5px; + --text-caption: 12px; + --leading-caption: 1.63; + --tracking-caption: 0.6px; + --text-body-sm: 14px; + --leading-body-sm: 1.63; + --tracking-body-sm: 0.7px; + --text-body: 16px; + --leading-body: 1.57; + --tracking-body: -0.32px; + --text-ui: 16px; + --leading-ui: 1.83; + --tracking-ui: -0.017em; + --text-heading: 24px; + --leading-heading: 1.3; + --tracking-heading: -0.48px; + --text-heading-lg: 32px; + --leading-heading-lg: 1.3; + --tracking-heading-lg: -0.64px; + --text-display: 72px; + --leading-display: 1.1; + --tracking-display: -1.44px; /* Typography — Weights */ --font-weight-light: 300; - --font-weight-regular: 400; - --font-weight-semibold: 600; + --font-weight-ui: 500; /* Spacing */ - --spacing-4: 4px; - --spacing-7: 7px; - --spacing-9: 9px; - --spacing-11: 11px; - --spacing-14: 14px; - --spacing-18: 18px; - --spacing-21: 21px; - --spacing-28: 28px; - --spacing-35: 35px; - --spacing-42: 42px; - --spacing-49: 49px; - --spacing-56: 56px; - --spacing-70: 70px; - --spacing-76: 76px; - --spacing-99: 99px; - --spacing-156: 156px; + --spacing-unit: 8px; + --spacing-8: 8px; + --spacing-16: 16px; + --spacing-24: 24px; + --spacing-32: 32px; + --spacing-64: 64px; + --spacing-120: 120px; + --spacing-128: 128px; + --spacing-152: 152px; /* Layout */ --page-max-width: 1200px; - --section-gap: 64-96px; - --card-padding: 28-42px; - --element-gap: 14-21px; + --section-gap: 64px; + --card-padding: 24px; + --element-gap: 8px; /* Border Radius */ - --radius-lg: 7.04px; - --radius-xl: 14.08px; - --radius-full: 999px; - --radius-full-2: 9999px; + --radius-lg: 8px; + --radius-xl: 12px; + --radius-3xl: 30px; + --radius-full: 9999px; /* Named Radii */ - --radius-nav: 7px; - --radius-cards: 14px; - --radius-badges: 999px; - --radius-buttons: 14px; - --radius-sections: 9999px; + --radius-nav: 30px; + --radius-tags: 8px; + --radius-cards: 12px; + --radius-buttons: 9999px; /* Surfaces */ - --surface-cream-paper: #fffefc; - --surface-keylime-wash: #e1f4df; - --surface-mint-veil: #cfe7d3; - --surface-sage-mist: #b1dbb8; - --surface-slate-hush: #b6ced5; + --surface-bone-canvas: #fffefb; + --surface-linen-card: #f3f2ee; + --surface-dorado-nav: #B08208; + --surface-negro-institucional: #000000; } ``` +### Fontsource — instalación de Azeret Mono + +```bash +npm install @fontsource/azeret-mono +``` + +```javascript +// Importa los pesos usados por el sistema (300 y 500) +import '@fontsource/azeret-mono/300.css'; +import '@fontsource/azeret-mono/500.css'; +``` + +Si el proyecto no usa un bundler con soporte npm, se puede enlazar directamente el CSS servido por Fontsource (`https://cdn.jsdelivr.net/fontsource/fonts/azeret-mono@latest/latin-300-normal.css` y su equivalente `-500-`) en el `` del documento. + ### Tailwind v4 ```css @theme { /* Colors */ - --color-forest-ink: #0f3e17; - --color-sage-mist: #b1dbb8; - --color-keylime-wash: #e1f4df; - --color-mint-veil: #cfe7d3; - --color-slate-hush: #b6ced5; - --color-cream-paper: #fffefc; - --color-charcoal: #222222; - --color-border-mist: #efeeeb; - --color-forest-shadow: #0c2f10; + --color-verde-institucional: #00723F; + --color-verde-claro: #50B848; + --color-dorado-uabc: #DD971A; + --color-amarillo-dorado: #FEBE10; + --color-verde-jardin: #024731; + --color-dorado-oscuro: #B08208; + --color-azul-institucional: #20419A; + --color-bone-white: #fffefb; + --color-linen: #f3f2ee; + --color-hairline-gray: #e5e7eb; + --color-graphite: #333833; + --color-negro-institucional: #000000; /* Typography */ - --font-faire-octave: 'Faire Octave', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - --font-suisse-intl: 'Suisse Intl', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-azeret-mono: 'Azeret Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; /* Typography — Scale */ - --text-caption: 10px; - --leading-caption: 1.61; - --text-body: 14px; - --leading-body: 1.5; - --text-subheading: 18px; - --leading-subheading: 1.3; - --text-heading-sm: 23px; - --leading-heading-sm: 1.5; - --text-heading: 40px; - --leading-heading: 1.35; - --tracking-heading: -0.4px; - --text-heading-lg: 56px; - --leading-heading-lg: 1.2; - --tracking-heading-lg: -1.68px; - --text-display: 74px; - --leading-display: 1.05; - --tracking-display: -2.22px; + --text-micro: 10px; + --leading-micro: 1.6; + --tracking-micro: 0.5px; + --text-caption: 12px; + --leading-caption: 1.63; + --tracking-caption: 0.6px; + --text-body-sm: 14px; + --leading-body-sm: 1.63; + --tracking-body-sm: 0.7px; + --text-body: 16px; + --leading-body: 1.57; + --tracking-body: -0.32px; + --text-ui: 16px; + --leading-ui: 1.83; + --tracking-ui: -0.017em; + --text-heading: 24px; + --leading-heading: 1.3; + --tracking-heading: -0.48px; + --text-heading-lg: 32px; + --leading-heading-lg: 1.3; + --tracking-heading-lg: -0.64px; + --text-display: 72px; + --leading-display: 1.1; + --tracking-display: -1.44px; /* Spacing */ - --spacing-4: 4px; - --spacing-7: 7px; - --spacing-9: 9px; - --spacing-11: 11px; - --spacing-14: 14px; - --spacing-18: 18px; - --spacing-21: 21px; - --spacing-28: 28px; - --spacing-35: 35px; - --spacing-42: 42px; - --spacing-49: 49px; - --spacing-56: 56px; - --spacing-70: 70px; - --spacing-76: 76px; - --spacing-99: 99px; - --spacing-156: 156px; + --spacing-8: 8px; + --spacing-16: 16px; + --spacing-24: 24px; + --spacing-32: 32px; + --spacing-64: 64px; + --spacing-120: 120px; + --spacing-128: 128px; + --spacing-152: 152px; /* Border Radius */ - --radius-lg: 7.04px; - --radius-xl: 14.08px; - --radius-full: 999px; - --radius-full-2: 9999px; + --radius-lg: 8px; + --radius-xl: 12px; + --radius-3xl: 30px; + --radius-full: 9999px; } ``` diff --git a/src/components/Toaster.tsx b/src/components/Toaster.tsx index 07c1f19..b35eb8a 100644 --- a/src/components/Toaster.tsx +++ b/src/components/Toaster.tsx @@ -3,17 +3,20 @@ import type { ToastPayload, ToastKind } from '@/lib/toast'; type Item = ToastPayload & { id: number; leaving?: boolean }; -const KIND_META: Record = { +const KIND_META: Record = { success: { - color: 'var(--color-positive)', + bg: 'bg-verde-institucional', + text: 'text-bone-white', d: 'M4.5 12.75l6 6 9-13.5', }, error: { - color: 'var(--color-danger-text)', + bg: 'bg-negro-institucional', + text: 'text-bone-white', d: 'M6 18L18 6M6 6l12 12', }, info: { - color: 'var(--color-secondary)', + bg: 'bg-azul-institucional', + text: 'text-bone-white', d: 'M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z', }, }; @@ -50,7 +53,7 @@ export default function Toaster() { function dismiss(id: number, animate = false) { if (animate) { setItems((prev) => prev.map((it) => (it.id === id ? { ...it, leaving: true } : it))); - setTimeout(() => setItems((prev) => prev.filter((it) => it.id !== id)), 250); + setTimeout(() => setItems((prev) => prev.filter((it) => it.id !== id)), 200); } else { setItems((prev) => prev.filter((it) => it.id !== id)); } @@ -71,29 +74,23 @@ export default function Toaster() { onClick={() => dismiss(t.id, true)} aria-live="polite" className={ - 'toast-item pointer-events-auto text-left flex items-start gap-3 rounded-[2px] px-4 py-3 bg-white ' + + 'toast-item pointer-events-auto text-left flex items-start gap-3 rounded-[12px] px-4 py-3 bg-bone-white text-graphite border border-hairline-gray shadow-none ' + 'motion-safe:transition-[opacity,transform] motion-safe:duration-200 ' + (t.leaving ? 'toast-leave' : 'toast-enter') } - style={{ - border: '2px solid var(--color-ink)', - boxShadow: 'var(--shadow-hard)', - color: 'var(--color-ink)', - }} > - {t.title} + {t.title} {t.description && ( - {t.description} + {t.description} )} @@ -102,18 +99,19 @@ export default function Toaster() { diff --git a/src/components/admin/BadgeSolicitudes.tsx b/src/components/admin/BadgeSolicitudes.tsx index 6a0a47b..c2037c1 100644 --- a/src/components/admin/BadgeSolicitudes.tsx +++ b/src/components/admin/BadgeSolicitudes.tsx @@ -4,11 +4,17 @@ import { toast } from '@/lib/toast'; function setBadge(n: number) { const v = Math.max(0, n | 0); + // Badge pill visual (nav): oculto si 0. document.querySelectorAll('[data-badge="pendientes"]').forEach((el) => { el.textContent = String(v); if (v > 0) el.removeAttribute('hidden'); else el.setAttribute('hidden', ''); el.setAttribute('aria-label', `${v} solicitudes pendientes`); + el.classList.add('bg-verde-institucional', 'text-bone-white', 'rounded-full'); + }); + // Contadores de texto plano (KPIs) — visibles siempre, sin estilo pill. + document.querySelectorAll('[data-count="pendientes"]').forEach((el) => { + el.textContent = String(v); }); } @@ -18,18 +24,26 @@ function readBadge(): number { return Number.isFinite(n) ? n : 0; } -export default function BadgeSolicitudes() { +export default function BadgeSolicitudes({ count, className }: { count?: number; className?: string } = {}) { useEffect(() => { + document.querySelectorAll('[data-badge="pendientes"]').forEach((el) => { + el.classList.add('bg-verde-institucional', 'text-bone-white', 'rounded-full'); + }); + const supabase = browserClient(); const refetch = async () => { - const { count } = await supabase + const { count: c } = await supabase .from('solicitudes') .select('*', { count: 'exact', head: true }) .eq('estado', 'pendiente'); - setBadge(count ?? 0); + setBadge(c ?? 0); }; + // Fetch inicial: el SSR ya no envía count (se removió de AppLayout para + // ahorrar 1 query/pageview). El badge arranca en 0 y se hidrata acá. + refetch(); + const channel = supabase .channel('solicitudes-pendientes') .on( @@ -43,11 +57,26 @@ export default function BadgeSolicitudes() { }, ) .on( - // ponytail: refetch en UPDATE porque payload.old no trae estado en replica identity default; - // upgrade a delta cuando la tabla tenga replica identity full. + // Delta preciso: REPLICA IDENTITY FULL (migración 0008) hace que + // payload.old traiga el estado anterior — evitamos el refetch en cada UPDATE. 'postgres_changes', { event: 'UPDATE', schema: 'prestamos', table: 'solicitudes' }, - () => { refetch(); }, + (payload) => { + const oldEstado = (payload.old as { estado?: string } | null)?.estado; + const newEstado = (payload.new as { estado?: string } | null)?.estado; + // Fallback: si el WAL no envió estado en `old` (tabla aún en REPLICA IDENTITY DEFAULT), + // caemos al refetch original — un poco más caro pero correcto. + if (oldEstado === undefined) { + refetch(); + return; + } + if (oldEstado === 'pendiente' && newEstado !== 'pendiente') { + setBadge(Math.max(0, readBadge() - 1)); + } else if (oldEstado !== 'pendiente' && newEstado === 'pendiente') { + setBadge(readBadge() + 1); + } + // Si ninguno de los dos es 'pendiente', el UPDATE no afecta al count — no hacer nada. + }, ) .subscribe(); @@ -56,5 +85,28 @@ export default function BadgeSolicitudes() { }; }, []); - return null; + if (count !== undefined) { + return ( + + ); + } + + return ( + + ); } diff --git a/src/components/admin/estadisticas/Chart.tsx b/src/components/admin/estadisticas/Chart.tsx index f9299a2..60f3205 100644 --- a/src/components/admin/estadisticas/Chart.tsx +++ b/src/components/admin/estadisticas/Chart.tsx @@ -21,50 +21,67 @@ type Linea = { tipo: 'linea'; datos: { fecha: string; valor: number }[]; alto?: type Donut = { tipo: 'donut'; datos: { label: string; valor: number }[]; alto?: number }; type Props = Barras | Linea | Donut; -const INK = '#383838'; -const PENCIL = '#a1a1a1'; -const PRIMARY = '#00723F'; -const SECONDARY = '#DD971A'; -const DANGER = '#a83224'; -const POSITIVE = '#38c1b0'; -const ATTENTION = '#ffde00'; +// PALETA UABC (TRACK E): +// Primario: #00723F (verde-institucional) +// Secundarios: #DD971A (dorado-uabc), #50B848 (verde-claro), #20419A (azul-institucional), #024731 (verde-jardin) +// Alerta: #000000 (negro-institucional) +// Superficie: #fffefb (bone-white) +// Texto: #333833 (graphite) +// Bordes: #e5e7eb (hairline-gray) -const MONO: React.CSSProperties = { - fontFamily: "'JetBrains Mono Variable', ui-monospace, monospace", - fontSize: 11, - color: INK, -}; +const VERDE_INSTITUCIONAL = '#00723F'; +const DORADO_UABC = '#DD971A'; +const VERDE_CLARO = '#50B848'; +const AZUL_INSTITUCIONAL = '#20419A'; +const VERDE_JARDIN = '#024731'; +const NEGRO_INSTITUCIONAL = '#000000'; +const BONE_WHITE = '#fffefb'; +const GRAPHITE = '#333833'; +const HAIRLINE_GRAY = '#e5e7eb'; + +const PRIMARY = VERDE_INSTITUCIONAL; +const ALERT = NEGRO_INSTITUCIONAL; // Paleta rotativa para el donut. Coloreado por label cuando aplique. const DONUT_BY_LABEL: Record = { - pendiente: ATTENTION, - aprobado: PRIMARY, - activo: PRIMARY, - devuelto: POSITIVE, - rechazado: PENCIL, - vencido: DANGER, + pendiente: DORADO_UABC, + aprobado: VERDE_INSTITUCIONAL, + activo: VERDE_CLARO, + devuelto: VERDE_JARDIN, + rechazado: GRAPHITE, + vencido: ALERT, }; -const DONUT_FALLBACK = [PRIMARY, SECONDARY, POSITIVE, ATTENTION, PENCIL, DANGER]; +const DONUT_FALLBACK = [ + VERDE_INSTITUCIONAL, + DORADO_UABC, + VERDE_CLARO, + AZUL_INSTITUCIONAL, + VERDE_JARDIN, + ALERT, +]; function donutColor(label: string, i: number): string { return DONUT_BY_LABEL[label.toLowerCase()] ?? DONUT_FALLBACK[i % DONUT_FALLBACK.length]; } +const TOOLTIP_CONTENT_STYLE: React.CSSProperties = { + backgroundColor: '#fffefb', + border: '1px solid #e5e7eb', + borderRadius: 8, + padding: '8px 12px', + color: '#333833', + boxShadow: 'none', + fontFamily: "'Azeret Mono', monospace", + fontSize: 12, +}; + function TooltipBox({ active, payload, label }: any) { if (!active || !payload?.length) return null; return ( -
- {label !== undefined &&
{label}
} +
+ {label !== undefined &&
{label}
} {payload.map((p: any, i: number) => ( -
+
{p.name}: {p.value}
))} @@ -86,8 +103,11 @@ function usePrefersReducedMotion(): boolean { function fmtFechaCorta(iso: string): string { // 'YYYY-MM-DD' → 'DD/MM' - const [, m, d] = iso.split('-'); - return `${d}/${m}`; + const parts = iso.split('-'); + if (parts.length >= 3) { + return `${parts[2]}/${parts[1]}`; + } + return iso; } export default function Chart(props: Props) { @@ -103,21 +123,40 @@ export default function Chart(props: Props) { ); return ( - - - + + + (v.length > 14 ? v.slice(0, 13) + '…' : v)} /> - } cursor={{ fill: 'rgba(56,56,56,0.06)' }} /> - - + } + cursor={{ fill: 'rgba(0, 114, 63, 0.05)' }} + contentStyle={TOOLTIP_CONTENT_STYLE} + /> + + @@ -129,17 +168,30 @@ export default function Chart(props: Props) { return ( - - - - } cursor={{ stroke: INK, strokeWidth: 1, strokeDasharray: '2 3' }} /> + + + + } + cursor={{ stroke: '#e5e7eb', strokeWidth: 1, strokeDasharray: '2 3' }} + contentStyle={TOOLTIP_CONTENT_STYLE} + /> @@ -153,15 +205,15 @@ export default function Chart(props: Props) { return ( - } /> + } contentStyle={TOOLTIP_CONTENT_STYLE} /> { const v = entry?.payload?.valor; - return v != null ? `${value} (${v})` : value; + return {v != null ? `${value} (${v})` : value}; }} /> diff --git a/src/components/admin/inventario/CategoriaForm.tsx b/src/components/admin/inventario/CategoriaForm.tsx index 4b6b379..2c354df 100644 --- a/src/components/admin/inventario/CategoriaForm.tsx +++ b/src/components/admin/inventario/CategoriaForm.tsx @@ -81,20 +81,23 @@ export default function CategoriaForm({ mode, categoria }: Props) { -
+
-

+

{mode === 'create' ? 'Nueva categoría' : `Editar: ${categoria?.nombre}`}

@@ -122,8 +125,7 @@ export default function CategoriaForm({ mode, categoria }: Props) { id={`${titleId}-err`} role="alert" tabIndex={-1} - className="text-sm" - style={{ color: 'var(--color-danger-text)' }} + className="text-sm text-negro-institucional" > {error}

diff --git a/src/components/admin/inventario/EliminarCategoria.tsx b/src/components/admin/inventario/EliminarCategoria.tsx index 661b271..6029cfc 100644 --- a/src/components/admin/inventario/EliminarCategoria.tsx +++ b/src/components/admin/inventario/EliminarCategoria.tsx @@ -54,17 +54,9 @@ export default function EliminarCategoria({ id, nombre, materialesCount }: Props <> @@ -72,37 +64,37 @@ export default function EliminarCategoria({ id, nombre, materialesCount }: Props -
-

+
+

Eliminar categoría

{bloqueado ? ( <> -

- No se puede eliminar {nombre}: tiene{' '} +

+ No se puede eliminar {nombre}: tiene{' '} {materialesCount}{' '} material{materialesCount === 1 ? '' : 'es'} asociado {materialesCount === 1 ? '' : 's'}. Reasígnalos primero.

Ver materiales de esta categoría → ) : ( -

- ¿Seguro que quieres eliminar {nombre}? +

+ ¿Seguro que quieres eliminar {nombre}?

)} {error && ( -

+

{error}

)} @@ -114,8 +106,7 @@ export default function EliminarCategoria({ id, nombre, materialesCount }: Props {!bloqueado && (
@@ -122,23 +125,24 @@ export default function MaestroForm({ mode, maestro }: Props) { />

-
diff --git a/src/components/admin/solicitudes/AccionesSolicitud.tsx b/src/components/admin/solicitudes/AccionesSolicitud.tsx index 7e0e573..4bfbd4d 100644 --- a/src/components/admin/solicitudes/AccionesSolicitud.tsx +++ b/src/components/admin/solicitudes/AccionesSolicitud.tsx @@ -82,24 +82,24 @@ function AprobarDialog({ prestamo }: { prestamo: Prestamo }) { return ( <> - - -

Aprobar solicitud

-

+

+ +

Aprobar solicitud

+

{prestamo.alumno?.nombre ?? prestamo.alumno?.email} solicita, para {prestamo.maestro_responsable}:

-
    +
      {prestamo.items.map((i, idx) => (
    • {i.cantidad} de {i.material?.nombre ?? '—'} {(i.material?.cantidad_disponible ?? 0) < i.cantidad && ( - — stock insuficiente ({i.material?.cantidad_disponible ?? 0} disp.) + — stock insuficiente ({i.material?.cantidad_disponible ?? 0} disp.) )}
    • ))}
    {stockInsuficiente && ( -
    +
    Uno o más materiales no tienen stock suficiente.
    )} @@ -128,7 +128,7 @@ function AprobarDialog({ prestamo }: { prestamo: Prestamo }) { />
    {error && ( -
    +
    {error}
    )} @@ -184,18 +184,18 @@ function RechazarDialog({ prestamo }: { prestamo: Prestamo }) { <> - - -

    Rechazar solicitud

    -

    +

    + +

    Rechazar solicitud

    +

    {resumenNombres(prestamo.items)} · {prestamo.alumno?.nombre ?? prestamo.alumno?.email}

    -

    +

    Explica al alumno por qué se rechaza. Este texto queda registrado.

    @@ -213,7 +213,7 @@ function RechazarDialog({ prestamo }: { prestamo: Prestamo }) { />
    {error && ( -
    +
    {error}
    )} @@ -221,9 +221,8 @@ function RechazarDialog({ prestamo }: { prestamo: Prestamo }) { diff --git a/src/components/admin/solicitudes/MarcarDevuelto.tsx b/src/components/admin/solicitudes/MarcarDevuelto.tsx index a1811ce..fd72b8b 100644 --- a/src/components/admin/solicitudes/MarcarDevuelto.tsx +++ b/src/components/admin/solicitudes/MarcarDevuelto.tsx @@ -40,23 +40,23 @@ export default function MarcarDevuelto({ prestamoId, resumenMateriales }: { pres return ( <> - - - -

    Confirmar devolución

    -

    - ¿Confirmas la devolución de {resumenMateriales}? El material regresará al inventario disponible. +

    + +

    Confirmar devolución

    +

    + ¿Confirmas la devolución de {resumenMateriales}? El material regresará al inventario disponible.

    {error && ( -
    +
    {error}
    )}
    -
    diff --git a/src/components/admin/solicitudes/VerDetalles.tsx b/src/components/admin/solicitudes/VerDetalles.tsx index dd4ecc4..38a8476 100644 --- a/src/components/admin/solicitudes/VerDetalles.tsx +++ b/src/components/admin/solicitudes/VerDetalles.tsx @@ -94,8 +94,7 @@ function ReasignarUnidad({ return ( - -
    -
    -

    Detalle del préstamo

    - + + +
    +

    Detalle del préstamo

    + +
    + + {loading &&

    Cargando…

    } + {error && ( +
    + {error}
    + )} - {loading &&

    Cargando…

    } - {error && ( -
    - {error} -
    - )} - - {data && ( - <> -
    -
    Alumno
    -
    {data.prestamo.alumno?.nombre ?? data.prestamo.alumno?.email}
    -
    Maestro
    -
    {data.prestamo.maestro_responsable}
    -
    Materiales
    -
    -
      - {data.prestamo.items.map((i) => ( -
    • - {i.cantidad}× {i.material?.nombre ?? '—'}{' '} - · Inv. {i.material?.numero_inventario ?? '—'} - {i.material_unidad?.etiqueta && ( -
      - Unidad: - {i.material_unidad.etiqueta} -
      - )} - {i.descripcion &&
      {i.descripcion}
      } + {data && ( + <> +
      +
      Alumno
      +
      {data.prestamo.alumno?.nombre ?? data.prestamo.alumno?.email}
      +
      Maestro
      +
      {data.prestamo.maestro_responsable}
      +
      Materiales
      +
      +
        + {data.prestamo.items.map((i) => ( +
      • +
        +
        + {i.cantidad}×{' '} + {i.material?.nombre ?? '—'} + Inv. {i.material?.numero_inventario ?? '—'} +
        {canReasignar && i.material_unidad && ( )} -
      • - ))} -
      -
      -
      Solicitado
      -
      {fmt.format(new Date(data.prestamo.fecha_solicitud))}
      - {data.prestamo.fecha_aprobacion && (<>
      Aprobado
      {fmt.format(new Date(data.prestamo.fecha_aprobacion))}
      )} - {data.prestamo.fecha_devolucion_estimada && (<>
      Devolver antes
      {fmt.format(new Date(data.prestamo.fecha_devolucion_estimada + 'T00:00:00'))}
      )} - {data.prestamo.notas && (<>
      Notas
      {data.prestamo.notas}
      )} -
      - -

      Historial de cambios

      - {data.audit_log.length === 0 ? ( -

      Sin cambios registrados.

      - ) : ( -
        - {data.audit_log.map((e, i) => { - const fecha = e.at ?? e.created_at ?? e.fecha ?? e.timestamp; - return ( -
      • - {e.accion ?? 'cambio'} - - {(e.estado_anterior ?? '—')} → {(e.estado_nuevo ?? '—')} - - {fecha && {fmt.format(new Date(fecha))}} -
      • - ); - })} +
    + {i.material_unidad?.etiqueta && ( +
    + Unidad: {i.material_unidad.etiqueta} +
    + )} + {i.descripcion &&
    {i.descripcion}
    } + + ))}
- )} - - )} -
+ +
Solicitado
+
{fmt.format(new Date(data.prestamo.fecha_solicitud))}
+ {data.prestamo.fecha_aprobacion && (<>
Aprobado
{fmt.format(new Date(data.prestamo.fecha_aprobacion))}
)} + {data.prestamo.fecha_devolucion_estimada && (<>
Devolver antes
{fmt.format(new Date(data.prestamo.fecha_devolucion_estimada + 'T00:00:00'))}
)} + {data.prestamo.notas && (<>
Notas
{data.prestamo.notas}
)} + + +

Historial de cambios

+ {data.audit_log.length === 0 ? ( +

Sin cambios registrados.

+ ) : ( +
+ {data.audit_log.map((e, i) => { + const fecha = e.at ?? e.created_at ?? e.fecha ?? e.timestamp; + const estadoNuevo = e.estado_nuevo; + const estadoAnt = e.estado_anterior; + return ( +
+
+
+ {e.accion ?? 'Cambio'} + {fecha && {fmt.format(new Date(fecha))}} +
+ {(estadoAnt || estadoNuevo) && ( +
+ {estadoAnt && ( + <> + {estadoAnt} + → + + )} + {estadoNuevo && ( + {estadoNuevo} + )} +
+ )} +
+ ); + })} +
+ )} + + )} ); diff --git a/src/components/admin/usuarios/BanearForm.tsx b/src/components/admin/usuarios/BanearForm.tsx index a42d6f6..45fcb91 100644 --- a/src/components/admin/usuarios/BanearForm.tsx +++ b/src/components/admin/usuarios/BanearForm.tsx @@ -11,6 +11,7 @@ export default function BanearForm({ profile, adminSelf }: Props) { const firstFieldRef = useRef(null); const errorRef = useRef(null); const [razon, setRazon] = useState(''); + const [expiresAt, setExpiresAt] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -21,6 +22,7 @@ export default function BanearForm({ profile, adminSelf }: Props) { if (adminSelf) return; setError(null); setRazon(''); + setExpiresAt(''); dialogRef.current?.showModal(); queueMicrotask(() => firstFieldRef.current?.focus()); }; @@ -54,10 +56,18 @@ export default function BanearForm({ profile, adminSelf }: Props) { setLoading(true); setError(null); try { + const payload: { profile_id: string; razon: string; expires_at?: string } = { + profile_id: profile.id, + razon: trimmed, + }; + if (expiresAt) { + payload.expires_at = new Date(expiresAt + 'T23:59:59').toISOString(); + } + const res = await fetch('/api/admin/baneos', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ profile_id: profile.id, razon: trimmed }), + body: JSON.stringify(payload), }); const json = await res.json().catch(() => ({})); if (!res.ok) throw new Error(json?.error ?? 'No se pudo banear'); @@ -79,11 +89,10 @@ export default function BanearForm({ profile, adminSelf }: Props) { <>
-

+

El usuario no podrá acceder al sistema hasta ser desbaneado.

@@ -137,19 +149,35 @@ export default function BanearForm({ profile, adminSelf }: Props) { aria-describedby={error ? `${titleId}-err` : undefined} placeholder="Explica el motivo — el usuario lo verá en su pantalla de bloqueo." /> -

+

{razon.length}/500

+
+ + setExpiresAt(e.target.value)} + /> +

+ Opcional. Si se omite, el baneo será indefinido hasta desbanear manualmente. +

+
+ {error && ( @@ -161,8 +189,7 @@ export default function BanearForm({ profile, adminSelf }: Props) { diff --git a/src/components/alumno/AgregarMaterial.tsx b/src/components/alumno/AgregarMaterial.tsx index b455007..53bef18 100644 --- a/src/components/alumno/AgregarMaterial.tsx +++ b/src/components/alumno/AgregarMaterial.tsx @@ -6,32 +6,65 @@ type Props = { }; export default function AgregarMaterial({ material }: Props) { - const { addItem, has } = useCart(); + const { items, addItem, updateCantidad, removeItem, has } = useCart(); const sinStock = material.cantidad_disponible === 0; const enCarrito = has(material.id); if (enCarrito) { + const item = items.find((i) => i.material_id === material.id); + const cantidad = item?.cantidad ?? 1; + return ( - +
+
+ + + {cantidad} + + +
+ En tu solicitud +
); } return ( ); } diff --git a/src/components/alumno/BuscadorCatalogo.tsx b/src/components/alumno/BuscadorCatalogo.tsx index 1ecfc3c..18cac18 100644 --- a/src/components/alumno/BuscadorCatalogo.tsx +++ b/src/components/alumno/BuscadorCatalogo.tsx @@ -82,25 +82,30 @@ export default function BuscadorCatalogo({ q: qInitial = '' }: { q?: string }) { -
+
+ onChange(e.target.value)} autoComplete="off" - style={{ paddingRight: q ? '2.5rem' : undefined }} /> {q && ( diff --git a/src/components/alumno/FiltroCategorias.tsx b/src/components/alumno/FiltroCategorias.tsx index 605d837..1630dec 100644 --- a/src/components/alumno/FiltroCategorias.tsx +++ b/src/components/alumno/FiltroCategorias.tsx @@ -50,13 +50,8 @@ export default function FiltroCategorias({ categorias }: Props) { const chipClass = (v: string) => v === active - ? 'btn' - : 'btn btn-ghost'; - - const chipStyle = (v: string): React.CSSProperties => - v === active - ? { background: 'var(--color-primary)', color: 'white', minHeight: '40px', paddingBlock: '0.375rem' } - : { minHeight: '40px', paddingBlock: '0.375rem' }; + ? 'bg-verde-institucional text-bone-white rounded-[8px] px-3 py-1 border border-verde-institucional inline-flex items-center whitespace-nowrap text-xs font-medium cursor-pointer transition-colors' + : 'chip cursor-pointer hover:bg-linen hover:border-dorado-uabc transition-colors'; return (
pick(ALL)} @@ -86,7 +80,6 @@ export default function FiltroCategorias({ categorias }: Props) { role="tab" aria-selected={active === v} className={chipClass(v)} - style={chipStyle(v)} data-cat-value={v} data-cat-active={active === v ? 'true' : undefined} onClick={() => pick(v)} diff --git a/src/components/alumno/SolicitudCart.tsx b/src/components/alumno/SolicitudCart.tsx index 0e2fda2..616e30f 100644 --- a/src/components/alumno/SolicitudCart.tsx +++ b/src/components/alumno/SolicitudCart.tsx @@ -14,14 +14,6 @@ type Material = { categoria: { id: number; nombre: string } | null; }; -// El catálogo sólo lista materiales con estado 'disponible' (ver catalogo.astro), -// así que el chip siempre coincide con el mismo estilo que usa el inventario del admin. -const DISPONIBLE_BADGE_STYLE = { - background: 'color-mix(in oklab, var(--color-positive) 18%, white)', - color: 'var(--color-ink)', - border: '1.5px solid var(--color-positive)', -} as const; - type CartItem = { material_id: number; nombre: string; @@ -238,7 +230,7 @@ export default function CartProvider({ return ( -
+
{materiales.map((m) => { const src = imgUrl(m.imagen_path); return ( @@ -249,34 +241,32 @@ export default function CartProvider({ data-nombre={m.nombre} data-numero-inventario={m.numero_inventario ?? ''} > -
+
{src ? ( - {m.nombre} + {m.nombre} ) : ( - + Disponible
-
-

{m.nombre}

- {m.categoria &&

{m.categoria.nombre}

} +
+ {m.categoria?.nombre ?? 'Sin categoría'} +

+ {m.nombre} +

- {m.descripcion &&

{m.descripcion}

} + {m.descripcion && ( +

{m.descripcion}

+ )} -
+
Inventario
{m.numero_inventario ?? '—'}
Disponibles
@@ -285,7 +275,7 @@ export default function CartProvider({
-
+
@@ -297,8 +287,7 @@ export default function CartProvider({ {items.length > 0 && (
{items.length === 0 ? ( -

Tu solicitud está vacía. Cierra esta ventana y agrega material del catálogo.

+

Tu solicitud está vacía. Cierra esta ventana y agrega material del catálogo.

) : (
    {items.map((item) => (
  • -

    {item.nombre}

    +

    {item.nombre}

    updateCantidad(item.material_id, Number(e.target.value) || 1)} style={{ width: '4.5rem', minHeight: '36px', paddingBlock: '0.25rem', fontVariantNumeric: 'tabular-nums' }} /> - de {item.cantidad_disponible} + de {item.cantidad_disponible}
    removeItem(item.material_id)} aria-label={`Quitar ${item.nombre}`} - className="rounded-lg p-1.5 hover:bg-black/5 transition-colors shrink-0" + className="btn btn-ghost rounded-full w-8 h-8 p-0 flex items-center justify-center shrink-0" > -

    +

    {tutorNombre ? <>Si no eliges nadie se usará tu tutor: {tutorNombre}. : 'Debes elegir un maestro; no tienes tutor guardado.'} @@ -415,7 +403,7 @@ export default function CartProvider({