/* Hand-written site styles shared by all pages.
   Kept separate from assets/css/output.css, which is generated by Tailwind. */

/* Fonts, self-hosted: the same Latin-subset files Google Fonts serves, under the
   SIL Open Font License 1.1 (OFL.txt next to each file). Loading them from this site
   removes the render-blocking fonts.googleapis.com stylesheet and two third-party
   connections from every page. Inter is a variable font; one file covers 300-500.
   Folder names carry Google's font version, so an update gets a new URL. */
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 300 500;
  font-display: swap;
  src: url('../vendor/fonts/inter-v20/inter-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
  font-family: 'Lugrasimo';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('../vendor/fonts/lugrasimo-v5/lugrasimo-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

body {
  font-family: 'Inter', sans-serif;
}

/* Motion system. Every transition on the site uses these four durations and three
   easings, so motion feels consistent. Tailwind's transition-* utilities default to
   them too (tailwind.config.js). Principles: the photo never waits on an animation;
   only transform and opacity animate; motion follows interaction or loading, never
   scroll position. */
:root {
  --dur-instant: 100ms;  /* press feedback */
  --dur-quick: 180ms;    /* hover, focus, menus */
  --dur-base: 280ms;     /* image reveal, page cross-fade */
  --dur-slow: 360ms;     /* lightbox open and close */
  --ease-out: cubic-bezier(0.2, 0, 0, 1);    /* entering */
  --ease-in: cubic-bezier(0.4, 0, 1, 1);     /* leaving */
  --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); /* moving */
  --lift: -3px;          /* photo hover travel */
  --menu-shift: -4px;    /* dropdown entry travel */
}

/* Reduced motion keeps meaning and drops movement: distances go to zero, fades
   stay but are short (a quick cross-fade does not trigger vestibular symptoms). */
@media (prefers-reduced-motion: reduce) {
  :root {
    --dur-quick: 120ms;
    --dur-base: 120ms;
    --dur-slow: 0ms;
    --lift: 0px;
    --menu-shift: 0px;
  }
}

/* Dropdown menus (Alpine x-transition classes). */
.t-menu-enter {
  transition: opacity var(--dur-quick) var(--ease-out), transform var(--dur-quick) var(--ease-out);
}
.t-menu-leave {
  transition: opacity var(--dur-quick) var(--ease-in), transform var(--dur-quick) var(--ease-in);
}
.t-menu-from {
  opacity: 0;
  transform: translateY(var(--menu-shift));
}
.t-menu-to {
  opacity: 1;
  transform: translateY(0);
}

/* Page transitions: a short cross-fade between pages, with the header held in place
   (it is identical on every page). Cross-document View Transitions; browsers
   without support (Firefox today) just navigate normally, as do visitors who ask
   for reduced motion. */
@media (prefers-reduced-motion: no-preference) {
  @view-transition {
    navigation: auto;
  }
}
::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: var(--dur-base);
  animation-timing-function: var(--ease-in-out);
}
header {
  view-transition-name: site-header;
}

/* Contact call-to-action: a light sweep across the button on hover (the idea of
   React Bits' GlareHover, written in plain CSS) and a slight press on click. */
.cta-glare {
  position: relative;
  overflow: hidden;
  transition: transform var(--dur-instant) var(--ease-out), opacity var(--dur-quick) var(--ease-out);
}
.cta-glare::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(105deg, transparent 35%, rgba(255, 255, 255, 0.35) 50%, transparent 65%);
  transform: translateX(-100%);
  pointer-events: none;
}
@media (hover: hover) and (prefers-reduced-motion: no-preference) {
  .cta-glare:hover::after {
    transform: translateX(100%);
    transition: transform calc(var(--dur-slow) * 2) var(--ease-in-out);
  }
}
.cta-glare:active {
  transform: scale(0.98);
}
@media (prefers-reduced-motion: reduce) {
  .cta-glare:active {
    transform: none;
  }
}

/* Alpine hides these (the closed menus, two of the three theme icons) only once it
   has loaded. Until then they all showed: on a slow connection both menus flashed
   open, and the three icons side by side made the phone header wrap taller, then
   shrink, shifting the whole page. x-cloak keeps them hidden until Alpine starts. */
