Skip to content
ArceApps Logo ArceApps
EN
Volver
Construyendo en Público

Dominando la Jerarquía Visual Responsiva: La Guía Indie Definitiva

Cuando eres un desarrollador en solitario creando herramientas y juegos en tu tiempo libre, lograr que la interfaz de usuario sea correcta en todos los dispositivos es una batalla constante. Recientemente, fusioné el PR #420 titulado “Jerarquía Visual Responsiva”, que representa un cambio masivo en cómo ArceApps maneja los diseños en dispositivos móviles frente a escritorios. Esta bitácora profundiza en la implementación técnica, el razonamiento detrás de estos cambios y la mentalidad indie requerida para equilibrar el perfeccionismo con el lanzamiento real de productos. Siempre he creído que el portafolio de un solopreneur debe reflejar su mejor trabajo, no solo en términos de lógica de backend o algoritmos complejos, sino fundamentalmente en cómo el usuario final percibe la interfaz.

El viaje hacia una jerarquía visual perfectamente responsiva rara vez es directo. Exige una gran conciencia del espacio en blanco, el escalado tipográfico, el contraste de color y el posicionamiento de los componentes. Cuando no tienes un equipo de diseño dedicado para entregarte archivos Figma perfectos para cada punto de interrupción concebible, debes confiar en frameworks CSS robustos como Tailwind CSS, principios de diseño sistemático y mucha prueba y error.

Uno de los problemas más evidentes con las primeras iteraciones de ArceApps fue la naturaleza estática de la tipografía. En un dispositivo móvil, un encabezado text-4xl se veía genial. Pero en un monitor 4K de 32 pulgadas, ese mismo encabezado parecía completamente insignificante. El enfoque tradicional para resolver esto es usar consultas de medios para incrementar el tamaño de la fuente en puntos de interrupción específicos. Si bien esto funciona, crea una experiencia “escalonada”. A medida que cambia el tamaño del navegador, el texto salta repentinamente de tamaño. Es discordante. También requiere una inmensa cantidad de código CSS repetitivo. Aquí entra la función clamp() de CSS.

La función clamp() toma tres parámetros: un valor mínimo, un valor preferido (generalmente una unidad relativa a la ventana gráfica como vw) y un valor máximo. Esto permite que la tipografía se escale de forma infinita y suave entre los límites definidos. Al definir estos tamaños de fuente fluidos directamente en nuestra hoja de estilo global, permitimos que cada componente en el ecosistema Astro herede propiedades de escalado perfectas. Esto elimina la necesidad de escribir manualmente text-sm md:text-base lg:text-lg en cada etiqueta de párrafo individual. Esto limpia drásticamente el marcado HTML y asegura una consistencia absoluta.

La Arquitectura CSS Central

Para comprender verdaderamente los cambios realizados en el PR #420, debemos examinar el archivo CSS global completo línea por línea. Este archivo actúa como la base para toda la configuración de Tailwind y la raíz de nuestra jerarquía visual responsiva.

@import "tailwindcss";
@plugin "@tailwindcss/typography";

@custom-variant dark (&:where(.dark, .dark *));

@theme {
  /* Brand Colors */
  --color-primary: #018786; /* Teal */
  --color-secondary: #FF9800; /* Orange */

  /* Material Design Colors */
  --color-surface: #FFFFFF;
  --color-surface-variant: #F4F5F7;
  --color-on-surface: #1C1B1F;
  --color-on-surface-variant: #49454F;

  /* Dark Mode Colors */
  --color-dark-surface: #121212;
  --color-dark-surface-variant: #1C1C1E;
  --color-dark-on-surface: #F5F5F5;
  --color-dark-on-surface-variant: #E0E0E0;

  /* Fonts */
  --font-sans: 'Inter Variable', 'Inter', system-ui, -apple-system, sans-serif;
  --font-heading: 'Inter Variable', 'Inter', system-ui, sans-serif;
}

/* Material Design 3 Utilities */
@layer utilities {
  .elevation-1 {
    box-shadow: 0px 1px 3px 1px rgba(0, 0, 0, 0.15), 0px 1px 2px 0px rgba(0, 0, 0, 0.30);
  }
  .elevation-2 {
    box-shadow: 0px 2px 6px 2px rgba(0, 0, 0, 0.15), 0px 1px 2px 0px rgba(0, 0, 0, 0.30);
  }
  .elevation-3 {
    box-shadow: 0px 4px 8px 3px rgba(0, 0, 0, 0.15), 0px 1px 3px 0px rgba(0, 0, 0, 0.30);
  }

  .material-card {
    @apply rounded-xl bg-surface dark:bg-dark-surface-variant border border-gray-200 dark:border-gray-800 transition-all duration-300;
  }

  .cv-auto {
    content-visibility: auto;
  }
}

