/* ============================================================================
   Theme "lofi" — liv.pics, absent.cat, dial.pics. System fonts only, no
   external requests, no framework. Lo-fi / "2005 website" feel — the
   photography is the design. Derived from mockup/index.html (visually
   verified there via Playwright screenshots in light+dark, desktop+mobile,
   including a fix for portrait-photo hero letterboxing).
   ============================================================================ */

/* ============================================================================
   This stylesheet is written to become grav/themes/lofi/css/lofi.css as-is.
   Keep it self-contained: system fonts only, no external requests, no
   framework. Lo-fi / "2005 website" feel — the photography is the design.
   ============================================================================ */

:root {
  /* Tells the browser to paint its OWN canvas dark, not just our elements. Without it, iOS
     Safari paints white behind the page during load and in the rubber-band overscroll area —
     that is the white flash on refresh in dark mode, and the white bars above and below the
     page. It cannot be fixed from background-color alone, because the flash happens before
     our background paints. Mirrors the --bg override pattern below so a viewer's manual
     light/dark choice wins over the system setting. */
  color-scheme: light;
  --bg: #ffffff;
  --fg: #111111;
  --fg-dim: #666666;
  --link: #0000cc;
  --link-visited: #551a8b;
  --rule: #dddddd;
  --nav-bg: rgba(255,255,255,0.92);
  --panel-bg: #f7f7f7;
  /* Barriecito (Google Fonts, loaded in head.html.twig — this theme's one deliberate exception
     to "no external requests") for display text only — site name, hero title — a big expressive
     hand-drawn feel. Syne Mono covers literally everything else (body copy, nav links, the
     when/where line, filters, footer) per direct instruction that it should be used for "body
     text and all other text". Both fall back to a chunky system stack if the font request ever
     fails (offline, CSP blocked, etc.). --mono kept as its own variable (rather than reusing
     --sans) since Syne Mono is itself monospace and the when/where line already leaned on that
     styling before this swap. */
  --display: 'Barriecito', 'Trebuchet MS', Verdana, sans-serif;
  --sans: 'Syne Mono', 'Courier New', monospace;
  --mono: 'Syne Mono', 'Courier New', monospace;
  --photo-hue: 0; /* set from the current hero photo's average colour once js/lofi.js can read
                      it (see "whole-page photo theming" below); harmless default until then. */
  --photo-sat: 0%;
  /* Nav pills (topbar) and hero-caption highlights share this hierarchy: pill-1 is the strongest
     (site name / hero title), pill-2 and pill-3 progressively fade, all borrowing the photo's hue
     the same way the rest of .photo-themed does. Untinted defaults (no photo theme active yet, or
     canvas sampling failed) fall back to the existing neutral panel/bg tones. */
  --nav-pill-1: var(--panel-bg);
  --nav-pill-2: var(--bg);
  --nav-pill-3: var(--panel-bg);
  --nav-pill-fg: var(--fg);
  --nav-pill-border: var(--rule);
  /* Tint carried by the blurred band behind the two nav bars (.nav-veil). Deliberately much
     more transparent than --nav-bg: the band is meant to read as the page softening behind the
     bars, not as a panel sitting on top of it. */
  --veil-tint: rgba(255,255,255,0.3);
  /* The shooting-metadata line under the hero caption. Its own tone rather than --fg-dim: it is
     the smallest type on the page (11px mono) and it sits on the ambient colour wash rather than
     on flat background, and at the general dim grey it was legible in theory and hard to read in
     practice. Everything else --fg-dim is used for is larger, on plainer ground, or both. */
  --meta-fg: #232323;
}

:root[data-theme="dark"] {
  color-scheme: dark;
  --bg: #0b0b0b;
  --fg: #e8e8e8;
  --fg-dim: #999999;
  --link: #9db4ff;
  --link-visited: #c9a8ff;
  --rule: #2a2a2a;
  --nav-bg: rgba(11,11,11,0.92);
  --panel-bg: #161616;
  --veil-tint: rgba(11,11,11,0.4);
  --meta-fg: #b0b0b0; /* dark mode reads fine already — kept a shade up from --fg-dim */
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    color-scheme: dark;
    --bg: #0b0b0b;
    --fg: #e8e8e8;
    --fg-dim: #999999;
    --link: #9db4ff;
    --link-visited: #c9a8ff;
    --rule: #2a2a2a;
    --nav-bg: rgba(11,11,11,0.92);
    --panel-bg: #161616;
    --veil-tint: rgba(11,11,11,0.4);
    --meta-fg: #b0b0b0;
  }
}

/* ---------- whole-page photo theming ---------- */

/* js/lofi.js samples the average colour of whatever photo is currently in the hero and sets
   --photo-hue/--photo-sat from it, then adds .photo-themed here to recolour the entire page —
   background, nav, panel, links, borders — to match. Only the HUE (and a capped SATURATION)
   come from the photo; LIGHTNESS is still pinned to fixed light/dark anchors below, same as the
   untinted theme, so contrast stays safe regardless of how light, dark or saturated the source
   photo is — a black-and-white photo (near-zero saturation) just quietly falls back to
   effectively the untinted theme instead of forcing a colour that isn't really there. Read-only
   from CSS's side: if the browser can't read the image's pixels (a cross-origin CDN image with
   no CORS header, eventually — see js/lofi.js), .photo-themed is simply never added and the page
   keeps its plain light/dark theme, no error, no broken styling. */
:root.photo-themed {
  --bg: hsl(var(--photo-hue) calc(var(--photo-sat) * 0.55) 95%);
  --nav-bg: hsl(var(--photo-hue) calc(var(--photo-sat) * 0.55) 95% / 0.9);
  --panel-bg: hsl(var(--photo-hue) var(--photo-sat) 90%);
  --rule: hsl(var(--photo-hue) var(--photo-sat) 78%);
  --link: hsl(var(--photo-hue) 78% 34%);
  --link-visited: hsl(calc(var(--photo-hue) + 30) 65% 40%);
  --fg: hsl(var(--photo-hue) 20% 10%);
  --fg-dim: hsl(var(--photo-hue) 14% 34%);
  --nav-pill-1: hsl(var(--photo-hue) calc(var(--photo-sat) * 0.85) 58%);
  --nav-pill-2: hsl(var(--photo-hue) calc(var(--photo-sat) * 0.6) 75%);
  --nav-pill-3: hsl(var(--photo-hue) calc(var(--photo-sat) * 0.35) 88%);
  --nav-pill-fg: hsl(var(--photo-hue) 25% 12%);
  --nav-pill-border: hsl(var(--photo-hue) var(--photo-sat) 40%);
  --veil-tint: hsl(var(--photo-hue) calc(var(--photo-sat) * 0.55) 95% / 0.3);
  --meta-fg: hsl(var(--photo-hue) 22% 11%);
}
:root[data-theme="dark"].photo-themed {
  --bg: hsl(var(--photo-hue) calc(var(--photo-sat) * 0.5) 8%);
  --nav-bg: hsl(var(--photo-hue) calc(var(--photo-sat) * 0.5) 8% / 0.9);
  --panel-bg: hsl(var(--photo-hue) calc(var(--photo-sat) * 0.7) 14%);
  --rule: hsl(var(--photo-hue) calc(var(--photo-sat) * 0.6) 26%);
  --link: hsl(var(--photo-hue) 85% 76%);
  --link-visited: hsl(calc(var(--photo-hue) + 30) 70% 78%);
  --fg: hsl(var(--photo-hue) 16% 93%);
  --fg-dim: hsl(var(--photo-hue) 12% 68%);
  --nav-pill-1: hsl(var(--photo-hue) calc(var(--photo-sat) * 0.8) 32%);
  --nav-pill-2: hsl(var(--photo-hue) calc(var(--photo-sat) * 0.55) 22%);
  --nav-pill-3: hsl(var(--photo-hue) calc(var(--photo-sat) * 0.35) 15%);
  --nav-pill-fg: hsl(var(--photo-hue) 20% 92%);
  --nav-pill-border: hsl(var(--photo-hue) calc(var(--photo-sat) * 0.6) 45%);
  --veil-tint: hsl(var(--photo-hue) calc(var(--photo-sat) * 0.5) 8% / 0.4);
  --meta-fg: hsl(var(--photo-hue) 14% 76%);
}
@media (prefers-color-scheme: dark) {
  :root.photo-themed:not([data-theme="light"]) {
    --bg: hsl(var(--photo-hue) calc(var(--photo-sat) * 0.5) 8%);
    --nav-bg: hsl(var(--photo-hue) calc(var(--photo-sat) * 0.5) 8% / 0.9);
    --panel-bg: hsl(var(--photo-hue) calc(var(--photo-sat) * 0.7) 14%);
    --rule: hsl(var(--photo-hue) calc(var(--photo-sat) * 0.6) 26%);
    --link: hsl(var(--photo-hue) 85% 76%);
    --link-visited: hsl(calc(var(--photo-hue) + 30) 70% 78%);
    --fg: hsl(var(--photo-hue) 16% 93%);
    --fg-dim: hsl(var(--photo-hue) 12% 68%);
    --nav-pill-1: hsl(var(--photo-hue) calc(var(--photo-sat) * 0.8) 32%);
    --nav-pill-2: hsl(var(--photo-hue) calc(var(--photo-sat) * 0.55) 22%);
    --nav-pill-3: hsl(var(--photo-hue) calc(var(--photo-sat) * 0.35) 15%);
    --nav-pill-fg: hsl(var(--photo-hue) 20% 92%);
    --nav-pill-border: hsl(var(--photo-hue) calc(var(--photo-sat) * 0.6) 45%);
    --veil-tint: hsl(var(--photo-hue) calc(var(--photo-sat) * 0.5) 8% / 0.4);
    --meta-fg: hsl(var(--photo-hue) 14% 76%);
  }
}

