Rediseño impeccable: brand mark, páginas 403/404, DESIGN.md + PRODUCT.md

Rediseño hecho en sesión aparte con el skill impeccable. Se registran
las especificaciones y ajustes visuales al sistema ya en producción.

Añade:
- BrandMark.astro con el escudo/logo del laboratorio.
- Favicon actualizado (svg + ico).
- Páginas propias 403 y 404 consistentes con el sistema visual.
- DESIGN.md y PRODUCT.md documentando decisiones.
- .impeccable/design.json con el design system tokenizado.

Ajusta componentes existentes (modales, autocomplete, layout, login,
solicitudes, mis-préstamos, middleware) para alinearse a los tokens
del design system.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 15:19:57 -07:00
parent 927d99c262
commit 57b07b9b82
20 changed files with 596 additions and 27 deletions
+203
View File
@@ -0,0 +1,203 @@
{
"schemaVersion": 2,
"generatedAt": "2026-08-16T00:00:00Z",
"title": "Design System: LabPréstamos",
"extensions": {
"colorMeta": {
"verde-institucional": {
"role": "primary",
"displayName": "Verde Institucional",
"canonical": "oklch(48.5% 0.12 154.8)",
"tonalRamp": [
"oklch(15% 0.12 154.8)",
"oklch(28% 0.12 154.8)",
"oklch(40% 0.12 154.8)",
"oklch(52% 0.12 154.8)",
"oklch(65% 0.12 154.8)",
"oklch(76% 0.12 154.8)",
"oklch(86% 0.12 154.8)",
"oklch(95% 0.12 154.8)"
]
},
"dorado-uabc": {
"role": "secondary",
"displayName": "Dorado UABC",
"canonical": "oklch(72.8% 0.148 74.4)",
"tonalRamp": [
"oklch(15% 0.148 74.4)",
"oklch(28% 0.148 74.4)",
"oklch(40% 0.148 74.4)",
"oklch(52% 0.148 74.4)",
"oklch(65% 0.148 74.4)",
"oklch(76% 0.148 74.4)",
"oklch(86% 0.148 74.4)",
"oklch(95% 0.148 74.4)"
]
},
"superficie": {
"role": "neutral",
"displayName": "Superficie",
"canonical": "oklch(97.3% 0.004 157.2)",
"tonalRamp": [
"oklch(15% 0.004 157.2)",
"oklch(28% 0.004 157.2)",
"oklch(40% 0.004 157.2)",
"oklch(52% 0.004 157.2)",
"oklch(65% 0.004 157.2)",
"oklch(76% 0.004 157.2)",
"oklch(86% 0.004 157.2)",
"oklch(95% 0.004 157.2)"
]
},
"tinta": {
"role": "neutral",
"displayName": "Tinta",
"canonical": "oklch(33.5% 0.033 260.9)",
"tonalRamp": [
"oklch(15% 0.033 260.9)",
"oklch(28% 0.033 260.9)",
"oklch(40% 0.033 260.9)",
"oklch(52% 0.033 260.9)",
"oklch(65% 0.033 260.9)",
"oklch(76% 0.033 260.9)",
"oklch(86% 0.033 260.9)",
"oklch(95% 0.033 260.9)"
]
},
"peligro": {
"role": "danger",
"displayName": "Peligro",
"canonical": "oklch(54.4% 0.186 26)",
"tonalRamp": [
"oklch(15% 0.186 26)",
"oklch(28% 0.186 26)",
"oklch(40% 0.186 26)",
"oklch(52% 0.186 26)",
"oklch(65% 0.186 26)",
"oklch(76% 0.186 26)",
"oklch(86% 0.186 26)",
"oklch(95% 0.186 26)"
]
}
},
"typographyMeta": {
"headline": { "displayName": "Headline", "purpose": "Page-level h1 — dashboard/section titles, login title." },
"title": { "displayName": "Title", "purpose": "Card and section headers within a page." },
"body": { "displayName": "Body", "purpose": "Default text size across the whole system: table cells, descriptions, list content." },
"label": { "displayName": "Label", "purpose": "Form field labels, always paired with htmlFor/id." },
"caption": { "displayName": "Caption", "purpose": "Metadata: matrícula, inventory numbers, timestamps, field hints." }
},
"shadows": [
{ "name": "floating-chrome", "value": "0 10px 30px -8px rgba(0,0,0,0.25)", "purpose": "Mobile dock nav — soft ambient shadow signaling it floats above the scrolling page." },
{ "name": "overlay-notification", "value": "0 16px 40px -12px rgba(0,0,0,0.25)", "purpose": "Toast stack — same ambient quality, slightly heavier, paired with backdrop-blur-xl." }
],
"motion": [
{ "name": "toast-entrance", "value": "cubic-bezier(0.22, 1, 0.36, 1)", "purpose": "260ms translate+scale+fade entrance for toast notifications." },
{ "name": "chrome-transition", "value": "cubic-bezier(0, 0, 0.2, 1)", "purpose": "150-200ms default ease-out for sidebar collapse, hover states, dock icon transitions." }
],
"breakpoints": [
{ "name": "md", "value": "768px" }
]
},
"components": [
{
"name": "Primary Button",
"kind": "button",
"refersTo": "button-primary",
"description": "Default call to action: submit, approve, continue with Google.",
"html": "<button class=\"ds-btn ds-btn-primary\">Enviar solicitud</button>",
"css": ".ds-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; padding: 10px 16px; border-radius: 8px; font-weight: 500; font-size: 0.875rem; border: none; cursor: pointer; touch-action: manipulation; transition: filter 150ms ease-out, background-color 150ms ease-out; } .ds-btn-primary { background: #00723F; color: #ffffff; } .ds-btn-primary:hover { filter: brightness(1.1); } .ds-btn-primary:focus-visible { outline: 2px solid #00723F; outline-offset: 2px; } .ds-btn-primary:disabled { opacity: 0.5; pointer-events: none; }"
},
{
"name": "Secondary Button",
"kind": "button",
"refersTo": "button-secondary",
"description": "Sparingly used accent action — filtering, non-primary confirms.",
"html": "<button class=\"ds-btn ds-btn-secondary\">Filtrar</button>",
"css": ".ds-btn-secondary { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; padding: 10px 16px; border-radius: 8px; font-weight: 500; font-size: 0.875rem; border: none; cursor: pointer; touch-action: manipulation; background: #DD971A; color: #ffffff; transition: filter 150ms ease-out; } .ds-btn-secondary:hover { filter: brightness(1.1); } .ds-btn-secondary:focus-visible { outline: 2px solid #00723F; outline-offset: 2px; }"
},
{
"name": "Ghost Button",
"kind": "button",
"refersTo": "button-ghost",
"description": "Low-commitment counterpart to Primary — cancel, clear filters.",
"html": "<button class=\"ds-btn ds-btn-ghost\">Cancelar</button>",
"css": ".ds-btn-ghost { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; padding: 10px 16px; border-radius: 8px; font-weight: 500; font-size: 0.875rem; cursor: pointer; touch-action: manipulation; background: transparent; color: #2D3748; border: 1px solid color-mix(in oklab, #2D3748 15%, transparent); transition: background-color 150ms ease-out; } .ds-btn-ghost:hover { background: color-mix(in oklab, #2D3748 5%, transparent); } .ds-btn-ghost:focus-visible { outline: 2px solid #00723F; outline-offset: 2px; }"
},
{
"name": "Status Pill",
"kind": "chip",
"refersTo": "badge",
"description": "Tinted-fill status indicator used for every préstamo/material estado; solid-fill only for the confirmed Vencido exception.",
"html": "<span class=\"ds-pill ds-pill-primary\">Disponible</span> <span class=\"ds-pill ds-pill-secondary\">Mantenimiento</span> <span class=\"ds-pill ds-pill-danger-solid\">Vencido</span>",
"css": ".ds-pill { display: inline-flex; align-items: center; border-radius: 9999px; padding: 2px 10px; font-size: 0.75rem; font-weight: 600; white-space: nowrap; } .ds-pill-primary { background: color-mix(in oklab, #00723F 13%, transparent); color: #00723F; } .ds-pill-secondary { background: color-mix(in oklab, #DD971A 15%, transparent); color: #c78614; } .ds-pill-danger-solid { background: #C53030; color: #ffffff; }"
},
{
"name": "Card",
"kind": "card",
"refersTo": "card",
"description": "Flat white container — the system's default surface for content, forms, and KPI tiles. No shadow, hairline ink-mix border only.",
"html": "<div class=\"ds-card\"><h3 class=\"ds-card-title\">Inicia sesión</h3><p class=\"ds-card-body\">Accede con tu cuenta institucional.</p></div>",
"css": ".ds-card { background: #ffffff; border-radius: 8px; padding: 24px; border: 1px solid color-mix(in oklab, #2D3748 10%, transparent); } .ds-card-title { font-size: 1.125rem; font-weight: 600; margin: 0 0 4px; color: #2D3748; } .ds-card-body { font-size: 0.875rem; opacity: 0.7; margin: 0; color: #2D3748; }"
},
{
"name": "Input Field",
"kind": "input",
"refersTo": "input",
"description": "Labeled text field with hairline border and single-color focus outline (no glow, no border-color shift).",
"html": "<div class=\"ds-field\"><label class=\"ds-label\" for=\"demo-input\">Cantidad</label><input id=\"demo-input\" class=\"ds-input\" type=\"number\" value=\"1\" /></div>",
"css": ".ds-label { display: block; font-size: 0.875rem; font-weight: 500; margin-bottom: 4px; color: color-mix(in oklab, #2D3748 90%, transparent); } .ds-input { width: 100%; min-height: 44px; border-radius: 8px; padding: 10px 12px; background: #ffffff; border: 1px solid color-mix(in oklab, #2D3748 20%, transparent); font-size: 0.875rem; touch-action: manipulation; transition: border-color 150ms, box-shadow 150ms; } .ds-input:focus-visible { outline: 2px solid #00723F; outline-offset: 2px; }"
},
{
"name": "Mobile Dock Nav Item",
"kind": "nav",
"refersTo": "nav-dock-item",
"description": "Signature component: icon-only floating pill navigation for the alumno/admin mobile layout, replacing a conventional bottom tab bar.",
"html": "<nav class=\"ds-dock\"><a href=\"#\" class=\"ds-dock-item ds-dock-item-active\" aria-label=\"Inicio\"><svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M2.25 12l8.954-8.955a1.5 1.5 0 012.122 0L22.28 12M4.5 9.75v10.125A1.125 1.125 0 005.625 21H9.75v-6h4.5v6h4.125A1.125 1.125 0 0019.5 19.875V9.75\"/></svg></a><a href=\"#\" class=\"ds-dock-item\" aria-label=\"Catálogo\"><svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 8.25V6z\"/></svg></a></nav>",
"css": ".ds-dock { display: inline-flex; align-items: center; gap: 4px; border-radius: 9999px; padding: 8px; backdrop-filter: blur(8px); background: rgba(255,255,255,0.95); box-shadow: 0 10px 30px -8px rgba(0,0,0,0.25); border: 1px solid color-mix(in oklab, #2D3748 10%, transparent); } .ds-dock-item { display: grid; place-items: center; width: 44px; height: 44px; border-radius: 9999px; color: color-mix(in oklab, #2D3748 70%, transparent); transition: background-color 150ms, color 150ms, transform 150ms; text-decoration: none; } .ds-dock-item:hover { background: color-mix(in oklab, #2D3748 5%, transparent); color: #2D3748; } .ds-dock-item:active { transform: scale(0.95); } .ds-dock-item-active { background: #00723F; color: #ffffff; }"
},
{
"name": "Toast",
"kind": "custom",
"refersTo": "toast",
"description": "Signature component: floating top-center notification with a colored circular icon chip, survives page reload via a sessionStorage queue.",
"html": "<div class=\"ds-toast\"><span class=\"ds-toast-icon\"><svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4.5 12.75l6 6 9-13.5\"/></svg></span><span class=\"ds-toast-text\"><span class=\"ds-toast-title\">Solicitud enviada</span><span class=\"ds-toast-desc\">Multímetro digital · pendiente de aprobación</span></span></div>",
"css": ".ds-toast { display: flex; align-items: flex-start; gap: 12px; border-radius: 16px; padding: 12px 16px; background: rgba(255,255,255,0.85); backdrop-filter: blur(20px); box-shadow: 0 16px 40px -12px rgba(0,0,0,0.25); border: 1px solid color-mix(in oklab, #2D3748 10%, transparent); color: #2D3748; max-width: 26rem; } .ds-toast-icon { flex-shrink: 0; display: grid; place-items: center; width: 36px; height: 36px; border-radius: 9999px; background: #00723F; color: #ffffff; } .ds-toast-title { display: block; font-weight: 600; font-size: 0.875rem; } .ds-toast-desc { display: block; font-size: 0.75rem; opacity: 0.7; margin-top: 2px; }"
}
],
"narrative": {
"northStar": "La Bitácora Digital",
"overview": "LabPréstamos is the printed loan sheet and the Excel logbook it used to feed, collapsed into one live surface. Every visual decision defends that lineage: nothing decorative, nothing that would feel out of place next to an official UABC form, but nothing that feels like a cold spreadsheet either. The system is cálido pero confiable — warm but reliable — and it earns the \"warm\" half narrowly: a gold accent, soft rounded corners, and gentle motion sit on top of an otherwise flat, administrative base.\n\nThe page itself stays flat and quiet — white cards on a pale green-grey surface, hairline borders instead of shadows, tabular numbers lined up like a real ledger. Depth is reserved for the two things that are genuinely floating above the page: the toast notifications and the mobile navigation dock. That asymmetry is deliberate — it is how the system tells the eye \"this is the document\" from \"this is the chrome.\"\n\nTwo audiences read this bitácora differently. The alumno's default device is the phone — the catalog, the request modal, the pill-shaped floating dock are all tuned for thumbs. The admin's default device is the desktop — dense tables, a persistent sidebar, KPI tiles. Neither view degrades on the other's device; every screen ships both a table and a card-list rendering.",
"keyCharacteristics": [
"Flat administrative base (white cards, hairline ink-tinted borders, no ambient shadow)",
"Shadow appears only on floating overlay chrome — the mobile dock and toasts — never on the page plane",
"Every status is a tinted pill: 1215% color wash background, full-strength text",
"Tabular numbers everywhere a quantity, date, or stock figure appears",
"One typeface (Inter Variable) carrying every role through weight and size alone",
"Motion is a courtesy, not a performance: motion-safe:-gated, specific-property transitions, no transition-all"
],
"rules": [
{ "name": "The Ink-Mix Border Rule", "body": "Every hairline border and divider is color-mix(in oklab, var(--color-tinta) N%, transparent), never a literal gray hex. A border is always \"ink, diluted,\" so it stays coherent with body text at any opacity.", "section": "colors" },
{ "name": "The Vencido Exception", "body": "Every status pill uses the tinted-fill pattern except an overdue loan (\"Vencido\"), which gets a solid peligro fill with white text — on both the table badge and, on mobile, a light danger wash across the entire card background. This is the system's one permitted alarm.", "section": "colors" },
{ "name": "The Tabular Numbers Rule", "body": "Any figure that represents a count — cantidad, stock (disponible/total), KPI values, dates — is rendered with font-variant-numeric: tabular-nums. Numbers must align like a ledger column.", "section": "typography" },
{ "name": "The Table/Card Swap Rule", "body": "Every data-dense admin view ships two complete renderings of the same data: a table for md:block and a card list for md:hidden. This is a structural swap, not a responsive squeeze.", "section": "layout" },
{ "name": "The Flat Page, Floating Chrome Rule", "body": "If an element is part of the document (a card, a table, a form), it stays flat with a hairline border. If an element floats above the document regardless of scroll (the dock, a toast), it gets a soft ambient shadow plus backdrop-blur.", "section": "elevation" }
],
"dos": [
"Do use color-mix(in oklab, var(--color-tinta) N%, transparent) for any new border or divider — never a literal gray.",
"Do keep shadow exclusive to floating overlay chrome (toasts, the mobile dock).",
"Do use the tinted-pill pattern (1215% background, full-strength text) for any new status or state indicator.",
"Do apply font-variant-numeric: tabular-nums to any new numeric quantity, stock figure, or date.",
"Do ship both a md:block table and a md:hidden card-list rendering for any new data-dense admin view.",
"Do gate all motion with motion-safe: and animate specific properties, never transition-all.",
"Do give every interactive control a 44px minimum tap target and touch-action: manipulation."
],
"donts": [
"Don't add a shadow to .card, .btn, or .input — the system's flatness on the page plane is deliberate.",
"Don't introduce a second typeface. Inter Variable carries every role through weight and size.",
"Don't give a status pill a solid saturated fill — the tinted wash is the rule, and Vencido is the one confirmed exception, not a precedent to extend.",
"Don't reach for rounded-2xl outside the login mark and toasts; it is reserved for \"first impression / interruption\" moments.",
"Don't replace the mobile bottom dock with a hamburger drawer, or the desktop sidebar with a top bar — the device-appropriate nav pattern per breakpoint is deliberate."
]
}
}
+14
View File
@@ -154,3 +154,17 @@ Fases 3, 4/5 y 6 tocan carpetas de rutas distintas (`src/pages/alumno/*`, `src/p
- Confirmado en prod por el usuario: "Marcar devuelto" ahora funciona. El mismo fix cubre aprobar/rechazar y cualquier otro POST admin que hubiera fallado con el mismo síntoma.
- Limpieza: removido `/api/whoami` (endpoint diagnóstico) y el bloque `debug` que devolvía email+rol en el 403 del endpoint devolver. Ya no aplican.
- Aprendizaje: cuando un endpoint responde 403 con **texto plano** ("Cross-site POST..."), es Astro; cuando responde 403 con **JSON** (`{"error":"no autorizado"}`), es lógica del endpoint. Ese matiz habría acelerado el diagnóstico.
- **2026-08-16 — Auditoría `/impeccable`: "quita el sloop de IA y reforma el diseño"**.
- Se generaron `PRODUCT.md` y `DESIGN.md` (skill impeccable) documentando el sistema de diseño ya implementado ("La Bitácora Digital"). Veredicto tras leer todo el código y capturar screenshots reales (login, catálogo, panel admin, solicitudes, inventario, reportes, mobile) vía un bypass temporal de middleware solo-DEV (`?preview=alumno|admin`, revertido al terminar): el sistema **no** tenía slop visual genérico típico de IA (sin gradientes, sin glassmorphism, sin morado/azul SaaS, sin emojis) — el trabajo previo ya era disciplinado. El slop real encontrado era puntual:
1. El favicon era el cohete default de Astro (boilerplate del scaffold, nunca reemplazado).
2. El "logo" era un cuadrado con la letra "L" — el placeholder más genérico posible, repetido en sidebar, header mobile y login sin ningún glifo propio detrás.
3. `/admin` para no-admins devolvía texto plano sin estilo (`new Response('Acceso denegado', {status:403})`), rompiendo el sistema de diseño.
4. No existía página 404 propia (caía al default de Astro).
5. Íconos "fake": los botones de cerrar modal usaban el carácter `×`, el estado vacío de solicitudes usaba ``, el chevron de historial usaba `` — inconsistente con el resto del sistema que sí dibuja íconos SVG reales (heroicons-outline).
6. Código muerto/inconsistente: `EliminarMaterial` y `RechazarDialog` cambiaban color en hover vía `onMouseEnter/onMouseLeave` en JS (con una custom property `--h` sin usar) en vez de CSS, y el dropdown de `Autocomplete` usaba `shadow-lg` de Tailwind en vez de respetar la regla "sin sombra fuera de dock/toast" del sistema.
- Fix aplicado: nuevo glifo de marca propio (`src/components/BrandMark.astro`) — una ficha/etiqueta con perforación, evoca el ticket de préstamo físico — reemplaza la "L" en sidebar/header/login y es ahora también el favicon (`public/favicon.svg` + `.ico` regenerado). Es un mark de producto, no un intento de escudo institucional UABC (ese sigue pendiente y sin fabricar, por instrucción explícita). Se unificó el color del chip del mark (antes dorado en nav vs. verde en login) a wash blanco/verde consistente. Se agregaron `src/pages/403.astro` y `src/pages/404.astro` con el mismo lenguaje visual del login; el middleware ahora usa `context.rewrite('/403')` en vez de devolver texto plano. Los `×`/``/`` se reemplazaron por SVG inline consistentes con el stroke-style existente. Se limpió el hover JS→CSS y el `shadow-lg` del autocomplete.
- Documentación actualizada: `PRODUCT.md` (Brand Commitments ya no dice "100% tipográfica sin logo"; aclara que el BrandMark es mark de producto, no escudo oficial) y `DESIGN.md` (nueva subsección "Brand Mark" en Components, nueva regla Do/Don't: "todo ícono es SVG dibujado, nunca un carácter unicode").
- Verificación: `npm run build` limpio, detector mecánico de impeccable (`detect.mjs`) sin hallazgos sobre los 15 archivos tocados, screenshots antes/después confirmando visualmente cada fix (incluyendo hover de "Eliminar" y el toggle del historial).
- No se tocó IA de información (paneles Panel vs. Estadísticas se solapan en KPIs pero no se reestructuraron — fuera de alcance de "quitar slop"), ni se generó contenido de relleno para las páginas con poco dato real (1 material de seed) — eso es estado temprano de datos, no slop de diseño.
- Estado: cambios visuales completos y verificados. Servidor de dev quedó corriendo en background (`astro dev status`/`astro dev logs`/`astro dev stop` para gestionarlo). Pendiente: deploy a producción cuando el usuario lo confirme.
+226
View File
@@ -0,0 +1,226 @@
---
name: LabPréstamos
description: Sistema institucional UABC para préstamo de material de laboratorio — la bitácora de papel y Excel, digitalizada.
colors:
verde-institucional: "#00723F"
verde-institucional-hover: "#005f34"
dorado-uabc: "#DD971A"
dorado-uabc-hover: "#c78614"
superficie: "#F4F7F5"
tinta: "#2D3748"
peligro: "#C53030"
typography:
headline:
fontFamily: "Inter Variable, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif"
fontSize: "1.5rem (2xl) → 1.875rem (3xl) md+"
fontWeight: 600
lineHeight: 1.25
title:
fontFamily: "Inter Variable, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif"
fontSize: "1.125rem"
fontWeight: 600
lineHeight: 1.4
body:
fontFamily: "Inter Variable, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif"
fontSize: "0.875rem"
fontWeight: 400
lineHeight: 1.5
label:
fontFamily: "Inter Variable, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif"
fontSize: "0.875rem"
fontWeight: 500
lineHeight: 1.3
caption:
fontFamily: "Inter Variable, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif"
fontSize: "0.75rem"
fontWeight: 400
lineHeight: 1.4
rounded:
control: "0.5rem"
emphasis: "1rem"
pill: "9999px"
spacing:
tight: "0.5rem"
base: "1rem"
loose: "1.5rem"
section: "2rem"
components:
button-primary:
backgroundColor: "{colors.verde-institucional}"
textColor: "#ffffff"
rounded: "{rounded.control}"
padding: "10px 16px"
button-secondary:
backgroundColor: "{colors.dorado-uabc}"
textColor: "#ffffff"
rounded: "{rounded.control}"
padding: "10px 16px"
button-ghost:
backgroundColor: "transparent"
textColor: "{colors.tinta}"
rounded: "{rounded.control}"
padding: "10px 16px"
card:
backgroundColor: "#ffffff"
textColor: "{colors.tinta}"
rounded: "{rounded.control}"
padding: "24px"
input:
backgroundColor: "#ffffff"
textColor: "{colors.tinta}"
rounded: "{rounded.control}"
padding: "10px 12px"
---
# Design System: LabPréstamos
## Overview
**Creative North Star: "La Bitácora Digital"**
LabPréstamos is the printed loan sheet and the Excel logbook it used to feed, collapsed into one live surface. Every visual decision defends that lineage: nothing decorative, nothing that would feel out of place next to an official UABC form, but nothing that feels like a cold spreadsheet either. The system is **cálido pero confiable** — warm but reliable — and it earns the "warm" half narrowly: a gold accent, soft rounded corners, and gentle motion sit on top of an otherwise flat, administrative base.
The page itself stays flat and quiet — white cards on a pale green-grey surface, hairline borders instead of shadows, tabular numbers lined up like a real ledger. Depth is reserved for the two things that are genuinely *floating above* the page: the toast notifications and the mobile navigation dock. That asymmetry is deliberate — it is how the system tells the eye "this is the document" from "this is the chrome."
Two audiences read this bitácora differently. The alumno's default device is the phone — the catalog, the request modal, the pill-shaped floating dock are all tuned for thumbs. The admin's default device is the desktop — dense tables, a persistent sidebar, KPI tiles. Neither view degrades on the other's device; every screen ships both a table and a card-list rendering.
**Key Characteristics:**
- Flat administrative base (white cards, hairline ink-tinted borders, no ambient shadow)
- Shadow appears only on floating overlay chrome — the mobile dock and toasts — never on the page plane
- Every status is a tinted pill: 1215% color wash background, full-strength text
- Tabular numbers everywhere a quantity, date, or stock figure appears
- One typeface (Inter Variable) carrying every role through weight and size alone
- Motion is a courtesy, not a performance: `motion-safe:`-gated, specific-property transitions, no `transition-all`
## Colors
Two institutional hues carry the whole palette under a strict 60/30/10 split — green dominant, gold as a deliberately rare accent, everything else neutral ink and white.
### Primary
- **Verde Institucional** (`#00723F`): the UABC green. Primary buttons, the desktop sidebar's entire background, active nav states, focus rings, "healthy/positive" status pills (disponible, aprobado, devuelto), the login mark. This is the color of the system's authority — it appears on anything that confirms, approves, or orients.
### Secondary
- **Dorado UABC** (`#DD971A`): the institutional gold. Used sparingly and only as an accent — secondary buttons, the "en préstamo" KPI tile, "mantenimiento" status pills, the logo-mark square on the sidebar/mobile header. Never the dominant color of a screen.
### Neutral
- **Superficie** (`#F4F7F5`): the page background — a near-white green-grey, not pure white. Everything else (cards, dialogs, the desktop sidebar's text, dock) sits on top of it.
- **Tinta** (`#2D3748`): body text, and — critically — the **source color for every border in the system**. Borders are never a fixed gray; they're `color-mix(in oklab, var(--color-ink) N%, transparent)` at 820% depending on prominence. This ties every hairline to the same ink token instead of a separate gray scale.
- **Peligro** (`#C53030`): errors, destructive actions, and the one status that is allowed to break the tinted-pill convention (see Named Rules).
### Named Rules
**The Ink-Mix Border Rule.** Every hairline border and divider is `color-mix(in oklab, var(--color-tinta) N%, transparent)`, never a literal gray hex. A border is always "ink, diluted," so it stays coherent with body text at any opacity.
**The Vencido Exception.** Every status pill uses the tinted-fill pattern (see Components → Badges) *except* an overdue loan ("Vencido"), which gets a solid `peligro` fill with white text — on both the table badge and, on mobile, a light danger wash across the entire card background. This is the system's one permitted alarm: reserve a solid semantic fill for the single state that genuinely needs to interrupt a scan, and only that state.
## Typography
**Body & Display Font:** Inter Variable (with `ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif` fallback)
**Character:** One typeface for the entire system — hierarchy comes from weight and size, never a second family. It reads as a single, consistent institutional voice rather than a "brand font + UI font" split.
### Hierarchy
- **Headline** (600, 1.5rem → 1.875rem at `md:`, 1.25 line-height): page-level `<h1>`s — "Mis préstamos", "Estadísticas", the login screen's title.
- **Title** (600, 1.125rem, 1.4 line-height): section and card headers — "Inicia sesión", "Inventario", empty-state headings.
- **Body** (400, 0.875rem, 1.5 line-height): the default text size for the whole system — table cells, descriptions, list content. Notably small-scale throughout; this is a dense operational tool, not editorial content.
- **Label** (500, 0.875rem): form field labels (`.label`), rendered at 90% ink opacity.
- **Caption** (400, 0.75rem, opacity 6080%): metadata — matrícula, inventory numbers, timestamps, hint text under fields.
### Named Rules
**The Tabular Numbers Rule.** Any figure that represents a count — cantidad, stock (`disponible / total`), KPI values, dates — is rendered with `font-variant-numeric: tabular-nums`. Numbers must align like a ledger column, because that is literally what they replace.
## Layout
The system runs on Tailwind's default breakpoint (`md:` = 768px) as the single hard split between the mobile and desktop layouts — there is no intermediate tablet treatment.
**Desktop (≥768px):** a fixed 256px (`w-64`) left sidebar in solid Verde Institucional, full page height. It is collapsible: a hamburger button above the content toggles a `sidebar-collapsed` class on `<html>`, animated with a 200ms `translateX(-100%)`; the choice persists in `localStorage` and is applied via a blocking inline `<script>` in `<head>` before paint, so there is no collapse flash on reload. Content sits in `max-w-6xl` (data-dense admin views) or `max-w-4xl`/`max-w-md` (narrower alumno views), with `p-8` outer padding.
**Mobile (<768px):** no sidebar. A minimal fixed header (logo mark + user identity) sits at the top; navigation lives instead in a floating pill-shaped dock anchored to the bottom-center of the viewport, respecting `env(safe-area-inset-bottom)`. Content padding drops to `p-4` with `pb-28` to clear the dock.
**The Table/Card Swap Rule.** Every data-dense admin view (solicitudes, inventario, reportes) ships two complete renderings of the same data: a `<table>` for `md:block` and a `<ul>` of `.card` items for `md:hidden`. This is a structural swap, not a responsive squeeze — the mobile version is not a scrolled-sideways table, it is a genuinely different layout for the same rows.
## Elevation & Depth
The system is flat by default. Cards, buttons, inputs, and dialogs carry no shadow — only a 1px ink-mix hairline border. Depth is reserved, deliberately, for the two things that are not part of the page: the mobile dock nav and the toast stack, both of which visually float above whatever is scrolling underneath them.
### Shadow Vocabulary
- **Floating chrome** (`box-shadow: 0 10px 30px -8px rgba(0,0,0,0.25)`): the mobile dock nav — a soft, diffuse shadow that reads as "hovering," combined with `backdrop-blur` and a translucent white background.
- **Overlay notification** (`box-shadow: 0 16px 40px -12px rgba(0,0,0,0.25)`): the toast stack — same diffuse quality, slightly heavier, paired with `backdrop-blur-xl` and `bg-white/85`.
### Named Rules
**The Flat Page, Floating Chrome Rule.** If an element is part of the document (a card, a table, a form), it stays flat with a hairline border. If an element floats above the document regardless of scroll (the dock, a toast), it gets a soft ambient shadow plus backdrop-blur. Shadow is the visual signal for "this is not part of the page you're reading."
## Shapes
Three radius steps, used by role rather than by component:
- **Control** (`0.5rem`, token `--radius-card`): the default — buttons, cards, inputs, dialogs, table containers. This is the system's baseline corner.
- **Emphasis** (`1rem`, `rounded-2xl`): reserved for two "first impression" surfaces — the login screen's avatar mark and toast notifications. A visibly softer corner than the control radius, used to signal warmth at the exact moments the system greets or interrupts the user.
- **Pill** (`9999px`, `rounded-full`): status badges, the mobile dock and its buttons, the pending-count chip. Anything that is a small, discrete piece of state rather than a container.
Borders are always hairline (1px) and always ink-mix (see Colors → Named Rules); the system has no heavy or decorative border weight.
## Components
### Brand Mark
- A single authored glyph (`BrandMark.astro`, 24×24 viewBox) stands in for the system's identity everywhere a mark is needed: the desktop sidebar header, the mobile header, the login screen, and the 403/404 states. It reads as a loan tag with a punch-hole — a quiet nod to the physical préstamo ticket the system replaces.
- It is a solid silhouette (not an outline stroke like the nav icons), rendered via `currentColor` so it can sit white-on-primary or tinted-on-danger without a second asset.
- It is the product's own mark, not an institutional crest — it never stands in for an official UABC or laboratorio seal.
- The favicon (`public/favicon.svg`) is the same glyph, colored Verde Institucional in light mode and Superficie in dark mode.
### Buttons
- **Shape:** Control radius (0.5rem), 44px minimum height, `touch-action: manipulation` — every button is a comfortable tap target regardless of platform.
- **Primary:** Verde Institucional background, white text. The system's default call to action (submit, approve, continuar con Google).
- **Secondary:** Dorado UABC background, white text. Used sparingly — filtering actions, not primary flows.
- **Ghost:** Transparent background, ink text, hairline ink-mix border (15%). Used for "Cancelar" / "Limpiar" — the low-commitment counterpart to Primary.
- **Hover / Focus:** hover is a uniform `motion-safe:hover:brightness-110` filter across all filled variants (not a swap to a separate hover-color token); focus is a 2px solid outline in Verde Institucional, 2px offset. Disabled drops to 50% opacity and disables pointer events.
### Badges / Status Pills
- **Style:** pill radius, `text-xs`/`text-sm font-semibold`, `px-2` to `px-2.5`, `py-0.5`.
- **The Tinted Pill Rule.** Background = the semantic color at 1215% via `color-mix`; text = the semantic color at full strength. Never a solid fill — with the one confirmed exception below.
- **Semantic mapping:** disponible / aprobado / devuelto → Verde Institucional tint. mantenimiento → Dorado UABC tint (text at the `-hover` shade for contrast). baja / rechazado → ink tint (neutral, ~810%). **Vencido** (overdue) → the sole solid-fill exception: white text on full-strength Peligro, plus a light danger wash across the whole card on mobile.
### Cards / Containers
- **Corner:** Control radius (0.5rem).
- **Background:** white, always — the one surface color that appears identically on both the pale-green page background and the green sidebar.
- **Border:** hairline ink-mix (10%); no shadow (see Elevation).
- **Internal Padding:** `p-6` for standard cards and forms; `p-4` for dense KPI tiles; `p-0` when the card is a table/list wrapper (the table supplies its own cell padding).
### Inputs / Fields
- **Style:** white background, hairline ink-mix border (20%), Control radius, 44px minimum height.
- **Focus:** 2px solid Verde Institucional outline, 2px offset — the same focus treatment as buttons and links, no separate glow or border-color shift.
- **Label:** `.label` class, 0.875rem/500 weight, ink at 90% opacity, always paired via `htmlFor`/`id`.
- **Error:** inline `role="alert"` text in Peligro below the field, not a border-color change.
### Navigation
- **Desktop sidebar:** solid Verde Institucional panel, white text at 85% opacity for inactive items, white/15% background wash for the active item, `aria-current="page"`. Collapsible (see Layout).
- **Mobile dock:** floating pill, `backdrop-blur` + `bg-white/95`, icon-only (24×24 heroicons-outline, `stroke-width: 1.8`, `currentColor`), active item = solid Verde Institucional circle. Sign-out is visually separated by a vertical hairline divider and turns Peligro on hover.
### Toasts (signature component)
- Fixed top-center overlay, `rounded-2xl`, `bg-white/85` + `backdrop-blur-xl`, floating-chrome shadow.
- A circular color chip (36px) leads each toast: Verde Institucional for success, Peligro for error, Dorado UABC for info — each with a small stroke icon.
- Entrance: 260ms `cubic-bezier(0.22, 1, 0.36, 1)` translate+scale+fade; auto-dismisses at 3.8s; tap anywhere to dismiss early; fully respects `prefers-reduced-motion`.
- Survives a `location.reload()` via a `sessionStorage` queue — the system was built assuming most actions end in a reload, and the toast must not be lost.
### Modals
- Native `<dialog>` element (no custom overlay library), Control radius, `backdrop:bg-black/40`, width `min(92vw, 28rem)`.
- Focus moves to the first field on open; closes on backdrop click or the close-icon control (a drawn SVG X, never a `×` character); disabled while a submit is in flight.
## Do's and Don'ts
### Do:
- **Do** use `color-mix(in oklab, var(--color-tinta) N%, transparent)` for any new border or divider — never a literal gray.
- **Do** keep shadow exclusive to floating overlay chrome (toasts, the mobile dock). A new floating element earns the same shadow vocabulary; a page element does not.
- **Do** use the tinted-pill pattern (1215% background, full-strength text) for any new status or state indicator.
- **Do** apply `font-variant-numeric: tabular-nums` to any new numeric quantity, stock figure, or date.
- **Do** ship both a `md:block` table and a `md:hidden` card-list rendering for any new data-dense admin view.
- **Do** gate all motion with `motion-safe:` and animate specific properties (`transition-colors`, `transition-[filter,...]`), never `transition-all`.
- **Do** give every interactive control a 44px minimum tap target and `touch-action: manipulation`.
- **Do** draw every icon as an SVG (the shared heroicons-outline language, or the `BrandMark` glyph for identity) — close controls, chevrons, and status glyphs included.
### Don't:
- **Don't** stand in a unicode glyph (`×`, ``, `✓`, emoji) for an icon — every icon is a drawn SVG, one consistent stroke and weight.
- **Don't** add a shadow to `.card`, `.btn`, or `.input` — the system's flatness on the page plane is deliberate.
- **Don't** introduce a second typeface. Inter Variable carries every role through weight and size.
- **Don't** give a status pill a solid saturated fill — the tinted wash is the rule, and "Vencido" is the system's one confirmed exception, not a precedent to extend.
- **Don't** reach for `rounded-2xl` outside the login mark and toasts; it is reserved for "first impression / interruption" moments, not a general card radius.
- **Don't** replace the mobile bottom dock with a hamburger drawer, or the desktop sidebar with a top bar — the device-appropriate nav pattern per breakpoint is an established, deliberate split.
+65
View File
@@ -0,0 +1,65 @@
# Product
<!-- impeccable:product-schema 1 -->
## Platform
web
## Users
- **Alumno**: estudiante UABC (correo `@uabc.edu.mx`) que solicita préstamo de material del laboratorio para una clase o proyecto. Usa el sistema predominantemente desde el celular — navega el catálogo por categoría, solicita material indicando motivo, y da seguimiento a sus préstamos activos/historial.
- **Admin**: encargado del laboratorio que gestiona solicitudes (aprobar/rechazar/marcar devuelto), mantiene el inventario (materiales/categorías) y consulta reportes/estadísticas. Usa el sistema predominantemente desde escritorio (tablas), con vistas mobile en tarjetas.
Ambos roles son cross-device aunque cada uno tiene un dispositivo primario distinto.
## Product Purpose
Antes de este sistema, el préstamo de material era enteramente manual: petición en papel → captura en una bitácora de Excel → vaciado manual de esa bitácora en reportes finales. El sistema unifica ese flujo en un solo lugar con estado en tiempo real (pendiente → aprobado/rechazado → devuelto), control de stock automático y export de reportes, eliminando la triple captura y su riesgo de errores de transcripción y pérdida de trazabilidad.
Éxito = que el laboratorio deje de depender de papel/Excel para el ciclo completo de préstamo, con historial y reportes confiables generados directamente del sistema.
## Positioning
Herramienta interna de un solo laboratorio (Laboratorio de Sistemas Computacionales, UABC) — no un producto multi-tenant ni una plantilla pensada para otros departamentos. Su ventaja no es competir con otro software: es reemplazar tres capturas manuales redundantes (papel, bitácora Excel, reporte final) por un único flujo trazable con control de acceso institucional (Google OAuth restringido a `@uabc.edu.mx`).
## Operating Context
- Login exclusivo con Google, restringido al dominio institucional `@uabc.edu.mx` (verificado en middleware de aplicación, no en Supabase Auth global — la instancia de Supabase es compartida con otro proyecto).
- Ciclo del préstamo: `pendiente → aprobado → devuelto` (con `rechazado` como salida alterna desde pendiente). El estado `activo` del enum de DB está reservado pero no se usa en el flujo actual — `aprobado` cumple ese rol.
- El stock (`cantidad_disponible`) se ajusta automáticamente vía triggers de base de datos según las transiciones de estado, no en código de aplicación.
- Todo cambio de estado queda en `audit_log` para trazabilidad — esto reemplaza la bitácora en papel/Excel.
- Reportes con filtros (fecha, material, alumno, estado) y export CSV nativo, para cerrar el ciclo que antes terminaba en Excel manual.
## Capabilities and Constraints
- Backend: Supabase self-hosted compartido con otro proyecto (schema `prestamos` aislado de `public`). No se despliega infraestructura nueva de Supabase.
- Auth restringida a `@uabc.edu.mx` en dos capas: trigger en `auth.users` (rechaza altas fuera de dominio) + verificación en middleware de Astro.
- Alta de administradores es manual (Supabase Studio/psql) — no hay flujo de auto-promoción a admin en la UI.
- Hosting propio (buglabs) vía Docker + adapter `@astrojs/node`, detrás de Cloudflare Tunnel.
- v1 sin reglas duras de límite de préstamo o duración — el admin decide caso por caso.
## Brand Commitments
- Paleta institucional UABC bajo regla 60/30/10: primario `#00723F` (verde), secundario `#DD971A` (dorado), superficie `#F4F7F5`, texto `#2D3748`, error `#C53030`.
- Tipografía: Inter Variable.
- La identidad visual usa un glifo propio de producto (`BrandMark`, una ficha/etiqueta con perforación — evoca la ficha de préstamo física) como wordmark gráfico y favicon; no es ni pretende ser el escudo/sello oficial de UABC o del laboratorio. **Existe un logo institucional oficial** pendiente de integrar — el usuario lo proporcionará como asset; hasta entonces no inventar ni aproximar ese escudo.
- Admin es desktop-first, alumno es mobile-first; ambos cross-device. Minimalista, con animaciones ligeras (`motion-safe`).
## Evidence on Hand
- Sin logo oficial entregado todavía (ver Brand Commitments) — no fabricar uno.
- Sin contenido de marketing, testimonios, casos de estudio o cifras de uso real — es una herramienta operativa interna, no una superficie de persuasión.
- El código en `src/` y el schema en `supabase/migrations/0001_init.sql` son la fuente de verdad del modelo de datos y flujos ya implementados.
## Product Principles
1. Un solo flujo digital reemplaza la triple captura manual (papel → Excel → reporte) — cualquier fricción nueva que reintroduzca captura duplicada es una regresión.
2. Estado y stock son siempre reflejo de la base de datos (triggers), nunca de lógica optimista en el cliente — la UI refleja, no decide.
3. Alumno mobile-first, admin desktop-first, ambos cross-device — ninguna vista puede degradarse a inusable en el dispositivo no primario.
4. Acceso gated por dominio institucional en dos capas — cualquier superficie nueva hereda esa verificación, no la reimplementa.
5. Sin dependencias ni infraestructura nuevas salvo pedido explícito — el servidor y el Supabase son compartidos con otro sistema (genqbar).
## Accessibility & Inclusion
WCAG AA no negociable (compromiso establecido en la Fase 2 de diseño). Sin necesidades de accesibilidad específicas de usuarios reales reportadas hasta ahora.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 655 B

After

Width:  |  Height:  |  Size: 22 KiB

+4 -4
View File
@@ -1,9 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M2 12 L9 4 H16.5 A2.5 2.5 0 0 1 19 6.5 V17.5 A2.5 2.5 0 0 1 16.5 20 H9 Z M7.3 12 a1.3 1.3 0 1 0 -2.6 0 a1.3 1.3 0 1 0 2.6 0 Z" />
<style>
path { fill: #000; }
path { fill: #00723F; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
path { fill: #F4F7F5; }
}
</style>
</svg>

Before

Width:  |  Height:  |  Size: 749 B

After

Width:  |  Height:  |  Size: 380 B

+9
View File
@@ -0,0 +1,9 @@
---
interface Props {
class?: string;
}
const { class: className = '', ...rest } = Astro.props;
---
<svg viewBox="0 0 24 24" class={className} fill="currentColor" fill-rule="evenodd" aria-hidden="true" {...rest}>
<path d="M2 12 L9 4 H16.5 A2.5 2.5 0 0 1 19 6.5 V17.5 A2.5 2.5 0 0 1 16.5 20 H9 Z M7.3 12 a1.3 1.3 0 1 0 -2.6 0 a1.3 1.3 0 1 0 2.6 0 Z" />
</svg>
@@ -53,11 +53,9 @@ export default function EliminarMaterial({ id, nombre, tienePrestamos }: Props)
<>
<button
type="button"
className="btn btn-ghost transition-colors duration-150 hover:!text-white"
className="btn btn-ghost hover:!bg-[color:var(--color-danger)] hover:!text-white"
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
onClick={open}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-danger)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
Eliminar
</button>
@@ -143,9 +143,11 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
type="button"
onClick={close}
aria-label="Cerrar"
className="rounded-lg p-1 hover:bg-black/5 transition-colors leading-none text-xl"
className="rounded-lg p-1.5 hover:bg-black/5 transition-colors"
>
<span aria-hidden="true">×</span>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
@@ -126,7 +126,9 @@ export function Autocomplete({ name, endpoint, label, placeholder, initialValue,
/>
{(text || value) && (
<button type="button" className="btn btn-ghost px-3" onClick={clear} aria-label="Limpiar">
<span aria-hidden="true">×</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
@@ -134,7 +136,7 @@ export function Autocomplete({ name, endpoint, label, placeholder, initialValue,
<ul
id={listId}
role="listbox"
className="absolute z-10 mt-1 w-full bg-white rounded-lg shadow-lg max-h-64 overflow-auto"
className="absolute z-10 mt-1 w-full bg-white rounded-lg max-h-64 overflow-auto"
style={{ border: '1px solid color-mix(in oklab, var(--color-ink) 15%, transparent)' }}
>
{loading && items.length === 0 && (
@@ -173,11 +173,8 @@ function RechazarDialog({ prestamo }: { prestamo: Prestamo }) {
<>
<button
type="button"
className="btn btn-ghost"
className="btn btn-ghost hover:!text-[color:var(--color-danger)]"
onClick={dlg.open}
style={{ ['--h' as any]: 'var(--color-danger)' }}
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-danger)')}
onMouseLeave={(e) => (e.currentTarget.style.color = '')}
>
Rechazar
</button>
@@ -55,7 +55,11 @@ export default function VerDetalles({ prestamoId }: { prestamoId: number }) {
<div className="p-6">
<div className="flex items-start justify-between gap-4">
<h2 className="text-lg font-semibold">Detalle del préstamo</h2>
<button type="button" className="btn btn-ghost" onClick={() => ref.current?.close()} aria-label="Cerrar">×</button>
<button type="button" className="rounded-lg p-1.5 hover:bg-black/5 transition-colors" onClick={() => ref.current?.close()} aria-label="Cerrar">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{loading && <p className="mt-4 text-sm opacity-70">Cargando</p>}
+4 -2
View File
@@ -106,9 +106,11 @@ export default function SolicitarModal({ material }: Props) {
type="button"
onClick={close}
aria-label="Cerrar"
className="rounded-lg p-1 hover:bg-black/5 transition-colors leading-none text-xl"
className="rounded-lg p-1.5 hover:bg-black/5 transition-colors"
>
<span aria-hidden="true">×</span>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
+7 -2
View File
@@ -1,6 +1,7 @@
---
import Layout from './Layout.astro';
import Toaster from '@/components/Toaster.tsx';
import BrandMark from '@/components/BrandMark.astro';
interface Props {
title?: string;
@@ -62,7 +63,9 @@ const activeCheck = (item: NavItem) => {
<!-- Sidebar desktop -->
<aside id="app-sidebar" class="hidden md:flex md:fixed md:top-0 md:left-0 md:w-64 md:h-screen bg-[color:var(--color-primary)] text-white md:flex-col md:z-40 sidebar-panel transition-transform duration-200 ease-out">
<a href="/" class="p-6 flex items-center gap-3 border-b border-white/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white" aria-label="LabPréstamos — Inicio">
<div class="w-9 h-9 rounded-lg bg-[color:var(--color-secondary)] grid place-items-center font-bold" aria-hidden="true">L</div>
<div class="w-9 h-9 rounded-lg bg-white/15 grid place-items-center p-2" aria-hidden="true">
<BrandMark class="w-full h-full text-white" />
</div>
<div>
<div class="font-semibold leading-tight">LabPréstamos</div>
<div class="text-xs text-white/70">UABC · Lab. Sistemas</div>
@@ -106,7 +109,9 @@ const activeCheck = (item: NavItem) => {
<!-- Header mobile mínimo -->
<header class="md:hidden flex items-center justify-between px-4 py-3 bg-[color:var(--color-primary)] text-white">
<a href="/" class="flex items-center gap-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white rounded-lg" aria-label="LabPréstamos — Inicio">
<div class="w-8 h-8 rounded-lg bg-[color:var(--color-secondary)] grid place-items-center font-bold text-sm" aria-hidden="true">L</div>
<div class="w-8 h-8 rounded-lg bg-white/15 grid place-items-center p-1.5" aria-hidden="true">
<BrandMark class="w-full h-full text-white" />
</div>
<span class="font-semibold text-sm">LabPréstamos</span>
</a>
<div class="text-xs text-white/80 truncate max-w-[45%] text-right">
+1 -1
View File
@@ -39,7 +39,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
}
if (pathname.startsWith('/admin') && context.locals.profile?.rol !== 'admin') {
return new Response('Acceso denegado', { status: 403 });
return context.rewrite('/403');
}
return next();
+18
View File
@@ -0,0 +1,18 @@
---
import Layout from '@/layouts/Layout.astro';
import BrandMark from '@/components/BrandMark.astro';
Astro.response.status = 403;
---
<Layout title="Acceso denegado — LabPréstamos">
<main id="main" class="min-h-screen grid place-items-center p-4">
<div class="w-full max-w-md text-center">
<div class="inline-grid place-items-center w-16 h-16 rounded-2xl mb-4 p-4" style="background: color-mix(in oklab, var(--color-danger) 12%, transparent);">
<BrandMark class="w-full h-full" style="color: var(--color-danger);" />
</div>
<h1 class="text-2xl font-semibold">Acceso denegado</h1>
<p class="text-sm opacity-70 mt-2">Esta sección es solo para administradores del laboratorio.</p>
<a href="/" class="btn btn-primary mt-6 inline-flex">Volver al inicio</a>
</div>
</main>
</Layout>
+18
View File
@@ -0,0 +1,18 @@
---
import Layout from '@/layouts/Layout.astro';
import BrandMark from '@/components/BrandMark.astro';
Astro.response.status = 404;
---
<Layout title="Página no encontrada — LabPréstamos">
<main id="main" class="min-h-screen grid place-items-center p-4">
<div class="w-full max-w-md text-center">
<div class="inline-grid place-items-center w-16 h-16 rounded-2xl bg-[color:var(--color-primary)] mb-4 p-4">
<BrandMark class="w-full h-full text-white" />
</div>
<h1 class="text-2xl font-semibold">Página no encontrada</h1>
<p class="text-sm opacity-70 mt-2">La dirección no existe o cambió de lugar.</p>
<a href="/" class="btn btn-primary mt-6 inline-flex">Volver al inicio</a>
</div>
</main>
</Layout>
+3 -1
View File
@@ -59,7 +59,9 @@ const tabs = [
{filas.length === 0 ? (
<div class="card text-center py-12">
<div class="text-4xl mb-3" aria-hidden="true">✓</div>
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="mx-auto mb-3" aria-hidden="true">
<path d="M4.5 12.75l6 6 9-13.5" />
</svg>
<p class="font-medium">No hay solicitudes por revisar.</p>
<p class="text-sm opacity-70 mt-1">Todo al día.</p>
</div>
+5 -4
View File
@@ -129,7 +129,9 @@ const badgeStyle = (e: Estado) =>
<section>
<details open={historial.length <= 5}>
<summary class="text-lg font-semibold cursor-pointer list-none flex items-center gap-2">
<span aria-hidden="true"></span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 6l6 6-6 6" />
</svg>
Historial <span class="text-sm opacity-60" style="font-variant-numeric: tabular-nums;">({historial.length})</span>
</summary>
{historial.length === 0 ? (
@@ -174,9 +176,8 @@ const badgeStyle = (e: Estado) =>
<style>
details > summary::-webkit-details-marker { display: none; }
details[open] > summary > span[aria-hidden] { transform: rotate(90deg); }
details > summary > span[aria-hidden] {
display: inline-block;
details[open] > summary > svg { transform: rotate(90deg); }
details > summary > svg {
transition: transform 150ms ease-out;
}
</style>
+4 -1
View File
@@ -1,5 +1,6 @@
---
import Layout from '@/layouts/Layout.astro';
import BrandMark from '@/components/BrandMark.astro';
const url = Astro.url;
const errorParam = url.searchParams.get('error');
@@ -15,7 +16,9 @@ const googleAuthUrl = '/api/auth/signin';
<main id="main" class="min-h-screen grid place-items-center p-4">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<div class="inline-grid place-items-center w-16 h-16 rounded-2xl bg-[color:var(--color-primary)] text-white font-bold text-3xl mb-4 motion-safe:animate-[fadein_.4s_ease-out]">L</div>
<div class="inline-grid place-items-center w-16 h-16 rounded-2xl bg-[color:var(--color-primary)] p-4 mb-4 motion-safe:animate-[fadein_.4s_ease-out]">
<BrandMark class="w-full h-full text-white" />
</div>
<h1 class="text-2xl md:text-3xl font-semibold">Sistema de Préstamos</h1>
<p class="text-sm mt-2 opacity-70">Laboratorio de Sistemas Computacionales · UABC</p>
</div>