[x-cloak] {
  display: none !important;
}

/* ...except the icon for the theme saved in this browser, which theme.js records
   on <html> before first paint, so the toggle is never empty. */
html[data-theme-choice="light"] [x-cloak][data-theme-icon="light"],
html[data-theme-choice="dark"] [x-cloak][data-theme-icon="dark"],
html[data-theme-choice="system"] [x-cloak][data-theme-icon="system"] {
  display: block !important;
}

/* Portfolio grid. Moved here from a <style> tag that portfolio-images.js injected
   at runtime, so the layout rules are parsed with the rest of the page CSS. */

/* The gallery is built by JavaScript after the page first paints. Until then the
   container is empty, so the footer painted near the top and was then shoved down
   when the photos arrived: a 0.166 layout shift, the whole of the page's CLS.
   Reserving a screen's height keeps the footer below the fold from the start. */
.portfolio-gallery {
  min-height: 100vh;
}

.portfolio-grid {
  display: flex;
  gap: 7px;
  width: 100%;
  padding: 7px;
  box-sizing: border-box;
}
@media (min-width: 640px) {
  .portfolio-grid {
    gap: 9px;
    padding: 9px;
  }
}
@media (min-width: 768px) {
  .portfolio-grid {
    gap: 11px;
    padding: 11px;
  }
}
.portfolio-column {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 7px;
  min-width: 0;
}
@media (min-width: 640px) {
  .portfolio-column {
    gap: 9px;
  }
}
@media (min-width: 768px) {
  .portfolio-column {
    gap: 11px;
  }
}
.portfolio-column img {
  width: 100%;
  height: auto;
  display: block;
  object-fit: cover;
}

/* Each photo's tile: <div> (lifts, carries the shadow) > <a> (placeholder colour,
   scrim) > <picture> > <img>. The <a>'s box is sized by the <img> width/height
   attributes before the file arrives, so the colour fills exactly the photo's space. */
.portfolio-column > div {
  position: relative;
  transition: transform var(--dur-quick) var(--ease-out);
}
.portfolio-column a {
  display: block;
  position: relative;
  overflow: hidden;
  /* The photo's average colour (from the manifest), shown while it loads. */
  background-color: var(--ph, transparent);
}

/* Soft shadow, pre-rendered on a layer whose opacity animates, so hovering never
   repaints the photo. */
.portfolio-column > div::after {
  content: "";
  position: absolute;
  inset: 0;
  box-shadow: 0 10px 24px -8px rgba(0, 0, 0, 0.35);
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--dur-quick) var(--ease-out);
}

/* A faint darkening at the bottom edge on hover. No text: the photos stay wordless. */
.portfolio-column a::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(to top, rgba(0, 0, 0, 0.35), rgba(0, 0, 0, 0) 45%);
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--dur-quick) var(--ease-out);
}

/* Hover with a mouse, or keyboard focus. The photo itself is never scaled or
   cropped (the old 1.05 zoom hid ~5% of its edges). Touch screens get no hover
   state; a tap opens the lightbox. Under reduced motion --lift is 0, so only the
   shadow and scrim fade. */
@media (hover: hover) and (pointer: fine) {
  .portfolio-column > div:hover {
    transform: translateY(var(--lift));
  }
  .portfolio-column > div:hover::after,
  .portfolio-column > div:hover a::after {
    opacity: 1;
  }
}
.portfolio-column > div:has(a:focus-visible) {
  transform: translateY(var(--lift));
}
.portfolio-column > div:has(a:focus-visible)::after,
.portfolio-column a:focus-visible::after {
  opacity: 1;
}

/* Photos below the first screenful fade in over their placeholder colour once
   loaded (portfolio-images.js adds .is-loaded). Only when scripting runs (html.js),
   so without JavaScript every photo shows normally, and never on the first six,
   which include the LCP photo. Under reduced motion the swap is instant. */
html.js .portfolio-column img[data-reveal] {
  opacity: 0;
  transition: opacity var(--dur-base) var(--ease-out);
}
html.js .portfolio-column img[data-reveal].is-loaded {
  opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
  html.js .portfolio-column img[data-reveal] {
    transition: none;
  }
}