* { box-sizing: border-box; }

/* Global safety net: an element with an explicit "display" rule elsewhere in this stylesheet
   (".grid a.tile { display: block }" was the first one caught) silently wins over the browser's
   own [hidden]{display:none} UA-stylesheet default — author CSS always beats UA CSS regardless
   of specificity. That cost a whole round of "the filters don't work" bug reports before it was
   traced back to this. Rather than keep patching each element that later grows an explicit
   display rule and a hidden toggle, one !important rule here makes [hidden] unconditionally work
   everywhere in this theme, permanently. */
[hidden] { display: none !important; }

html, body {
  margin: 0;
  padding: 0;
  background: var(--bg);
  color: var(--fg);
  /* The ambient wash is deliberately wider than the viewport (see --ambient-bleed in
     .hero-ambient) so its blurred edges fall outside it; without this it would also be wide
     enough to scroll sideways. "clip" rather than "hidden" so this doesn't turn either element
     into a scroll container; "hidden" is left in front of it as the fallback for browsers
     without clip (Safari < 16). */
  overflow-x: hidden;
  overflow-x: clip;
}

body {
  font-family: var(--sans);
  font-size: 13px;
  line-height: 1.5;
  /* No reserved top space for the nav any more — it's a floating overlay (see .topbar below),
     not a bar that pushes content down, so the hero can start almost flush with the page top. */
  transition: background-color 0.6s ease, color 0.6s ease;
}

/* Recolouring happens by changing the custom properties above; these transitions are what make
   that read as a smooth wash across the page rather than a hard cut whenever the hero photo
   (and so --photo-hue/--photo-sat) changes. */
.topbar, .hamburger, .panel, .filters select, .grid a.tile.current, footer, .hero .meta {
  transition: background-color 0.6s ease, color 0.6s ease, border-color 0.6s ease, outline-color 0.6s ease;
}

a {
  color: var(--link);
  text-decoration: underline;
  transition: color 0.6s ease;
}
a:visited { color: var(--link-visited); }
a:hover { text-decoration: none; }

/* ---------- top bar: thin, fades to hamburger on scroll ---------- */

/* Floating nav: no full-width bar, no dividing line — each item is its own pill, the site name
   pinned left and the action links pinned right, drifting over whatever's scrolled to the top of
   the page rather than reserving its own strip (see body's removed padding-top above). The pills
   carry their own solid-ish backgrounds specifically so text stays legible wherever the nav
   happens to land, hero photo or grid tiles alike — same --nav-pill hierarchy used by the hero
   caption below, so both read as one system. */
.topbar {
  position: fixed;
  /* Clear of the notch on a phone held upright, unchanged (10px) everywhere else — env() is 0
     on any device without an inset. Needs viewport-fit=cover in head.html.twig to be non-zero. */
  top: calc(10px + env(safe-area-inset-top, 0px)); left: 0; right: 0;
  display: flex;
  flex-wrap: wrap; /* narrow (mobile) viewports drop the whole action group below the site name
                       rather than squeezing text to wrap awkwardly inside a single pill */
  justify-content: space-between;
  align-items: center;
  gap: 8px;
  /* Landscape: the pills would otherwise sit under the rounded corner / camera housing. */
  padding: 0 calc(14px + env(safe-area-inset-right, 0px)) 0 calc(14px + env(safe-area-inset-left, 0px));
  font-size: 13px;
  font-weight: bold;
  z-index: 100;
  transition: opacity 0.25s ease;
  pointer-events: none; /* only the pills themselves are interactive, not the gaps between them */
}
.topbar > * { pointer-events: auto; }

.topbar.faded {
  opacity: 0;
  pointer-events: none;
}

.topbar .site-name { display: flex; }
/* The action links as their own right-aligned group, so .topbar itself only ever has two flex
   children (name, actions) and space-between keeps them at opposite ends regardless of how many
   links the actions group grows to hold. Wraps to its own second row on narrow viewports rather
   than breaking the left/right split. */
.topbar .nav-actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  align-items: center;
  gap: 8px;
}
.topbar .site-name a {
  font-family: var(--display);
  font-size: 19px;
  text-decoration: none;
  color: var(--nav-pill-fg);
  background: var(--nav-pill-1);
  border: 2px solid var(--nav-pill-border);
  border-radius: 0;
  padding: 5px 16px;
  white-space: nowrap;
  transition: background-color 0.6s ease, color 0.6s ease, border-color 0.6s ease;
}

.topbar button.link-style,
.topbar a.js-only {
  background: var(--nav-pill-3);
  color: var(--nav-pill-fg);
  border: 2px solid var(--nav-pill-border);
  border-radius: 0;
  padding: 5px 12px;
  font: inherit;
  font-weight: bold;
  text-decoration: none;
  white-space: nowrap;
  cursor: pointer;
  transition: background-color 0.6s ease, color 0.6s ease, border-color 0.6s ease;
}
.topbar button.link-style:hover,
.topbar a.js-only:hover {
  background: var(--nav-pill-2);
}

/* "all photos" once you have scrolled past the filter bar: you are already among the photos, so
   the link stops offering to take you there. Dimmed rather than removed — a nav that loses an
   item as you scroll is worse than one that shows an item is spent — and inert, matching the
   reset button's treatment in the filter row. Eased in both directions (the pill's own colours
   already transition over the same 0.6s when the photo theme changes) so it reads as the link
   going quiet rather than as something switching off. */
.topbar a.inactive,
.panel a.inactive {
  opacity: 0.4;
  cursor: default;
  pointer-events: none;
}
.topbar a#allPhotosLink,
.panel a#panelAllPhotosLink {
  transition: opacity 0.45s ease, background-color 0.6s ease, color 0.6s ease, border-color 0.6s ease;
}