/* Material Icons Class */
.material-icons {
  font-family: 'Material Icons';
  font-weight: normal;
  font-style: normal;
  font-size: 24px;  /* Preferred icon size */
  display: inline-block;
  line-height: 1;
  text-transform: none;
  letter-spacing: normal;
  word-wrap: normal;
  white-space: nowrap;
  direction: ltr;
  /* Support for all WebKit browsers. */
  -webkit-font-smoothing: antialiased;
  /* Support for Safari and Chrome. */
  text-rendering: optimizeLegibility;
  /* Support for Firefox. */
  -moz-osx-font-smoothing: grayscale;
  /* Support for IE. */
  font-feature-settings: 'liga';
}

/* Base Styles */
@layer base {
  html {
    @apply scroll-smooth;
  }

  body {
    @apply bg-surface dark:bg-dark-surface text-on-surface dark:text-dark-on-surface antialiased transition-colors duration-300;
  }

  /* Typography */
  h1, h2, h3, h4, h5, h6 {
    @apply font-heading font-medium tracking-tight;
    /* Default weight for headings is Medium (500) */
    font-variation-settings: 'wght' 500;
    transition: font-variation-settings 0.3s ease-out;
  }
}

@media (prefers-reduced-motion: reduce) {
  html {
    scroll-behavior: auto !important;
  }
}

@media (hover: hover) {
  h1:hover, h2:hover, h3:hover, h4:hover, h5:hover, h6:hover {
    font-variation-settings: 'wght' 650;
  }
}

/*
   Variable Font Weight Overrides
   Ensures Tailwind weight utilities work with font-variation-settings
   and provide appropriate hover animations.
*/

/* Semibold (600) -> Hover 700 */
.font-semibold {
  font-variation-settings: 'wght' 600 !important;
}
@media (hover: hover) {
  .font-semibold:hover {
    font-variation-settings: 'wght' 700 !important;
  }
}

/* Bold (700) -> Hover 800 */
.font-bold {
  font-variation-settings: 'wght' 700 !important;
}
@media (hover: hover) {
  .font-bold:hover {
    font-variation-settings: 'wght' 800 !important;
  }
}

/* Extra Bold (800) -> Hover 900 */
.font-extrabold {
  font-variation-settings: 'wght' 800 !important;
}
@media (hover: hover) {
  .font-extrabold:hover {
    font-variation-settings: 'wght' 900 !important;
  }
}