/* Dark mode styles */
.dark {
  background-color: #1a1a1a;
  color: #f5f5f5;
}

/* Gradient accents: soft, static glows behind the About and Contact pages, never
   behind gallery photos. Warm sand and peach on white; deep indigo and teal on
   #1a1a1a. They are painted on <html> (class page-aura) with the body made
   transparent, because in dark mode <html> has its own background, which would
   cover any layer placed behind the page. Static by design: animating a page
   background repaints it every frame. */
html.page-aura {
  background-color: #ffffff;
  background-image:
    radial-gradient(60% 45% at 12% 8%, rgba(251, 191, 150, 0.22), transparent 70%),
    radial-gradient(50% 40% at 88% 28%, rgba(234, 205, 163, 0.24), transparent 70%),
    radial-gradient(60% 45% at 50% 100%, rgba(253, 186, 160, 0.14), transparent 70%);
}
html.dark.page-aura {
  background-color: #1a1a1a;
  background-image:
    radial-gradient(60% 45% at 12% 8%, rgba(99, 102, 241, 0.16), transparent 70%),
    radial-gradient(50% 40% at 88% 28%, rgba(20, 184, 166, 0.12), transparent 70%),
    radial-gradient(60% 45% at 50% 100%, rgba(79, 70, 229, 0.10), transparent 70%);
}
/* The body's own bg-white / dark:bg-[#1a1a1a] would paint over the glow. */
html.page-aura > body {
  background-color: transparent !important;
}

/* A hairline under the header on every page: warm fading out at both ends, cool in
   dark mode. Absolutely positioned, so it takes no space and shifts nothing.

   The header is its own stacking context (view-transition-name makes it one), so its
   dropdown menus' z-50 only ranks them within the header. Without a z-index of its
   own, the gallery tiles (positioned for the hover lift, and later in the page)
   painted over the open menus. z-index 40 keeps the header and its menus above page
   content; the lightbox (z-index 100000) still covers everything. */
header {
  position: relative;
  z-index: 40;
}
header::after {
  content: "";
  position: absolute;
  left: 1rem;
  right: 1rem;
  bottom: 0;
  height: 1px;
  background: linear-gradient(90deg, transparent, rgba(234, 179, 140, 0.55) 30%, rgba(234, 205, 163, 0.55) 70%, transparent);
  pointer-events: none;
}
html.dark header::after {
  background: linear-gradient(90deg, transparent, rgba(99, 102, 241, 0.45) 30%, rgba(20, 184, 166, 0.45) 70%, transparent);
}

/* Keyboard focus on a gallery photo. The link wraps the whole photo, so the ring
   sits just outside it, inside the grid gap. */
.portfolio-column a:focus-visible {
  outline: 2px solid currentColor;
  outline-offset: 2px;
}

/* Lightbox (PhotoSwipe) follows the page theme: white or #1a1a1a behind the photo,
   with icons that contrast against it. The "html" prefix outranks PhotoSwipe's own
   ".pswp" defaults, whose stylesheet lightbox.js adds after this one. */
html .pswp {
  --pswp-bg: #ffffff;
  --pswp-placeholder-bg: #f3f4f6;
  --pswp-icon-color: #1a1a1a;
  --pswp-icon-color-secondary: #ffffff;
  --pswp-icon-stroke-color: #ffffff;
  --pswp-error-text-color: #1a1a1a;
  --pswp-preloader-color: rgba(26, 26, 26, 0.25);
  --pswp-preloader-color-secondary: rgba(26, 26, 26, 0.9);
}

html.dark .pswp {
  --pswp-bg: #1a1a1a;
  --pswp-placeholder-bg: #262626;
  --pswp-icon-color: #f5f5f5;
  --pswp-icon-color-secondary: #1a1a1a;
  --pswp-icon-stroke-color: #1a1a1a;
  --pswp-error-text-color: #f5f5f5;
  --pswp-preloader-color: rgba(245, 245, 245, 0.25);
  --pswp-preloader-color-secondary: rgba(245, 245, 245, 0.9);
}