/* One soft band sitting behind both bars — the floating nav at the very top and the filter row
   pinned below it — instead of each carrying its own panel colour. Strongest at the top and
   easing off to nothing below the filter row, so there's no hard edge anywhere; the page just
   gradually comes back into focus underneath it.
   Built as three stacked layers (this element plus its two pseudo-elements) rather than one
   blur: a single backdrop-filter can only be faded in OPACITY, which reads as a panel getting
   more transparent, not as focus returning. Each layer blurs harder than the one under it and is
   masked to a shorter run from the top, so the blur radii accumulate near the nav and drop away
   one layer at a time going down — an actual blur gradient.
   Only shown while the filter row is pinned (class set by js/lofi.js); at rest, the top of the
   page is the hero photo and should be left alone. */
.nav-veil {
  position: fixed;
  top: 0; left: 0; right: 0;
  height: 200px;
  z-index: 50; /* beneath .topbar (100) and the sticky .filters (60), above page content */
  pointer-events: none; /* never intercepts a click aimed at the photo or grid underneath */
  opacity: 0;
  /* Flat colour plus the mask below, NOT a linear-gradient of that colour: a gradient's colour
     stops can't be transitioned, so on a light/dark switch the band's tint snapped to its new
     value while the whole page underneath took 0.6s to follow — half a second of a bright (or
     black) bar sitting across the top of the page. As a background-color it transitions in step
     with everything else, and the mask below shapes the same fade the gradient used to. */
  background-color: var(--veil-tint);
  backdrop-filter: blur(2px);
  -webkit-backdrop-filter: blur(2px);
  -webkit-mask-image: linear-gradient(to bottom, black 30%, rgba(0,0,0,0.35) 72%, transparent 100%);
          mask-image: linear-gradient(to bottom, black 30%, rgba(0,0,0,0.35) 72%, transparent 100%);
  transition: opacity 0.3s ease, background-color 0.6s ease;
}
.nav-veil::before,
.nav-veil::after {
  content: '';
  position: absolute;
  inset: 0;
}
.nav-veil::before {
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  -webkit-mask-image: linear-gradient(to bottom, black 38%, transparent 72%);
          mask-image: linear-gradient(to bottom, black 38%, transparent 72%);
}
.nav-veil::after {
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
  -webkit-mask-image: linear-gradient(to bottom, black 18%, transparent 48%);
          mask-image: linear-gradient(to bottom, black 18%, transparent 48%);
}
:root.filters-stuck .nav-veil { opacity: 1; }
/* The one case where the band should not fade: js/lofi.js drops it in the same frame as the
   jump to the top after a grid click, and a fade there would be a smear left behind by a
   movement that was instant. */
:root.veil-instant .nav-veil { transition: none; }



.hamburger {
  position: fixed;
  top: 6px;
  right: 10px;
  z-index: 110;
  width: 32px;
  height: 32px;
  border: 2px solid var(--rule);
  background: var(--nav-bg);
  color: var(--fg);
  font-size: 16px;
  line-height: 1;
  cursor: pointer;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.25s ease;
}
.hamburger.visible {
  opacity: 1;
  pointer-events: auto;
}

.panel {
  position: fixed;
  top: 42px;
  right: 10px;
  z-index: 109;
  background: var(--panel-bg);
  border: 2px solid var(--rule);
  padding: 10px 14px;
  font-size: 12px;
  min-width: 200px;
  display: none;
}
.panel.open { display: block; }
/* Panel entries are the same pills as the topbar's, not underlined text links. On a phone this
   panel IS the navigation, and a 12px underlined link is a ~16px tap target — under half
   Apple's 44px minimum, which is what made these so fiddly. Sized to 44px here, full-width so
   the target is the whole row rather than just the words. */
.panel a, .panel button {
  display: block;
  width: 100%;
  margin: 8px 0;
  box-sizing: border-box;
  min-height: 44px;
  display: flex;
  align-items: center;
  font-size: 14px;
  background: var(--nav-pill-3);
  color: var(--nav-pill-fg);
  border: 2px solid var(--nav-pill-border);
  border-radius: 0;
  padding: 5px 14px;
  text-decoration: none;
  text-align: left;
  cursor: pointer;
  font-family: inherit;
  font-weight: bold;
  transition: background-color 0.6s ease, color 0.6s ease, border-color 0.6s ease;
}
/* Deliberately does NOT use `font: inherit`: the shorthand resets font-size to the .panel's own
   12px, which made the button entries a size smaller than the link entries sitting next to them.
   The pill rule above already sets family, size and weight for both. */
.panel button.link-style {
  font-family: inherit;
  font-size: 14px;
  font-weight: bold;
}
.panel a:hover, .panel button:hover { background: var(--nav-pill-2); }
/* A disabled control (HDR on a standard display) still reads as a pill, just visibly inert. */
.panel button[disabled] { opacity: 0.45; cursor: default; }
.panel button[disabled]:hover { background: var(--nav-pill-3); }

/* Desktop: the topbar is a permanently floating strip, not something that fades away and hands
   off to a hamburger — there's room for it to just stay put. Mobile keeps the fade+hamburger
   (see js/lofi.js's scroll listener), since a full-width bar competing with a narrow viewport is
   the actual reason that behaviour existed in the first place. */
/* Mobile: the nav is the hamburger, from the first paint. The action pills used to sit in the
   bar and wrap onto a second row on a narrow screen, which put two rows of furniture across the
   top of the photograph; and the hamburger only appeared once you had scrolled, so the crowded
   version was the one you actually arrived at. The pills all live in the panel behind the
   hamburger instead, and only the site name stays in the bar. */
@media (max-width: 699px) {
  .topbar .nav-actions { display: none; }
  /* Out-specifying ".js .js-only { display: revert }" and the base opacity:0 in one go — same
     cascade footgun as the desktop rule below. */
  .js .hamburger.js-only {
    display: block;
    opacity: 1;
    pointer-events: auto;
  }
}

@media (min-width: 700px) {
  .topbar.faded { opacity: 1; pointer-events: auto; }
  /* .hamburger also carries .js-only, whose own "display: revert" rule (further down this file)
     matches with the same two-class specificity and would otherwise win this — same cascade
     footgun already fixed once for .filters and .panel button, same fix: out-specificity it
     rather than depend on source order. */
  .js .hamburger.js-only { display: none; }
  .panel { display: none !important; }
}

/* ---------- hero ---------- */

.hero {
  /* Fills nearly the whole viewport on load. The floating nav no longer reserves its own strip
     (body has no padding-top any more — see above), so this only needs to account for its own
     top/bottom margin, not the topbar's height; the nav simply floats over the top of the photo
     instead. Shared by .frame and .hero img below — keep the two in sync. --hero-top-gap is its
     own variable (not a hardcoded margin) specifically so .hero-ambient below can read the same
     value and extend up to cover it — otherwise that margin is a flat, untinted --bg sliver
     between the true page top and where the ambient wash begins, which reads as a stray "bar"
     above the photo once you scroll up even slightly. */
  --hero-h: calc(100vh - 16px);
  --hero-top-gap: 8px;
  position: relative;
  /* Wider than the grid/filters/footer below (all capped at 1400px) — the hero is the one
     full-bleed showcase element on the page, and a landscape photo needs real width headroom
     to reach the same visual size as a portrait one: with --hero-h fixed regardless of aspect
     ratio (see .frame below), a landscape photo is width-constrained long before it's
     height-constrained unless the box is wide enough, and a 1400px cap left it visibly
     letterboxed ("windowboxed") next to a portrait photo filling the same box top to bottom. */
  max-width: 1800px;
  margin: var(--hero-top-gap) auto 0;
  padding: 0 12px;
}

/* Ambient background: a heavily blurred, scaled copy of the same photo, bleeding out to full
   viewport width behind the hero (breaking out of .hero's own max-width via the classic
   left:50%/-50vw-translate trick) but nowhere else — not the nav, not the filters, not the
   grid. Filled in (background-image + .ready) and swapped by js/lofi.js; a plain photo with no
   ambient effect is exactly what a no-JS visitor sees, which is a perfectly fine fallback.
   z-index 0 vs. .frame/.meta's 1 — position:absolute content otherwise paints ABOVE
   static-positioned siblings regardless of z-index, so those two need their own stacking order
   pinned explicitly or the ambient layer would cover the photo. */
