UI: navegación SPA, tabs sin bordes, patrón de fondo fijo

- ClientRouter (view transitions) para navegación sin recarga entera;
  Toaster y BadgeSolicitudes marcados con transition:persist para no
  rearmar la suscripción realtime en cada nav.
- Overlay de carga ahora activa a los 250ms del swap (evita parpadeo).
- Tabs de subnav (inventario/categorías/maestros, solicitudes, reportes)
  reescritos: sin border-b-2 ni underline verde; activo usa bg-linen
  como el sidebar. Coherente y sin patrón AI-slop.
- Marco horizontal debajo de todos los tab strips removido.
- Patrón de puntos verde del canvas ahora con background-attachment:
  fixed — estático en scroll.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014xaxs8cGcQe9NwbMqA1gvn
This commit is contained in:
2026-09-21 14:52:56 -07:00
parent ac3e7061ac
commit 7bab90b408
10 changed files with 53 additions and 49 deletions
+2 -2
View File
@@ -243,8 +243,8 @@ const activeCheck = (item: NavItem) => {
</form> </form>
</nav> </nav>
<Toaster client:idle /> <Toaster client:idle transition:persist />
{isAdmin && <BadgeSolicitudes client:idle />} {isAdmin && <BadgeSolicitudes client:idle transition:persist />}
</div> </div>
</Layout> </Layout>
+23 -20
View File
@@ -1,5 +1,6 @@
--- ---
import '@/styles/global.css'; import '@/styles/global.css';
import { ClientRouter } from 'astro:transitions';
interface Props { interface Props {
title?: string; title?: string;
@@ -18,6 +19,7 @@ const { title = 'Sistema de Préstamos — Laboratorio UABC' } = Astro.props;
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Mulish:ital,wght@0,200..1000;1,200..1000&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Mulish:ital,wght@0,200..1000;1,200..1000&display=swap" rel="stylesheet" />
<meta name="generator" content={Astro.generator} /> <meta name="generator" content={Astro.generator} />
<ClientRouter />
<title>{title}</title> <title>{title}</title>
</head> </head>
<body> <body>
@@ -63,27 +65,28 @@ const { title = 'Sistema de Préstamos — Laboratorio UABC' } = Astro.props;
</style> </style>
<script is:inline> <script is:inline>
(() => { (() => {
const overlay = document.getElementById('page-loader'); const show = () => {
if (!overlay) return; const overlay = document.getElementById('page-loader');
const show = () => { overlay.hidden = false; }; if (overlay) overlay.hidden = false;
};
const hide = () => {
const overlay = document.getElementById('page-loader');
if (overlay) overlay.hidden = true;
};
document.addEventListener('click', (e) => { // Con ClientRouter, la navegación entre páginas dispara eventos propios.
const a = (e.target instanceof Element) ? e.target.closest('a[href]') : null; // La navegación es rápida (fetch + swap sin recargar) — mostramos el
if (!a) return; // overlay solo si el swap tarda >250ms para no parpadear en cambios rápidos.
if (e.defaultPrevented) return; let pendingShow = null;
if (e.button !== 0) return; document.addEventListener('astro:before-preparation', () => {
if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return; pendingShow = setTimeout(show, 250);
if (a.target && a.target !== '_self') return; });
const href = a.getAttribute('href') || ''; document.addEventListener('astro:after-swap', () => {
if (!href || href.startsWith('#') || href.startsWith('javascript:') || href.startsWith('mailto:') || href.startsWith('tel:')) return; if (pendingShow) { clearTimeout(pendingShow); pendingShow = null; }
try { hide();
const url = new URL(a.href, location.href); });
if (url.origin !== location.origin) return;
if (url.pathname === location.pathname && url.search === location.search) return;
} catch { return; }
show();
}, true);
// Submits que hagan navegación real (no fetch client-side) sí ameritan overlay inmediato.
document.addEventListener('submit', (e) => { document.addEventListener('submit', (e) => {
if (e.defaultPrevented) return; if (e.defaultPrevented) return;
const form = e.target; const form = e.target;
@@ -98,7 +101,7 @@ const { title = 'Sistema de Préstamos — Laboratorio UABC' } = Astro.props;
show(); show();
}, true); }, true);
window.addEventListener('pageshow', () => { overlay.hidden = true; }); window.addEventListener('pageshow', hide);
})(); })();
</script> </script>
</body> </body>
+4 -4
View File
@@ -30,23 +30,23 @@ const categorias = ((data ?? []) as unknown as CategoriaRow[]).map((c) => ({
<CategoriaForm mode="create" client:load /> <CategoriaForm mode="create" client:load />
</header> </header>
<nav class="flex gap-2 mb-6 border-b border-hairline-gray" aria-label="Sub-navegación de inventario"> <nav class="flex gap-2 mb-6" aria-label="Sub-navegación de inventario">
<a <a
href="/admin/inventario" href="/admin/inventario"
class="px-4 py-2 text-sm font-medium rounded-[8px] transition-colors border-b-2 -mb-px border-transparent text-graphite hover:text-verde-institucional" class="px-4 py-2 text-sm font-medium rounded-[8px] transition-colors text-graphite hover:bg-linen hover:text-verde-institucional"
> >
Materiales Materiales
</a> </a>
<a <a
href="/admin/inventario/categorias" href="/admin/inventario/categorias"
class="px-4 py-2 text-sm font-medium rounded-[8px] transition-colors border-b-2 -mb-px border-verde-institucional text-verde-institucional" class="px-4 py-2 text-sm font-medium rounded-[8px] transition-colors bg-linen text-verde-institucional"
aria-current="page" aria-current="page"
> >
Categorías Categorías
</a> </a>
<a <a
href="/admin/maestros" href="/admin/maestros"
class="px-4 py-2 text-sm font-medium rounded-[8px] transition-colors border-b-2 -mb-px border-transparent text-graphite hover:text-verde-institucional" class="px-4 py-2 text-sm font-medium rounded-[8px] transition-colors text-graphite hover:bg-linen hover:text-verde-institucional"
> >
Maestros Maestros
</a> </a>
+4 -4
View File
@@ -68,14 +68,14 @@ const estadoBadge = (e: Material['estado']) => {
<MaterialForm mode="create" categorias={categorias} client:load /> <MaterialForm mode="create" categorias={categorias} client:load />
</header> </header>
<nav class="flex gap-2 mb-6 border-b border-hairline-gray" aria-label="Sub-navegación de inventario"> <nav class="flex gap-2 mb-6" aria-label="Sub-navegación de inventario">
<a href="/admin/inventario" class="px-4 py-2 text-sm font-medium rounded-[8px] transition-colors border-b-2 -mb-px border-verde-institucional text-verde-institucional" aria-current="page"> <a href="/admin/inventario" class="px-4 py-2 text-sm font-medium rounded-[8px] transition-colors bg-linen text-verde-institucional" aria-current="page">
Materiales Materiales
</a> </a>
<a href="/admin/inventario/categorias" class="px-4 py-2 text-sm font-medium rounded-[8px] transition-colors border-b-2 -mb-px border-transparent text-graphite hover:text-verde-institucional"> <a href="/admin/inventario/categorias" class="px-4 py-2 text-sm font-medium rounded-[8px] transition-colors text-graphite hover:bg-linen hover:text-verde-institucional">
Categorías Categorías
</a> </a>
<a href="/admin/maestros" class="px-4 py-2 text-sm font-medium rounded-[8px] transition-colors border-b-2 -mb-px border-transparent text-graphite hover:text-verde-institucional"> <a href="/admin/maestros" class="px-4 py-2 text-sm font-medium rounded-[8px] transition-colors text-graphite hover:bg-linen hover:text-verde-institucional">
Maestros Maestros
</a> </a>
</nav> </nav>
+4 -4
View File
@@ -34,22 +34,22 @@ const maestros = ((data ?? []) as unknown as MaestroRow[]).map((m) => ({
<MaestroForm mode="create" client:load /> <MaestroForm mode="create" client:load />
</header> </header>
<nav class="flex gap-2 mb-6 border-b border-hairline-gray" aria-label="Sub-navegación de inventario"> <nav class="flex gap-2 mb-6" aria-label="Sub-navegación de inventario">
<a <a
href="/admin/inventario" href="/admin/inventario"
class="px-4 py-2 text-sm font-medium rounded-[8px] transition-colors border-b-2 -mb-px border-transparent text-graphite hover:text-verde-institucional" class="px-4 py-2 text-sm font-medium rounded-[8px] transition-colors text-graphite hover:bg-linen hover:text-verde-institucional"
> >
Materiales Materiales
</a> </a>
<a <a
href="/admin/inventario/categorias" href="/admin/inventario/categorias"
class="px-4 py-2 text-sm font-medium rounded-[8px] transition-colors border-b-2 -mb-px border-transparent text-graphite hover:text-verde-institucional" class="px-4 py-2 text-sm font-medium rounded-[8px] transition-colors text-graphite hover:bg-linen hover:text-verde-institucional"
> >
Categorías Categorías
</a> </a>
<a <a
href="/admin/maestros" href="/admin/maestros"
class="px-4 py-2 text-sm font-medium rounded-[8px] transition-colors border-b-2 -mb-px border-verde-institucional text-verde-institucional" class="px-4 py-2 text-sm font-medium rounded-[8px] transition-colors bg-linen text-verde-institucional"
aria-current="page" aria-current="page"
> >
Maestros Maestros
+6 -6
View File
@@ -132,8 +132,8 @@ const exportFilename = `reporte-${today}.csv`;
<p class="text-sm opacity-70 mt-1">Historial filtrable de préstamos y lista de vencidos.</p> <p class="text-sm opacity-70 mt-1">Historial filtrable de préstamos y lista de vencidos.</p>
</header> </header>
<nav class="mb-6 border-b border-hairline-gray" aria-label="Vistas de reportes"> <nav class="mb-6" aria-label="Vistas de reportes">
<ul class="flex gap-2 -mb-px"> <ul class="flex gap-2">
<li> <li>
<a <a
href="?vista=historial" href="?vista=historial"
@@ -141,8 +141,8 @@ const exportFilename = `reporte-${today}.csv`;
class:list={[ class:list={[
'inline-block px-4 py-2 text-sm font-medium rounded-[8px] transition-colors', 'inline-block px-4 py-2 text-sm font-medium rounded-[8px] transition-colors',
vista === 'historial' vista === 'historial'
? 'text-verde-institucional border-b-2 border-verde-institucional' ? 'bg-linen text-verde-institucional'
: 'text-graphite hover:text-verde-institucional border-b-2 border-transparent', : 'text-graphite hover:bg-linen hover:text-verde-institucional',
]} ]}
>Historial</a> >Historial</a>
</li> </li>
@@ -153,8 +153,8 @@ const exportFilename = `reporte-${today}.csv`;
class:list={[ class:list={[
'inline-block px-4 py-2 text-sm font-medium rounded-[8px] transition-colors', 'inline-block px-4 py-2 text-sm font-medium rounded-[8px] transition-colors',
vista === 'vencidos' vista === 'vencidos'
? 'text-verde-institucional border-b-2 border-verde-institucional' ? 'bg-linen text-verde-institucional'
: 'text-graphite hover:text-verde-institucional border-b-2 border-transparent', : 'text-graphite hover:bg-linen hover:text-verde-institucional',
]} ]}
>Vencidos</a> >Vencidos</a>
</li> </li>
+3 -3
View File
@@ -44,15 +44,15 @@ const tabs = [
</div> </div>
</header> </header>
<nav aria-label="Vistas de solicitudes" class="mb-6 border-b border-hairline-gray flex gap-2 overflow-x-auto"> <nav aria-label="Vistas de solicitudes" class="mb-6 flex gap-2 overflow-x-auto">
{tabs.map((t) => { {tabs.map((t) => {
const active = path === t.href; const active = path === t.href;
return ( return (
<a <a
href={t.href} href={t.href}
class:list={[ class:list={[
'px-4 py-2 text-sm font-medium whitespace-nowrap transition-colors border-b -mb-px', 'px-4 py-2 text-sm font-medium whitespace-nowrap transition-colors rounded-[8px]',
active ? 'border-verde-institucional text-verde-institucional' : 'border-transparent text-graphite/70 hover:text-verde-institucional' active ? 'bg-linen text-verde-institucional' : 'text-graphite/70 hover:bg-linen hover:text-verde-institucional'
]} ]}
aria-current={active ? 'page' : undefined} aria-current={active ? 'page' : undefined}
> >
+3 -3
View File
@@ -55,15 +55,15 @@ const chipPorEstado: Record<string, string> = {
</div> </div>
</header> </header>
<nav aria-label="Vistas de solicitudes" class="mb-6 border-b border-hairline-gray flex gap-2 overflow-x-auto"> <nav aria-label="Vistas de solicitudes" class="mb-6 flex gap-2 overflow-x-auto">
{tabs.map((t) => { {tabs.map((t) => {
const active = path === t.href; const active = path === t.href;
return ( return (
<a <a
href={t.href} href={t.href}
class:list={[ class:list={[
'px-4 py-2 text-sm font-medium whitespace-nowrap transition-colors border-b -mb-px', 'px-4 py-2 text-sm font-medium whitespace-nowrap transition-colors rounded-[8px]',
active ? 'border-verde-institucional text-verde-institucional' : 'border-transparent text-graphite/70 hover:text-verde-institucional' active ? 'bg-linen text-verde-institucional' : 'text-graphite/70 hover:bg-linen hover:text-verde-institucional'
]} ]}
aria-current={active ? 'page' : undefined} aria-current={active ? 'page' : undefined}
> >
+3 -3
View File
@@ -36,15 +36,15 @@ const tabs = [
</div> </div>
</header> </header>
<nav aria-label="Vistas de solicitudes" class="mb-6 border-b border-hairline-gray flex gap-2 overflow-x-auto"> <nav aria-label="Vistas de solicitudes" class="mb-6 flex gap-2 overflow-x-auto">
{tabs.map((t) => { {tabs.map((t) => {
const active = path === t.href || (t.href === '/admin/solicitudes' && path === '/admin/solicitudes/'); const active = path === t.href || (t.href === '/admin/solicitudes' && path === '/admin/solicitudes/');
return ( return (
<a <a
href={t.href} href={t.href}
class:list={[ class:list={[
'px-4 py-2 text-sm font-medium whitespace-nowrap transition-colors border-b -mb-px', 'px-4 py-2 text-sm font-medium whitespace-nowrap transition-colors rounded-[8px]',
active ? 'border-verde-institucional text-verde-institucional' : 'border-transparent text-graphite/70 hover:text-verde-institucional' active ? 'bg-linen text-verde-institucional' : 'text-graphite/70 hover:bg-linen hover:text-verde-institucional'
]} ]}
aria-current={active ? 'page' : undefined} aria-current={active ? 'page' : undefined}
> >
+1
View File
@@ -55,6 +55,7 @@
background-image: radial-gradient(circle, color-mix(in oklab, var(--color-verde-institucional) 12%, transparent) 1.5px, transparent 1.5px); background-image: radial-gradient(circle, color-mix(in oklab, var(--color-verde-institucional) 12%, transparent) 1.5px, transparent 1.5px);
background-size: 22px 22px; background-size: 22px 22px;
background-position: 0 0; background-position: 0 0;
background-attachment: fixed;
color: var(--color-graphite); color: var(--color-graphite);
font-family: var(--font-sans); font-family: var(--font-sans);
font-weight: 700; font-weight: 700;