/* Prose Customization */
@layer components {
  .prose {
    /* Base Color & Typography */
    @apply text-on-surface-variant dark:text-dark-on-surface leading-relaxed;

    /* Global Heading Styles */
    @apply prose-headings:font-heading prose-headings:text-primary dark:prose-headings:text-dark-on-surface prose-headings:font-bold prose-headings:tracking-tight;
  }

  /* Ensure prose headings also support variable weight animation */
  .prose h1, .prose h2, .prose h3, .prose h4, .prose h5, .prose h6 {
      font-variation-settings: 'wght' 700; /* Start bold as per prose-headings:font-bold */
      transition: font-variation-settings 0.3s ease-out;
  }

  /* Force dark mode color for headings and bold text to ensure high contrast */
  :is(.dark .prose) :is(h1, h2, h3, h4, h5, h6, strong) {
      color: var(--color-dark-on-surface) !important;
  }

  @media (hover: hover) {
      .prose h1:hover, .prose h2:hover, .prose h3:hover, .prose h4:hover, .prose h5:hover, .prose h6:hover {
          font-variation-settings: 'wght' 800; /* Extra Bold on hover */
      }
  }

  /* Specific Heading Sizes & Spacing */
  .prose h1 {
    @apply text-4xl mb-8 mt-12;
  }
  .prose h2 {
    @apply text-3xl mb-6 mt-10;
  }
  .prose h3 {
    @apply text-2xl mb-4 mt-8;
  }
  .prose h4 {
    @apply text-xl mb-4 mt-6;
  }

  /* Paragraphs */
  .prose p {
    @apply my-6 text-lg leading-8 dark:text-dark-on-surface;
  }

  /* Links */
  .prose a {
    @apply text-primary font-semibold no-underline transition-colors;
  }
  .prose a:hover {
    @apply text-secondary underline;
  }
  :is(.dark .prose) a {
    color: var(--color-secondary) !important;
  }
  :is(.dark .prose) a:hover {
    color: var(--color-primary) !important;
  }

  /* Strong */
  .prose strong {
    @apply text-on-surface dark:text-dark-on-surface font-bold;
  }

  /* Code (Inline) */
  .prose code {
    @apply text-secondary! dark:text-teal-200! bg-surface-variant dark:bg-gray-800 px-1.5 py-0.5 rounded font-mono text-sm;
  }
  .prose code::before, .prose code::after {
    content: none;
  }

  /* Pre (Code Blocks) */
  .prose pre {
    @apply bg-[#1e1e1e] dark:bg-[#1a1a1a] text-gray-100 rounded-2xl shadow-xl border border-gray-700 dark:border-gray-600 p-6 my-8 overflow-x-auto;
    position: relative; /* For copy button */
  }

  /* Blockquotes */
  .prose blockquote {
    @apply border-l-4 border-primary bg-surface-variant/30 dark:bg-dark-surface-variant/30 py-4 pl-6 pr-4 my-8 rounded-r-xl not-italic font-serif text-lg text-on-surface dark:text-gray-100;
  }

  /* Images & Videos */
  .prose img, .prose video {
    @apply rounded-2xl shadow-lg my-10 mx-auto block;
    max-width: min(100%, 500px); /* Fix: Responsive Visual Hierarchy to avoid "exploding" media on tablets/desktop */
    height: auto;
  }

  /* Lists */
  .prose ul, .prose ol {
    @apply my-6;
  }
  .prose li {
    @apply my-2 dark:text-dark-on-surface;
  }
  .prose li::marker {
    @apply text-primary;
  }

  /* HR */
  .prose hr {
    @apply border-gray-200 dark:border-gray-800 my-12;
  }

  /* Copy Button Styles */
  .copy-code-btn {
    position: absolute;
    top: 0.75rem;
    right: 0.75rem;
    padding: 0.35rem;
    background-color: rgba(255, 255, 255, 0.1);
    border: 1px solid rgba(255, 255, 255, 0.2);
    border-radius: 0.5rem;
    color: #e5e7eb;
    cursor: pointer;
    transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
    display: flex;
    align-items: center;
    justify-content: center;
    opacity: 0; /* Hidden by default */
  }

  /* Show copy button on hover */
  .prose pre:hover .copy-code-btn,
  .copy-code-btn:focus-visible {
    opacity: 1;
  }

  .copy-code-btn:hover {
    background-color: rgba(255, 255, 255, 0.2);
    color: white;
    transform: scale(1.05);
  }

  .copy-code-btn:active {
    transform: scale(0.95);
  }

  .copy-code-btn .material-icons {
    font-size: 1.25rem;
  }

  /* Spatial (Glassmorphism 2.0) Card Styles */
  .spatial-card {
    @apply relative overflow-hidden transition-all duration-300;
    /* Light Mode: Clean, slightly translucent, subtle border */
    @apply bg-surface/90 border border-gray-200/60 backdrop-blur-md;

    /* Dark Mode: Deep glassmorphism, transparent border (handled by pseudo), stronger blur */
    @apply dark:bg-[#1e1e1e]/60 dark:border-transparent dark:backdrop-blur-xl;
  }

  /* Gradient Border for Dark Mode */
  .spatial-card::before {
    content: "";
    @apply absolute -inset-[1px] z-10 rounded-[inherit] pointer-events-none opacity-0 transition-opacity duration-300;
    padding: 1px; /* Border width */
    background: linear-gradient(135deg, rgba(1, 135, 134, 0.5), rgba(255, 152, 0, 0.5));
    -webkit-mask:
       linear-gradient(#fff 0 0) content-box,
       linear-gradient(#fff 0 0);
    -webkit-mask-composite: xor;
            mask-composite: exclude;
  }

  .dark .spatial-card::before {
    @apply opacity-100;
  }

  /* Inner Glow/Highlight for depth */
  .spatial-card::after {
    content: "";
    @apply absolute inset-0 z-10 rounded-[inherit] pointer-events-none opacity-0 transition-opacity duration-300;
    background: linear-gradient(
      105deg,
      rgba(255, 255, 255, 0.05) 0%,
      rgba(255, 255, 255, 0) 40%
    );
  }

  .dark .spatial-card::after {
    @apply opacity-100;
  }
}

/* Animations */
@keyframes fade-in-up {
  from { opacity: 0; transform: translateY(20px); }
  to { opacity: 1; transform: translateY(0); }
}

@keyframes scale-progress {
  from { transform: scaleX(0); }
  to { transform: scaleX(1); }
}

.scroll-progress-bar {
  transform: scaleX(0);
}

@media (prefers-reduced-motion: no-preference) {
  .fade-in {
    animation: fade-in-up 0.5s ease-out forwards;
  }

  /* Scroll-Driven Animations */
  @supports (animation-timeline: view()) {
    .fade-in-section {
      animation: fade-in-up linear both;
      animation-timeline: view();
      animation-range: entry 10% cover 30%;
    }
  }

  @supports (animation-timeline: scroll()) {
    .scroll-progress-bar {
      animation: scale-progress linear;
      animation-timeline: scroll();
    }
  }
}

Como se puede ver en el código fuente en bruto de arriba, la hoja de estilo global está impulsada completamente por la directiva @theme de Tailwind y las clases de utilidad. Establecemos variables raíz para los colores de la marca (verde azulado primario y naranja secundario) que son ordenados explícitamente por el protocolo AGENTS.md. La implementación del modo oscuro tiene matices particulares, utilizando colores de superficie específicos para garantizar que la elevación se comunique correctamente, en lugar de simplemente invertir todo el DOM. Tenga en cuenta el enfoque específico en las clases ‘.prose’: esto garantiza que todo el contenido markdown generado dinámicamente se adhiera estrictamente a nuestra escala de tipografía fluida sin requerir estilos en línea.

Inmersión Profunda en la Configuración de Tailwind

Más allá del CSS sin procesar, la configuración del proyecto en sí dicta cómo se generan estas clases. A continuación se muestra la estructura del paquete y la configuración de Tailwind que impulsa este sistema responsivo.

{
  "name": "arceapps-astro-site",
  "type": "module",
  "version": "0.0.1",
  "scripts": {
    "dev": "astro dev",
    "build": "astro build",
    "preview": "astro preview",
    "astro": "astro",
    "news:fetch": "node scripts/fetch-rss.js",
    "apps:update": "node scripts/update-play-images.js",
    "test": "vitest run"
  },
  "dependencies": {
    "@astrojs/partytown": "^2.1.4",
    "@astrojs/rss": "^4.0.14",
    "@astrojs/sitemap": "^3.6.0",
    "@fontsource-variable/inter": "^5.2.8",
    "@fontsource/material-icons": "^5.2.7",
    "@fontsource/merriweather": "^5.2.11",
    "@tailwindcss/vite": "^4.1.18",
    "astro": "^5.16.14",
    "fuse.js": "^7.1.0",
    "sharp": "^0.34.5",
    "tailwindcss": "^4.1.17"
  },
  "devDependencies": {
    "@astrojs/check": "^0.9.6",
    "@playwright/test": "^1.57.0",
    "@tailwindcss/typography": "^0.5.19",
    "@types/mdast": "^4.0.4",
    "google-play-scraper": "^10.1.2",
    "gray-matter": "^4.0.3",
    "jsdom": "^27.4.0",
    "playwright": "^1.57.0",
    "rehype-external-links": "^3.0.0",
    "rss-parser": "^3.13.0",
    "terser": "5.46.0",
    "typescript": "^5.9.3",
    "vfile": "^6.0.3",
    "vitest": "^4.0.18"
  },
  "pnpm": {
    "overrides": {
      "lodash": "^4.17.23",
      "fast-xml-parser": "^5.3.4"
    }
  }
}

La integración de Tailwind v4 cambió drásticamente la canalización de compilación. Al mover la configuración a CSS y aprovechar el nuevo motor, los tiempos de compilación para el sitio estático de Astro se redujeron a la mitad. Las dependencias enumeradas anteriormente muestran un claro compromiso con una arquitectura optimizada y estática en primer lugar. La ausencia de pesados marcos de trabajo en tiempo de ejecución es intencional. La atención se centra por completo en enviar la carga útil más pequeña posible al cliente mientras se mantiene una interfaz de usuario hermosa y receptiva.

La Arquitectura de Diseño

La arquitectura CSS no existe en el vacío. Es consumida por el componente de Diseño (Layout) central de Astro. Analicemos cómo la envoltura global utiliza estos estilos.

---
import "@fontsource-variable/inter";
import "@fontsource/merriweather";
import "@fontsource/material-icons";
import "../styles/global.css";
import Header from "../components/Header.astro";
import Footer from "../components/Footer.astro";
import { ClientRouter } from "astro:transitions";
import { getLangFromUrl } from "../i18n/utils";

interface Props {
  title: string;
  description?: string;
  image?: string;
  type?: "website" | "article" | "profile";
  publishDate?: Date;
  author?: string;
  lang?: string;
  translatedPath?: string;
}

const lang = Astro.props.lang || getLangFromUrl(Astro.url);

const {
  title,
  description = "ArceApps - Aplicaciones Android de calidad, modernas y funcionales.",
  image = "/images/default-og.png",
  type = "website",
  publishDate,
  author = "ArceApps",
  translatedPath,
} = Astro.props;

const canonicalURL = new URL(Astro.url.pathname, Astro.site);
const socialImageURL = new URL(image, Astro.url);
---

<!doctype html>
<html lang={lang} class="scroll-smooth">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width" />
    <link rel="icon" type="image/png" href="/logo.png" />
    <link rel="apple-touch-icon" href="/logo.png" />
    <link rel="canonical" href={canonicalURL} />
    <ClientRouter />

    <!-- Security Headers -->
    <meta name="referrer" content="strict-origin-when-cross-origin" />
    <meta
      http-equiv="Content-Security-Policy"
      content="default-src 'self'; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://cdn.simpleicons.org https://play-lh.googleusercontent.com https://*.googleusercontent.com; font-src 'self'; connect-src 'self' https://www.google-analytics.com https://region1.google-analytics.com; form-action https://formsubmit.co; base-uri 'self'; object-src 'none'; upgrade-insecure-requests;"
    />

    <!-- Anti-Clickjacking / Frame Busting -->
    <style id="anti-clickjack" is:inline>
      body {
        display: none !important;
      }
    </style>
    <script is:inline>
      function removeAntiClickjackStyle(doc) {
        if (self === top) {
          var antiClickjack = doc.getElementById("anti-clickjack");
          if (antiClickjack) antiClickjack.parentNode.removeChild(antiClickjack);
        } else {
          top.location = self.location;
        }
      }

      // Initial load
      removeAntiClickjackStyle(document);

      // View Transitions: Apply to new document before swap
      document.addEventListener("astro:before-swap", (ev) => {
        removeAntiClickjackStyle(ev.newDocument);
      });
    </script>
    <noscript>
      <style>
        body {
          display: flex !important;
        }
      </style>
    </noscript>

    <!-- Primary Meta Tags -->
    <title>{title}</title>
    <meta name="title" content={title} />
    <meta name="description" content={description} />

    <!-- Open Graph / Facebook -->
    <meta property="og:type" content={type} />
    <meta property="og:url" content={Astro.url} />
    <meta property="og:title" content={title} />
    <meta property="og:description" content={description} />
    <meta property="og:image" content={socialImageURL} />

    <!-- Twitter -->
    <meta property="twitter:card" content="summary_large_image" />
    <meta property="twitter:url" content={Astro.url} />
    <meta property="twitter:title" content={title} />
    <meta property="twitter:description" content={description} />
    <meta property="twitter:image" content={socialImageURL} />

    <!-- PWA -->
    <link rel="manifest" href="/manifest.json" />
    <meta name="theme-color" content="#018786" />

    <!-- Performance Optimizations -->
    <link rel="preconnect" href="https://play-lh.googleusercontent.com" />
    <link rel="preconnect" href="https://cdn.simpleicons.org" />

    <!-- Google Analytics -->
    <script
      is:inline type="text/partytown"
      src="https://www.googletagmanager.com/gtag/js?id=G-CZLNYSWY76"></script>
    <script is:inline type="text/partytown">
      window.dataLayer = window.dataLayer || [];
      function gtag() {
        dataLayer.push(arguments);
      }
      gtag("js", new Date());

      gtag("config", "G-CZLNYSWY76");
    </script>

    <!-- Schema.org -->
    <script
      is:inline type="application/ld+json"
      set:html={JSON.stringify({
        "@context": "https://schema.org",
        "@type": type === "article" ? "BlogPosting" : "WebSite",
        url: Astro.url,
        name: title,
        description: description,
        image: socialImageURL,
        author: {
          "@type": "Person",
          name: author,
          url: "https://arceapps.com/about-me",
        },
        ...(publishDate && {
          datePublished: publishDate.toISOString(),
          dateModified: publishDate.toISOString(),
        }),
      }).replace(/</g, "\\u003C")}
    />

    <script is:inline>
      // Theme Script
      function getTheme() {
        if (
          typeof localStorage !== "undefined" &&
          localStorage.getItem("theme")
        ) {
          return localStorage.getItem("theme");
        }
        if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
          return "dark";
        }
        return "light";
      }

      function applyTheme(doc) {
        const theme = getTheme();
        if (theme === "dark") {
          doc.documentElement.classList.add("dark");
        } else {
          doc.documentElement.classList.remove("dark");
        }
        // Ensure localStorage is synced (optional but good for consistency)
        if (typeof localStorage !== "undefined") {
          localStorage.setItem("theme", theme);
        }
      }

      // Initial load
      applyTheme(document);

      // View Transitions: Apply theme to new document BEFORE swap to prevent FOUC
      document.addEventListener("astro:before-swap", (ev) => {
        applyTheme(ev.newDocument);
      });
    </script>
  </head>
  <body class="flex flex-col min-h-screen">
    <!-- Scroll Sentinel -->
    <div
      id="scroll-sentinel"
      class="absolute top-0 w-full h-[300px] pointer-events-none -z-50"
      aria-hidden="true"
    >
    </div>

    <a
      href="#main-content"
      class="fixed top-4 left-4 z-[100] -translate-y-[150%] bg-primary text-white px-4 py-2 rounded-lg shadow-lg font-bold focus:translate-y-0 transition-transform duration-300 focus:outline-none focus:ring-4 focus:ring-white/50"
    >
      Skip to content
    </a>
    <Header translatedPath={translatedPath} />
    <main id="main-content" class="flex-grow">
      <slot />
    </main>
    <Footer />
    <!-- Scroll to Top Button -->
    <button
      id="scroll-to-top"
      aria-label="Scroll to top"
      tabindex="-1"
      aria-hidden="true"
      class="fixed bottom-8 right-8 z-50 bg-primary text-white w-12 h-12 rounded-full shadow-lg elevation-4 flex items-center justify-center translate-y-20 opacity-0 transition-all duration-300 focus:outline-none focus:ring-4 focus:ring-primary/50 hover:bg-primary-dark hover:scale-110 pointer-events-none"
    >
      <span class="material-icons" aria-hidden="true">arrow_upward</span>
    </button>

    <script src="../scripts/layout.ts"></script>
  </body>
</html>

El componente Layout sirve como el esqueleto HTML. Importa el CSS global y configura la etiqueta crítica `` que activa los anclajes de tipografía fluida que definimos anteriormente. Sin esta etiqueta, el navegador asume un ancho de escritorio y nuestro diseño responsivo falla. Además, observe el script en línea que maneja la persistencia del `theme` en localStorage. Esto evita el temido “destello del tema incorrecto” (FOIT) en la carga inicial.

El Encabezado de Navegación

Un diseño responsivo se prueba más severamente en el encabezado de navegación. Examinemos el componente Header.

---
import { Image } from "astro:assets";
import brandIcon from "../assets/brand-icon.png";
import Search from "./Search.astro";
import { getLangFromUrl, useTranslations } from "../i18n/utils";

const lang = getLangFromUrl(Astro.url);
const t = useTranslations(lang);

interface Props {
  translatedPath?: string;
}

const { translatedPath } = Astro.props;

const navItems = [
  { label: t('nav.home'), href: lang === 'es' ? '/es' : '/' },
  { label: t('nav.apps'), href: lang === 'es' ? '/es/apps' : '/apps' },
  { label: t('nav.blog'), href: lang === 'es' ? '/es/blog' : '/blog' },
  { label: t('nav.devlog'), href: lang === 'es' ? '/es/devlog' : '/devlog' },
  { label: t('nav.about'), href: lang === 'es' ? '/es/about-me' : '/about-me' },
];

const { pathname } = Astro.url;
const normalize = (path: string) => path.replace(/\/$/, "") || "/";
const currentPath = normalize(pathname);

const isActive = (href: string) => {
  if (href === "/" || href === "/es") return currentPath === href;
  return currentPath.startsWith(href);
};

// Language Toggle Logic
const currentLang = lang;
const nextLang = currentLang === 'es' ? 'en' : 'es';
const nextLangLabel = currentLang === 'es' ? 'EN' : 'ES';

// Use translatedPath if provided, otherwise fallback to simple path replacement
let nextPath = translatedPath;

if (!nextPath) {
  // Simple path replacement for language switching
  // Note: This works perfectly for structural pages. For content with different slugs (blog posts),
  // it might lead to 404s if slugs don't match.
  // Ideally, we would map exact content paths, but for now this covers the main navigation.
  const segments = pathname.split('/').filter(Boolean);
  if (currentLang === 'es' && segments[0] === 'es') {
    segments.shift();
  } else if (currentLang === 'en') {
    segments.unshift('es');
  }
  nextPath = '/' + segments.join('/');
}
---

<header
  class="sticky top-0 z-50 bg-surface/90 dark:bg-dark-surface/90 backdrop-blur-md border-b border-gray-200 dark:border-gray-800 transition-colors duration-300"
>
  <div class="container mx-auto px-4 h-16 flex items-center justify-between">
    <!-- Logo -->
    <a
      href={lang === 'es' ? '/es' : '/'}
      data-astro-prefetch
      class="flex items-center gap-2 group focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none rounded-lg p-1"
    >
      <Image
        src={brandIcon}
        alt="ArceApps Logo"
        width={40}
        height={40}
        loading="eager"
        fetchpriority="high"
        class="w-10 h-10 rounded-full shadow-md group-hover:scale-105 transition-transform"
      />
      <span
        class="text-xl font-bold tracking-tight text-on-surface dark:text-dark-on-surface"
      >
        Arce<span class="text-primary">Apps</span>
      </span>
    </a>

    <!-- Desktop Nav -->
    <nav class="hidden md:flex items-center gap-8">
      {
        navItems.map((item) => {
          const active = isActive(item.href);
          return (
            <a
              href={item.href}
              data-astro-prefetch
              aria-current={active ? "page" : undefined}
              class:list={[
                "text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none rounded px-2 py-1",
                active
                  ? "text-primary font-bold"
                  : "text-on-surface-variant dark:text-dark-on-surface-variant hover:text-primary dark:hover:text-primary",
              ]}
            >
              {item.label}
            </a>
          );
        })
      }
    </nav>

    <!-- Actions -->
    <div class="flex items-center gap-2">
      <!-- Search -->
      <Search />

      <!-- Language Toggle -->
      <a
        id="language-toggle"
        href={nextPath}
        class="w-10 h-10 rounded-full flex items-center justify-center font-bold text-sm text-on-surface-variant dark:text-dark-on-surface-variant hover:bg-surface-variant dark:hover:bg-gray-800 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
        aria-label={t('nav.language')}
        data-lang={nextLang}
      >
        {nextLangLabel}
      </a>

      <!-- Theme Toggle -->
      <button
        id="theme-toggle"
        class="relative w-10 h-10 rounded-full flex items-center justify-center text-on-surface-variant dark:text-dark-on-surface-variant hover:bg-surface-variant dark:hover:bg-gray-800 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary overflow-hidden"
        aria-label="Cambiar tema"
      >
        <div class="relative w-6 h-6">
          <!-- Moon Icon (Visible in Light Mode -> Action: Go Dark) -->
          <span
            id="icon-dark"
            class="material-icons absolute inset-0 text-xl transform transition-all duration-500 motion-reduce:duration-0 rotate-0 scale-100 opacity-100 dark:-rotate-90 dark:scale-0 dark:opacity-0"
            aria-hidden="true"
          >
            dark_mode
          </span>
          <!-- Sun Icon (Visible in Dark Mode -> Action: Go Light) -->
          <span
            id="icon-light"
            class="material-icons absolute inset-0 text-xl transform transition-all duration-500 motion-reduce:duration-0 rotate-90 scale-0 opacity-0 dark:rotate-0 dark:scale-100 dark:opacity-100"
            aria-hidden="true"
          >
            light_mode
          </span>
        </div>
      </button>

      <!-- Mobile Menu Button -->
      <button
        id="menu-toggle"
        class="md:hidden w-10 h-10 rounded-full flex items-center justify-center text-on-surface-variant dark:text-dark-on-surface-variant hover:bg-surface-variant dark:hover:bg-gray-800 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
        aria-label="Menu"
        aria-expanded="false"
        aria-controls="mobile-menu"
      >
        <span class="material-icons text-2xl" aria-hidden="true">menu</span>
      </button>
    </div>
  </div>

  <!-- Mobile Menu -->
  <div
    id="mobile-menu"
    class="hidden md:hidden border-t border-gray-200 dark:border-gray-800 bg-surface dark:bg-dark-surface absolute w-full shadow-lg"
  >
    <nav class="flex flex-col p-4">
      {
        navItems.map((item) => {
          const active = isActive(item.href);
          return (
            <a
              href={item.href}
              data-astro-prefetch
              aria-current={active ? "page" : undefined}
              class:list={[
                "py-3 px-4 text-base font-medium rounded-lg transition-colors focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none",
                active
                  ? "text-primary bg-primary/10 dark:bg-primary/5"
                  : "text-on-surface dark:text-dark-on-surface hover:bg-surface-variant dark:hover:bg-gray-800",
              ]}
            >
              {item.label}
            </a>
          );
        })
      }
    </nav>
  </div>
</header>

<script src="../scripts/header.ts"></script>
<script>
  // Language preference handler
  document.addEventListener('astro:page-load', () => {
    const langToggle = document.getElementById('language-toggle');
    if (langToggle) {
      langToggle.addEventListener('click', () => {
        const nextLang = langToggle.getAttribute('data-lang');
        if (nextLang) {
          localStorage.setItem('lang-preference', nextLang);
        }
      });
    }
  });
</script>

El encabezado utiliza las utilidades Flexbox de Tailwind (`flex`, `justify-between`, `items-center`) para crear un diseño horizontal resistente. En pantallas móviles, los enlaces están ocultos (`hidden md:flex`) y un menú de hamburguesa toma prioridad. Esto ilustra perfectamente la estrategia de macro-diseño discutida anteriormente: no reducimos la escala de la navegación; alteramos fundamentalmente su estructura en función del espacio horizontal disponible.

Optimización de la Visualización de Metadatos

Si bien nuestra arquitectura de diseño dicta cómo se muestran las cosas, también debemos considerar qué se muestra. Mantener los detalles de las aplicaciones en varios idiomas es propenso a errores. Para resolver esto, el ecosistema ArceApps depende de `scripts/update-play-images.js`.

import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import gplay from 'google-play-scraper';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const APPS_DIR = path.join(__dirname, '../src/content/apps');

async function processFile(filePath) {
  const content = fs.readFileSync(filePath, 'utf8');
  const parsed = matter(content);
  const data = parsed.data;

  if (!data.googlePlayUrl) {
    return;
  }

  try {
    const urlPattern = /id=([^&]+)/;
    const match = data.googlePlayUrl.match(urlPattern);

    if (!match || !match[1]) {
      console.warn(`[WARN] Could not extract app ID from: ${data.googlePlayUrl}`);
      return;
    }

    const appId = match[1];

    // Extraneous query string parameter lang handling
    let lang = 'en';
    if (filePath.includes(`${path.sep}es${path.sep}`)) {
        lang = 'es';
    }

    console.log(`Fetching data for ${appId} (lang: ${lang})...`);

    const appInfo = await gplay.app({ appId, lang });

    let updated = false;

    // Update realIconUrl
    if (appInfo.icon && appInfo.icon !== data.realIconUrl) {
      data.realIconUrl = appInfo.icon;
      updated = true;
    }

    // Update heroImage (headerImage)
    if (appInfo.headerImage && appInfo.headerImage !== data.heroImage) {
      data.heroImage = appInfo.headerImage;
      updated = true;
    }

    // Update screenshots
    if (appInfo.screenshots && appInfo.screenshots.length > 0) {
      // Check if arrays are different
      const currentScreenshots = data.screenshots || [];
      const isDifferent = appInfo.screenshots.length !== currentScreenshots.length ||
                          appInfo.screenshots.some((url, i) => url !== currentScreenshots[i]);

      if (isDifferent) {
        data.screenshots = appInfo.screenshots;
        updated = true;
      }
    }

    // Update rating (rounded to 1 decimal)
    if (appInfo.score) {
      const roundedScore = Math.round(appInfo.score * 10) / 10;
      if (roundedScore !== data.rating) {
        data.rating = roundedScore;
        updated = true;
      }
    } else if (data.rating !== undefined) {
      // If there's no score in the store but we have a rating, remove it
      delete data.rating;
      updated = true;
    }

    // Update version
    if (appInfo.version && appInfo.version !== data.version) {
      data.version = appInfo.version;
      updated = true;
    }

    // Update lastUpdated
    if (appInfo.updated) {
        const date = new Date(appInfo.updated);
        // Format to something like "Jul 23, 2025" or "23 Jul 2025"
        // Let's use the native date formatter based on lang
        const options = { year: 'numeric', month: 'short', day: 'numeric' };
        const formattedDate = date.toLocaleDateString(lang === 'es' ? 'es-ES' : 'en-US', options);

        if (formattedDate !== data.lastUpdated && data.lastUpdated !== appInfo.updated) {
             // Fallback to storing string if formatting gets weird or store raw string from playstore
             // appInfo.updated is a timestamp (number)
             // We can also just store string "Jul 23, 2025"
             data.lastUpdated = formattedDate;
             updated = true;
        }
    }

    // Actualizar la descripción de la ficha de la tienda en el cuerpo del artículo
    let bodyUpdated = false;
    if (appInfo.description) {
      const STORE_START = '<!-- STORE_DESCRIPTION_START -->';
      const STORE_END   = '<!-- STORE_DESCRIPTION_END -->';
      const startIdx = parsed.content.indexOf(STORE_START);
      const endIdx   = parsed.content.indexOf(STORE_END);

      if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
        const currentSection = parsed.content.slice(startIdx + STORE_START.length, endIdx);
        const newSection     = `\n\n${appInfo.description}\n\n`;

        if (currentSection.trim() !== appInfo.description.trim()) {
          parsed.content =
            parsed.content.slice(0, startIdx + STORE_START.length) +
            newSection +
            parsed.content.slice(endIdx);
          bodyUpdated = true;
        }
      }
    }

    if (updated || bodyUpdated) {
      const newContent = matter.stringify(parsed.content, data);
      fs.writeFileSync(filePath, newContent, 'utf8');
      console.log(`[OK] Updated ${path.basename(filePath)} with latest Google Play data.`);
    } else {
      console.log(`[SKIP] No updates needed for ${path.basename(filePath)}.`);
    }

  } catch (error) {
    console.error(`[ERROR] Failed to fetch data for ${filePath}:`, error.message);
  }
}

async function walkDir(dir) {
  const files = fs.readdirSync(dir);
  for (const file of files) {
    const fullPath = path.join(dir, file);
    const stat = fs.statSync(fullPath);
    if (stat.isDirectory()) {
      await walkDir(fullPath);
    } else if (file.endsWith('.md')) {
      await processFile(fullPath);
    }
  }
}

async function main() {
  console.log('Starting Google Play data update (images + store description)...');
  try {
    if (fs.existsSync(APPS_DIR)) {
      await walkDir(APPS_DIR);
      console.log('Finished updating Google Play data.');
    } else {
      console.error(`Directory not found: ${APPS_DIR}`);
    }
  } catch (error) {
    console.error('Error during update process:', error);
    process.exit(1);
  }
}

main();

Este script de Node utiliza `google-play-scraper` para obtener metadatos en vivo desde Play Store. Analiza los archivos markdown en inglés y español para cada aplicación e inyecta las clasificaciones y números de versión actualizados. Esto asegura que el diseño responsivo en el que pasamos horas perfeccionando esté poblado con datos precisos y sincronizados. Es una pieza crítica de automatización que permite al desarrollador en solitario centrarse en las funciones, no en la entrada de datos.