.hero-ambient {
  position: absolute;
  /* Bleeds a long way past the top edge on purpose, and not just over .hero's own top margin:
     filter: blur() fades out toward the element's own boundary, because beyond that boundary it
     has nothing but transparency to sample. An edge sitting anywhere inside the viewport
     therefore paints a band of flat --bg — a dark bar across the top of the page in dark mode,
     where --bg is near-black and the wash above it is bright. Pushing the edge ~3x the blur
     radius off-screen keeps the faded part where nobody sees it. (The left/right edges fade the
     same way; --ambient-bleed widens the element past both, which is why html/body clip
     overflow-x — see the rule near the top of this file.) */
  --ambient-bleed: 160px;
  top: calc(-1 * var(--hero-top-gap) - var(--ambient-bleed));
  /* Extends past .hero's own bottom edge into the filters bar and the first inch of the grid —
     deliberately: .filters and .grid have no background colour of their own, so the colour
     bleeds through the gaps between their controls/tiles, tying the whole top of the page
     together instead of stopping dead at the hero's edge. Nothing downstream needs to know
     about this; position:absolute content isn't clipped by a non-overflow:hidden ancestor, and
     later siblings in normal flow (.filters, .grid) still paint over it wherever they have
     their own content, by ordinary DOM paint order. */
  bottom: -260px;
  left: 50%;
  width: calc(100vw + var(--ambient-bleed) * 2);
  transform: translateX(-50%);
  z-index: 0;
  background-position: center;
  background-size: cover;
  background-repeat: no-repeat;
  filter: blur(50px) saturate(1.9) brightness(1.02);
  opacity: 0;
  transition: opacity 0.6s ease;
  /* Full strength from the very top (no fade-in — the colour should read right from the top of
     the page, under the nav), tapering only near the bottom: fainter across the caption, and
     gone by the end of the 260px extension into the filters/grid, so the wash bleeds into them
     rather than stopping dead at the hero's edge.
     Measured from the element's BOTTOM (calc(100% - …)) rather than as percentages of its
     height — percentages would shift the whole taper upward over the photo every time
     --ambient-bleed above grows the element at the top. */
  -webkit-mask-image: linear-gradient(to bottom, black calc(100% - 500px), rgba(0,0,0,0.45) calc(100% - 330px), transparent 100%);
          mask-image: linear-gradient(to bottom, black calc(100% - 500px), rgba(0,0,0,0.45) calc(100% - 330px), transparent 100%);
  pointer-events: none;
}
/* On the home page the wash deliberately bleeds 260px past the hero, into the filter row and
   the top of the grid. A photo's own page has nothing below the hero but the footer, so that
   same bleed had nowhere to go: as an absolutely-positioned child it still counts toward the
   page's scrollable area, and it was adding ~130px of empty scroll below the footer — a grey
   void under the page that no element explained. Ending it at the hero's own bottom edge costs
   nothing there, since the blur fades out over the last stretch anyway. */
.hero.solo .hero-ambient { bottom: 0; }

.hero-ambient.ready { opacity: 0.85; }
:root[data-theme="dark"] .hero-ambient.ready { opacity: 0.55; }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .hero-ambient.ready { opacity: 0.55; }
}

/* Fixed height regardless of the photo's own aspect ratio — a portrait photo no longer makes
   the hero taller than a landscape one. That matters now that clicking a photo swaps it in
   place: a frame that resized itself to each photo's proportions shifted the filters/grid
   below on every swap, which felt like the page jumping under you. True proportions are kept
   (no cropping — width/height auto, capped by max-width/max-height, so the image still shows
   in full) at the cost of letterboxing around photos that don't match the frame's proportions;
   the ambient background (above) exists specifically to make that letterboxing a colour wash
   instead of a flat gap, so the trade-off doesn't read as dead space. */
.hero .frame {
  position: relative;
  z-index: 1;
  display: flex;
  justify-content: center;
  align-items: center;
  height: var(--hero-h);
}
/* While js/lofi.js works out which photo actually belongs in the hero (a random one on the home
   page, the project's cover on a ?project= link), the photo the server had to put there is held
   back — otherwise it paints, and is then replaced, which reads as a flash of a photo you didn't
   ask for. Only the <picture> is hidden: the ambient colour wash behind it stays, so this is a
   soft field of colour for those few frames rather than an empty box. Never applied without JS,
   which is the case the served photo exists for. */
.hero.hero-pending .frame > picture {
  /* opacity, NOT visibility. Both hide it, but `visibility` is not a compositor-friendly
     property: Safari has been seen leaving the photo unpainted when it flips back — the hero
     showing as a flat block of the ambient wash until a scroll forced a repaint. Opacity is
     animated on the compositor and doesn't have that failure mode. */
  opacity: 0;
  /* Last resort: if js/lofi.js never runs at all (blocked, failed, errored) the photo would
     otherwise stay hidden forever. This reveals it after a couple of seconds — long past when
     the script would normally have swapped it, invisible when everything works. */
  animation: hero-pending-reveal 0.01s linear 2.5s forwards;
}
@keyframes hero-pending-reveal { to { opacity: 1; } }

/* Where a photo waits while it loads. js/lofi.js stages the incoming <picture> here, out of
   flow and invisible, so the browser fetches it without it affecting anything on screen; the
   moment it is ready the old photo is removed and this class comes off, in the same frame. No
   transition of any kind: stepping between photos is navigation, and every fade tried here —
   a page-level view transition, then a local cross-dissolve — showed as a flash between photos
   of different shapes. */
.hero .frame > .hero-incoming {
  position: absolute;
  inset: 0;
  display: flex;
  justify-content: center;
  align-items: center;
  opacity: 0;
}
/* Holds the next photo's <picture> purely so the browser fetches it (js/lofi.js's
   prefetchNeighbour). Not display:none — an image in a display:none subtree is still fetched,
   but it also can't be relied on across engines, and this way it behaves exactly like the hero
   it is standing in for. Zero-sized, invisible and inert. */
.hero-prefetch {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  opacity: 0;
  pointer-events: none;
}
.hero img {
  display: block;
  width: auto;
  height: auto;
  max-width: 100%;
  /* Matches .frame's own height literally rather than "100%" — .frame uses align-items:center,
     so its flex-item child (<picture>) sizes to its content and never gets a definite height for
     a percentage to resolve against; percentage max-height was silently ignored, and the image
     rendered at full natural size, overflowing down over the grid below. Caught by a real
     browser click landing on the (invisibly oversized) hero image instead of the grid tile it
     was aimed at, not by reading the CSS. */
  max-height: var(--hero-h);
  margin: 0 auto;
  /* Deliberately NO background colour. There used to be a --panel-bg one here "visible only
     while the image itself is loading" — but swapping the hero in place (a grid click, prev/
     next) replaces the <picture> with one whose largest srcset entry usually isn't cached yet,
     so that "only while loading" window is every single swap, and a near-white panel the size
     of the whole hero flashing on each one is far more jarring than the alternative. With no
     background, what shows through for those few frames is .hero-ambient: the blurred colour
     wash of the photo being loaded, which is the closest thing to it that can be painted before
     it arrives. */
}

/* Prev/next: navigate the hero in place (js/lofi.js swaps the photo and updates the permalink —
   no page load). Subtle by design, brightening on hover/focus rather than shouting for
   attention; a no-JS visitor never sees them (js-only). */
.hero-nav {
  position: absolute;
  top: 50%;
  z-index: 2;
  transform: translateY(-50%);
  width: 36px;
  height: 36px;
  border-radius: 50%;
  border: 1px solid var(--rule);
  background: var(--nav-bg);
  color: var(--fg);
  font-size: 15px;
  line-height: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  opacity: 0.5;
  transition: opacity 0.2s ease;
}
.hero-nav:hover, .hero-nav:focus-visible { opacity: 1; }
.hero-nav.prev { left: 4px; }
.hero-nav.next { right: 4px; }

/* On anything wider than a phone, give the arrows a real gutter of their own — a landscape
   photo can fill the whole frame width (see .hero's own comment above), and at the old 12px
   side padding a 36px-wide button sitting 4px in from .hero's edge landed mostly ON TOP of the
   photo's own edge rather than beside it. Widening .hero's padding here (not .frame/.grid/
   .filters, which stay at their old width) pushes the photo itself in just enough that the
   button fits entirely in the gap next to it. Left at 12px on phones, where there's no width to
   spare and a small overlap with the photo edge is the normal, accepted trade-off (same as any
   lightbox/gallery UI). */
@media (min-width: 700px) {
  .hero { padding: 0 44px; }
}

.hero .meta {
  position: relative;
  z-index: 1;
  /* A few px more top padding than it looks like it needs — with only 6px, the top of the title
     pill sat close enough to the photo above it that a sliver of it read as tucked half-under
     the frame rather than clearly below it. */
  padding: 10px 2px 2px;
  font-size: 12px;
  color: var(--fg-dim);
  text-align: center;
  /* Legibility comes from a genuinely FIXED height (not just a minimum) plus overflow:hidden,
     from the title/when pills below having their own solid-ish backgrounds, and from the
     description (below) and tags row (further below) each being capped to how much space
     they're given, rather than from a lighting trick on the text itself. Sized for the tallest
     realistic case — title + when/location + exif + one row of tags — measured in a real browser
     across every photo rather than guessed, so there's as little dead space as possible above
     the filter row. Photos with descriptions need more room than that, so js/lofi.js raises
     --meta-h when any photo in the set actually has one; there's no point reserving space for a
     description on a set that has none. */
  height: var(--meta-h, 170px);
  overflow: hidden;
}
/* The standalone photo page has no grid under it, so there's nothing for a varying caption
   height to shove around — and it carries an extra element (the back link) that a height fixed
   for the home page would crop. Let it size to its content. */
.hero.solo .meta {
  height: auto;
  overflow: visible;
}
/* The standalone photo page is the one place the caption is the point, not an afterthought —
   it carries the title, date/place, exif, tags AND the way back to the grid. At the home page's
   near-full-viewport photo height, all of that sat below the fold, so reading it meant scrolling
   the top of the photo off screen: you could have the photo or its details, never both. Give
   the caption its share of the viewport instead, so the whole thing fits at once on a desktop
   screen. Left alone below 700px, where a photo shrunk enough to fit a caption under it on a
   phone would be too small to be worth showing. */
@media (min-width: 700px) {
  .hero.solo { --hero-h: calc(100vh - 300px); }
}
.hero .meta .title,
.hero .meta .when {
  /* display:block + width:fit-content (not inline-block) — the pill still hugs just its own
     text width and stays centred (auto margins), but "block" guarantees it always starts a new
     line below whatever came before it. Two differently-sized inline-block pills otherwise just
     sit side by side on the same line whenever there's room, which is exactly what looked
     unbalanced: a big title pill and a small when/where pill next to each other. */
  display: block;
  width: fit-content;
  transition: background-color 0.6s ease, color 0.6s ease, border-color 0.6s ease;
}
.hero .meta .title {
  font-family: var(--display);
  font-size: 30px;
  color: var(--nav-pill-fg);
  background: var(--nav-pill-1);
  border: 2px solid var(--nav-pill-border);
  border-radius: 0;
  padding: 3px 16px;
  margin: 0 auto 4px;
}
.hero .meta .when {
  font-family: var(--mono);
  color: var(--nav-pill-fg);
  background: var(--nav-pill-3);
  border: 2px solid var(--nav-pill-border);
  border-radius: 0;
  padding: 2px 12px;
  margin: 0 auto;
}
/* Back to the grid, on the standalone photo page — the one navigation control on a page that has
   no nav of its own beyond the floating bar, so it gets the same chunky pill treatment as
   everything else rather than being a small text link lost under the caption. */
.hero .meta .back {
  /* Bottom margin only matters here (not on the home page, which has no .back at all) — the
     footer's border-top sat close enough underneath it on the solo page to read as crowding. */
  margin: 14px 0 28px;
}
.hero .meta .back a {
  display: inline-block;
  font-family: var(--display);
  font-size: 20px;
  text-decoration: none;
  color: var(--nav-pill-fg);
  background: var(--nav-pill-3);
  border: 2px solid var(--nav-pill-border);
  border-radius: 0;
  padding: 5px 18px;
  transition: background-color 0.6s ease, color 0.6s ease, border-color 0.6s ease;
}
.hero .meta .back a:hover {
  background: var(--nav-pill-1);
}

/* Shooting metadata (camera, focal length, shutter speed, ISO, white balance) — deliberately
   NOT another pill: plain small mono text so five extra data points don't compete visually with
   the title/when pills above or eat much vertical space. Any field the photo doesn't have is
   just missing from the line (see partials/exif.html.twig), not a blank gap. */
.hero .meta .exif {
  margin: 4px auto 0;
  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: 0.02em;
  color: var(--meta-fg);
}
.hero .meta .desc {
  margin: 6px 0;
  color: var(--fg);
  font-size: 13px;
  /* Clamped rather than left to grow freely — .meta's own overflow:hidden would just as happily
     crop it mid-line; this keeps the crop at a clean line boundary instead. */
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* Shared tag-pill look for hero tags and the filter bar's tag chips — chunky and square by
   design: thicker border, bold text, no rounding, rather than a thin, quiet, rounded chip. */
.hero .meta .tags {
  /* A photo with many tags would otherwise wrap onto a second or third row and, now that .meta
     is a fixed height, get cropped mid-pill by its overflow:hidden — capped to one row's worth
     here instead, so any tags that don't fit are cleanly absent rather than half-cut-off. */
  max-height: 30px;
  overflow: hidden;
}
.hero .meta .tags a,
.filters .tag-chip,
.project-filter .tag-chip {
  display: inline-block;
  margin: 3px 6px 3px 0;
  padding: 3px 10px;
  border-radius: 0;
  background: var(--panel-bg);
  border: 2px solid var(--rule);
  color: var(--fg-dim);
  font-size: 12px;
  font-weight: bold;
  text-decoration: none;
  font-family: inherit;
  cursor: pointer;
  white-space: nowrap;
}
.hero .meta .tags a:hover,
.filters .tag-chip:hover,
.project-filter .tag-chip:hover {
  color: var(--bg);
  background: var(--link);
  border-color: var(--link);
}
.filters .tag-chip .count,
.project-filter .tag-chip .count {
  margin-left: 5px;
  font-size: 10px;
  opacity: 0.7;
}
.filters .tag-chip.active,
.project-filter .tag-chip.active {
  color: #fff;
  background: var(--link);
  border-color: var(--link);
}

/* ---------- filter row ---------- */

/* Tag chips are the primary browse surface here (see photos.html.twig for why — sparse date/
   location metadata but consistent keywording), so the bar is built around them: one scrolling
   row of chips, then a count/sort/size/reset line underneath. Sticky at every width (not just
   desktop, as it used to be) — one compact line no longer costs the mobile viewport much. */
.filters {
  --filters-top: 8px;
  max-width: 1400px;
  margin: 4px auto 0;
  padding: 6px 12px;
  border-top: 1px solid var(--rule);
  border-bottom: 1px solid var(--rule);
  font-size: 12px;
  display: flex;
  flex-direction: column;
  gap: 4px;
  /* The "filter" nav link is a plain #filters anchor, and the floating nav sits over the top of
     the viewport — without this the jump lands with the nav covering the filter row. Also
     matches the sticky offset below, so arriving here looks identical to having scrolled here. */
  scroll-margin-top: calc(var(--filters-top) + 8px);
}
.js .filters {
  position: sticky;
  top: var(--filters-top);
  z-index: 60;
}
@media (min-width: 700px) {
  .filters { --filters-top: 56px; } /* clears the floating nav, whose tallest pill bottoms out around y=52 */
}
/* Readability while pinned comes from .nav-veil (below) — one soft band behind BOTH bars —
   rather than from a panel colour on this element, which cut a hard horizontal edge across the
   page. The row's own hairlines go transparent while it's floating for the same reason: they
   read as two hard lines ruled across a band whose whole point is that it has no edges. They
   come back when it's home, where they belong as part of the page's structure. (Transparent
   rather than removed, so the border box — and therefore the layout — doesn't change.) */
.filters.stuck {
  border-top-color: transparent;
  border-bottom-color: transparent;
}

/* One line, however many tags there are — scrolls horizontally rather than wrapping, so the
   sticky bar stays a fixed height regardless of tag count. The active tag (js/lofi.js) is
   pulled to the front so choosing one never scrolls it out of sight. */
.filters .tag-row {
  display: flex;
  flex-wrap: nowrap;
  justify-content: center;
  overflow-x: auto;
  gap: 2px;
  padding-bottom: 2px;
  -webkit-overflow-scrolling: touch;
}

.filters .filters-meta {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 16px;
}
.filters .result-count {
  /* Was --fg-dim, the same muted grey as a label — legible enough for "sort"/"tag" captions
     next to their own control, but this is the one piece of information here with no control
     attached to lean on, and it read as too faint to actually use. */
  color: var(--fg);
  font-weight: bold;
  font-family: var(--mono);
}
.filters .filters-controls {
  display: flex;
  align-items: center;
  gap: 10px;
  /* One height for every control on this row — the sort button, the size slider and the reset
     button are all boxes of exactly this size, so they line up as one row of squared-off
     controls instead of three differently-sized native widgets. */
  --control-h: 28px;
}
.filters label { color: var(--fg-dim); }
/* Square and blocky, matching the reset button below rather than each browser's native control
   chrome. */
/* Solid, not transparent: this one sits over whatever the ambient wash or the grid happens to
   put behind the filter row, and a bare glyph on a see-through button was hard to read against
   a busy photo. Full --fg text on a solid --bg panel, unlike the reset button below, which is
   wordy enough to stay legible as an outline. */
.filters .sort-btn {
  background: var(--bg);
  border: 2px solid var(--rule);
  border-radius: 0;
  box-sizing: border-box;
  height: var(--control-h);
  min-width: var(--control-h);
  padding: 0 6px;
  font: inherit;
  font-size: 14px;
  font-weight: bold;
  line-height: 1;
  color: var(--fg);
  cursor: pointer;
}
.filters .sort-btn:hover {
  color: var(--bg);
  background: var(--link);
  border-color: var(--link);
}
.filters .reset-btn {
  background: none;
  border: 2px solid var(--rule);
  border-radius: 0;
  box-sizing: border-box;
  height: var(--control-h);
  padding: 0 10px;
  font: inherit;
  font-weight: bold;
  line-height: 1;
  color: var(--link);
  cursor: pointer;
}
.filters .reset-btn:hover:not(:disabled) {
  color: var(--bg);
  background: var(--link);
  border-color: var(--link);
}
/* Always on screen (so applying a filter doesn't shuffle the row around), but visibly inert
   until there's something to undo: dimmed, not-allowed cursor, no hover response. Dimmed via
   opacity on the whole control rather than a grey colour, so it reads the same way against the
   light theme, the dark theme and the photo-tinted themes in between. */
.filters .reset-btn:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}

/* The size slider, rebuilt as a box the same height as the buttons either side of it — the
   native control is a rounded pill with a round thumb on every platform, which was the one
   piece of chrome on the page still carrying the browser's own look rather than this site's.
   Track and thumb both have to be styled per-engine (-webkit-* for Chrome/Safari, -moz-* for
   Firefox); there is no shared selector for them. */
.filters input[type="range"] {
  -webkit-appearance: none;
  appearance: none;
  box-sizing: border-box;
  width: 104px;
  height: var(--control-h);
  margin: 0;
  padding: 0 6px;
  background: var(--bg);
  border: 2px solid var(--rule);
  border-radius: 0;
  cursor: pointer;
}
.filters input[type="range"]::-webkit-slider-runnable-track {
  height: 2px;
  background: var(--rule);
  border: none;
  border-radius: 0;
}
.filters input[type="range"]::-moz-range-track {
  height: 2px;
  background: var(--rule);
  border: none;
  border-radius: 0;
}
.filters input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 8px;
  height: 16px;
  margin-top: -7px; /* centres a 16px thumb on a 2px track */
  background: var(--link);
  border: none;
  border-radius: 0;
}
.filters input[type="range"]::-moz-range-thumb {
  width: 8px;
  height: 16px;
  background: var(--link);
  border: none;
  border-radius: 0;
}
.filters input[type="range"]:hover::-webkit-slider-thumb { background: var(--fg); }
.filters input[type="range"]:hover::-moz-range-thumb { background: var(--fg); }
.filters input[type="range"]:focus-visible {
  outline: 2px solid var(--link);
  outline-offset: 2px;
}

/* ---------- grid ---------- */

.grid {
  --tile-h: 140px; /* overridden inline by the size slider (js/lofi.js); this is just the default */
  max-width: 1400px;
  margin: 12px auto 24px;
  padding: 0 12px;
  display: flex;
  flex-wrap: wrap;
  /* Only affects the trailing row. Every other row is justified to exactly the content width by
     js/lofi.js's layoutGrid(), so centring is a no-op on them; the leftover tiles at the end
     (which deliberately aren't stretched — that would mean either giant photos or cropped ones)
     end up centred, which reads as intentional rather than as a row that failed to fill. */
  justify-content: center;
  gap: 3px;
}
.grid a.tile {
  display: block;
  /* Sized explicitly in pixels by js/lofi.js's justified-row layout (each row rescaled as a
     whole to fit the container width exactly, every tile in it keeping its own faithful aspect
     ratio — see layoutGrid()) — flex-grow/shrink both off so nothing tries to re-stretch or
     squash what JS already computed precisely. --tile-h is only the pre-JS/no-JS fallback
     height; a no-JS visitor gets a plain wrapping grid with a ragged last row instead, which is
     a perfectly fine degraded experience. */
  flex: 0 0 auto;
  height: var(--tile-h);
  position: relative;
}
/* Filters set the `hidden` attribute on non-matching tiles (js/lofi.js) — the global
   [hidden]{display:none!important} rule near the top of this file (see its comment) is what
   actually makes that work, overriding ".grid a.tile{display:block}" above. */
.grid a.tile img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
@media (min-width: 700px) {
  .grid { --tile-h: 180px; }
}

/* The grid tile matching whatever photo is currently loaded in the hero — orients the visitor
   within the grid now that clicking a photo navigates in place rather than popping over it.
   The outline is the whole cue: there used to be a corner dot here as well (and a second,
   dimmer one marking photos viewed earlier in the session), but a small mark floating over the
   corner of a photograph read as a blemish on the picture rather than as interface. */
.grid a.tile.current {
  outline: 2px solid var(--link);
  outline-offset: -2px;
}

/* Grid tiles hidden by a filter animate out of flex flow instantly (no [hidden] transition
   support), but resizing the tile height via the slider should feel smooth. */
.grid a.tile img { transition: height 0.15s ease; }

/* ---------- projects ---------- */

/* No on-page <h1> (see projects.html.twig) — the nav's own "projects" link already says where
   you are, so both need their own top margin to clear the floating nav instead of inheriting it
   from a heading that no longer exists. */
.empty-state {
  max-width: 1400px;
  margin: 64px auto 40px;
  padding: 0 12px;
  color: var(--fg-dim);
  text-align: center;
}
/* Tag chips narrowing the list of projects, styled by the shared .tag-chip rules above. */
.project-filter {
  --filters-top: 8px;
  max-width: 1400px;
  margin: 64px auto 0;
  padding: 6px 12px;
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 2px;
}
/* Rides along under the nav exactly as the photo grid's filter bar does, with the same blurred
   band appearing behind both once it pins (js/lofi.js toggles .filters-stuck on <html> for
   whichever of the two bars this page has). */
.js .project-filter {
  position: sticky;
  top: var(--filters-top);
  z-index: 60;
}
@media (min-width: 700px) {
  .project-filter { --filters-top: 56px; }
}
.project-empty-filter {
  max-width: 1400px;
  margin: 40px auto 64px;
  padding: 0 12px;
  text-align: center;
  color: var(--fg-dim);
}

.project-list {
  max-width: 1500px;
  margin: 32px auto 40px;
  padding: 0 12px;
  display: flex;
  flex-direction: column;
  gap: 48px;
}
/* One project per full-width row: the cover on one side, the words on the other, alternating
   down the page. A project is represented by a single photograph, so that photograph gets the
   room — the previous card grid held every cover in a fixed 4:3 box at a quarter of the page
   width, which cropped most of them and shrank all of them. */
.project-row {
  position: relative;
  display: flex;
  align-items: center;
  gap: 28px;
  padding: 24px 0;
  color: inherit;
  text-decoration: none;
}
/* Blurred copy of the cover, spreading out of the photograph and under the words beside it, so
   the row is lit by its own picture instead of sitting on flat page background. Same idea as
   the hero's wash (.hero-ambient) and the same reason for the generous bleed: blur() fades
   toward the element's own edge, so any edge that lands inside the row would paint a visible
   band. Revealed by js/lofi.js once the image has actually loaded — assigning a background
   image that hasn't arrived yet paints nothing, which is the flicker already fixed on the
   hero. */
.project-ambient {
  position: absolute;
  /* Bleeds a long way past the row on every side. Two reasons, both learned the hard way: a
     blur() fades toward its own element boundary (it has only transparency to sample beyond
     it), and any boundary landing where the mask is still opaque draws a hard rectangular edge
     — which is what showed up as the colour cutting off at the sides of a wide window. With
     the edges this far out, and the gradients below reaching zero well inside them, there is no
     edge anywhere near the visible area. */
  inset: -120px -25vw;
  z-index: 0;
  background-position: center;
  background-size: cover;
  background-repeat: no-repeat;
  filter: blur(54px) saturate(1.8);
  opacity: 0;
  transition: opacity 0.6s ease;
  pointer-events: none;
  /* Two gradients intersected: one carrying the colour out of the photograph and across the
     words (its direction flips with the zig-zag), one fading it out above and below the row so
     one project's wash doesn't run into the next. `intersect` keeps only what both allow; the
     -webkit- pair is the same thing under Safari's older syntax. */
  --ambient-dir: to right;
  -webkit-mask-image:
    linear-gradient(var(--ambient-dir), transparent 20%, #000 36%, #000 54%, rgba(0,0,0,0.4) 72%, transparent 86%),
    linear-gradient(to bottom, transparent 4%, #000 26%, #000 74%, transparent 96%);
  mask-image:
    linear-gradient(var(--ambient-dir), transparent 20%, #000 36%, #000 54%, rgba(0,0,0,0.4) 72%, transparent 86%),
    linear-gradient(to bottom, transparent 4%, #000 26%, #000 74%, transparent 96%);
  -webkit-mask-composite: source-in;
  mask-composite: intersect;
}
.project-row.flipped .project-ambient { --ambient-dir: to left; }
.project-ambient.ready { opacity: 0.72; }
:root[data-theme="dark"] .project-ambient.ready { opacity: 0.56; }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .project-ambient.ready { opacity: 0.56; }
}
/* Above the wash. */
.project-cover, .project-info { position: relative; z-index: 1; }
/* The zig-zag, owned by js/lofi.js (.flipped on every second VISIBLE row) rather than by
   :nth-child — which counts rows the tag filter has hidden, so after filtering it would leave
   the visible ones stacked on the same side. :nth-child is kept as the no-JS fallback, where
   nothing is ever hidden and the two agree anyway. */
.project-row.flipped { flex-direction: row-reverse; }
html:not(.js) .project-row:nth-child(even) { flex-direction: row-reverse; }

.project-cover {
  flex: 0 1 58%;
  min-width: 0;
  display: flex;
  /* Pushes the photograph to the outer edge of its half on both sides of the zig-zag, so a
     portrait cover (narrower than the column it sits in) still lines up with the page's margin
     rather than floating in the middle of a gap. */
  justify-content: flex-start;
}
.project-row.flipped .project-cover { justify-content: flex-end; }
/* No aspect-ratio box and no object-fit: the cover keeps the photograph's own proportions,
   portrait or landscape, and is never cropped to fit the layout. The viewport-height cap is
   what keeps a portrait cover from turning one project into a full screen of scrolling — it
   shrinks the photo rather than cutting into it. */
.project-cover img {
  display: block;
  width: auto;
  height: auto;
  max-width: 100%;
  max-height: 68vh;
  border: 2px solid transparent;
  transition: border-color 0.2s ease;
}
.project-row:hover .project-cover img { border-color: var(--link); }

.project-info {
  flex: 1 1 0;
  min-width: 0;
}
/* The words sit against the photograph rather than against the far page edge, so each row
   reads as one object and the alternation mirrors properly instead of leaving a void down the
   middle of every second row. */
.project-row.flipped .project-info { text-align: right; }
.project-name {
  font-family: var(--display);
  font-size: clamp(28px, 4.5vw, 52px);
  line-height: 1.05;
  margin: 0 0 10px;
}
.project-meta {
  margin: 0;
  font-size: 13px;
  color: var(--fg-dim);
  font-family: var(--mono);
}
.project-tags {
  margin: 8px 0 0;
  font-size: 13px;
  color: var(--fg-dim);
}
/* The same pill as the nav actions and the "all photos" links, so "view project" reads as the
   site's one button, not a third kind of link. A <span>, since the row itself is the anchor. */
.project-cta {
  display: inline-block;
  margin: 20px 0 0;
  font-family: var(--display);
  font-size: 18px;
  color: var(--nav-pill-fg);
  background: var(--nav-pill-3);
  border: 2px solid var(--nav-pill-border);
  border-radius: 0;
  padding: 5px 18px;
  transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}
.project-row:hover .project-cta { background: var(--nav-pill-1); }

/* Each row takes its colour from its own cover, using exactly the variables the whole-page
   photo theme uses (:root.photo-themed above) — js/lofi.js sets --photo-hue/--photo-sat on the
   row, and everything inside it inherits these instead of the page's. Only the text and button
   tones are redefined here, not --bg: the row's colour comes from its wash, and repainting the
   page background per row would fight the photo-themed page it sits on. */
.project-row.photo-themed {
  --rule: hsl(var(--photo-hue) var(--photo-sat) 78%);
  --link: hsl(var(--photo-hue) 78% 34%);
  --fg: hsl(var(--photo-hue) 22% 10%);
  --fg-dim: hsl(var(--photo-hue) 18% 24%); /* darker than the page's dim: this text sits on the wash */
  --nav-pill-1: hsl(var(--photo-hue) calc(var(--photo-sat) * 0.85) 58%);
  --nav-pill-3: hsl(var(--photo-hue) calc(var(--photo-sat) * 0.35) 88%);
  --nav-pill-fg: hsl(var(--photo-hue) 25% 12%);
  --nav-pill-border: hsl(var(--photo-hue) var(--photo-sat) 40%);
  color: var(--fg);
}
:root[data-theme="dark"] .project-row.photo-themed {
  --rule: hsl(var(--photo-hue) calc(var(--photo-sat) * 0.6) 26%);
  --link: hsl(var(--photo-hue) 85% 76%);
  --fg: hsl(var(--photo-hue) 16% 93%);
  --fg-dim: hsl(var(--photo-hue) 12% 68%);
  --nav-pill-1: hsl(var(--photo-hue) calc(var(--photo-sat) * 0.8) 32%);
  --nav-pill-3: hsl(var(--photo-hue) calc(var(--photo-sat) * 0.35) 15%);
  --nav-pill-fg: hsl(var(--photo-hue) 20% 92%);
  --nav-pill-border: hsl(var(--photo-hue) calc(var(--photo-sat) * 0.6) 45%);
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .project-row.photo-themed {
    --rule: hsl(var(--photo-hue) calc(var(--photo-sat) * 0.6) 26%);
    --link: hsl(var(--photo-hue) 85% 76%);
    --fg: hsl(var(--photo-hue) 16% 93%);
    --fg-dim: hsl(var(--photo-hue) 12% 68%);
    --nav-pill-1: hsl(var(--photo-hue) calc(var(--photo-sat) * 0.8) 32%);
    --nav-pill-3: hsl(var(--photo-hue) calc(var(--photo-sat) * 0.35) 15%);
    --nav-pill-fg: hsl(var(--photo-hue) 20% 92%);
    --nav-pill-border: hsl(var(--photo-hue) calc(var(--photo-sat) * 0.6) 45%);
  }
}

/* Phones get the plain stack — at this width there are no two sides to alternate between, and
   a half-width cover would be smaller than the grid thumbnails it's meant to stand out from. */
@media (max-width: 799px) {
  .project-list { gap: 40px; }
  .project-row,
  .project-row.flipped,
  html:not(.js) .project-row:nth-child(even) {
    flex-direction: column;
    align-items: stretch;
    gap: 12px;
  }
  .project-cover { flex: 0 0 auto; justify-content: flex-start; }
  .project-row.flipped .project-info { text-align: left; }
  .project-cover img { max-height: none; }
  .project-name { font-size: 32px; }
  /* Stacked, there are no two sides for the colour to travel between — it comes down out of
     the photograph into the words underneath instead. One gradient, so nothing to composite. */
  .project-ambient,
  .project-row.flipped .project-ambient {
    inset: -90px -25vw;
    -webkit-mask-image: linear-gradient(to bottom, transparent 6%, #000 34%, #000 66%, transparent 94%);
    mask-image: linear-gradient(to bottom, transparent 6%, #000 34%, #000 66%, transparent 94%);
    -webkit-mask-composite: source-over;
    mask-composite: add;
  }
}

/* One more project offered under a project's photo grid — the same rows as /projects, so a
   project ends with somewhere to go instead of with the footer. */
.project-suggest { margin-top: 48px; }
/* The rule itself is on an inner element at the footer's own width (1400px + the same 12px
   gutter), so the two dividers on the page line up rather than one running edge to edge and the
   other stopping at the content. */
.project-suggest-title {
  max-width: 1400px;
  margin: 0 auto;
  padding: 24px 12px 0;
  border-top: 2px solid var(--rule);
  font-family: var(--display);
  font-size: 28px;
  color: var(--fg-dim);
  text-align: center;
}

/* "going up?" — the way back to the top of a long page. Hidden by js/lofi.js until there is
   actually something above you, so it never sits there on a page that doesn't scroll. */
.going-up {
  max-width: 1400px;
  margin: 8px auto 32px;
  padding: 0 12px;
  text-align: center;
}
.going-up a {
  display: inline-block;
  font-family: var(--display);
  font-size: 18px;
  text-decoration: none;
  color: var(--nav-pill-fg);
  background: var(--nav-pill-3);
  border: 2px solid var(--nav-pill-border);
  border-radius: 0;
  padding: 5px 18px;
  transition: background-color 0.6s ease, color 0.6s ease, border-color 0.6s ease;
}
.going-up a:hover { background: var(--nav-pill-1); }

/* ---------- project view of the photo grid (?project=<slug>) ---------- */

/* The project's name, sitting in the filter bar where the tag row normally is (js/lofi.js
   swaps one for the other). Inside a project the tags would cut a set that has already been
   deliberately chosen, and putting the name here means it travels with the sticky bar instead
   of scrolling away above the grid. Sized to the row it replaces so the bar's height — and so
   the grid's position — doesn't change between views. */
.project-heading {
  margin: 0;
  padding: 3px 0 2px;
  font-family: var(--display);
  font-size: 22px;
  line-height: 1.2;
  text-align: center;
}

/* Way back out of a project: above the filter bar and again below the grid, each offering both
   destinations — the whole library, or the projects list you probably came from. Same pill as
   the solo photo page's "all photos" link, since it does the same job. */
.project-return {
  max-width: 1400px;
  margin: 12px auto 0;
  padding: 0 12px;
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 10px;
}
/* The bar underneath is sticky and pins itself right below the nav; without real space here the
   two read as one stack of controls rather than as a way out and then the filters. */
.project-return { margin-bottom: 28px; }
.project-return.bottom { margin: 8px auto 40px; }
.project-return a {
  display: inline-block;
  font-family: var(--display);
  font-size: 20px;
  text-decoration: none;
  color: var(--nav-pill-fg);
  background: var(--nav-pill-3);
  border: 2px solid var(--nav-pill-border);
  border-radius: 0;
  padding: 5px 18px;
  transition: background-color 0.6s ease, color 0.6s ease, border-color 0.6s ease;
}
.project-return a:hover { background: var(--nav-pill-1); }

/* ---------- footer ---------- */

footer {
  max-width: 1400px;
  margin: 0 auto;
  padding: 24px 12px 48px;
  font-size: 16px;
  color: var(--fg-dim);
  border-top: 2px solid var(--rule);
  text-align: center;
}
footer a { margin: 0 10px; font-weight: bold; }
footer .copyright { margin-top: 10px; font-size: 13px; }

/* ---------- misc ---------- */

.visually-hidden {
  position: absolute;
  width: 1px; height: 1px;
  overflow: hidden;
  clip: rect(0,0,0,0);
}


/* ---------- accessibility / progressive enhancement ---------- */

.skip-link {
  position: absolute;
  left: -999px;
  top: 0;
  background: var(--bg);
  color: var(--fg);
  padding: 8px 12px;
  z-index: 200;
  border: 1px solid var(--rule);
}
.skip-link:focus {
  left: 8px;
  top: 8px;
}

/* Without JS: no hero/grid pick, no filters, no nav fade — just the newest photo (rendered
   server-side into .hero) and a plain list. The <noscript> block below the filter row explains
   the reduced experience rather than showing a confusing empty filter row. */
.js-only { display: none; }
.js .js-only { display: revert; }
/* `revert` resolves to each element's user-agent default (inline, inline-block, ...), which
   silently wins over an author rule like ".filters { display: flex }" — both are two-class
   selectors, same specificity, and `.js .js-only` happens to come later in the cascade. Caught
   by inspecting computed style in a real browser: .filters was flexing in the mockup but
   stacking one-per-line here. Restore the two elements that need something other than the UA
   default, with enough specificity to always win regardless of source order. */
.js .filters.js-only { display: flex; }
.js .panel button.js-only { display: block; }
.js .project-filter.js-only { display: flex; }
.js .project-return.js-only { display: flex; }
.js .no-js-only { display: none; }
/* Same cascade trap in the other direction: `[hidden]` is only a UA rule (0,1,0 at best), so
   ".js .js-only" above out-specifies it and an element carrying BOTH js-only and hidden — the
   project return links, which JS shows and hides — would stay visible. Restored here with
   enough specificity to win, for any such element present or future. */
.js .js-only[hidden] { display: none; }

/* ---------- HDR ---------- */

/* Thumbnails and, until the visitor opts in, the hero/photo image are held to standard range —
   set globally via a custom property so the HDR toggle (js/lofi.js) only has to flip one value.
   Meaningful only in browsers that implement dynamic-range-limit (Chrome/Edge 136+, Safari 26+);
   harmless no-op elsewhere. */
:root { --dynamic-range-limit: standard; }
img { dynamic-range-limit: var(--dynamic-range-limit); }
