/* ==========================================================================
   Lincoln 4 Life -- modern redesign
   A friendly, social-feed styling layer over 20 years of archived content.
   ========================================================================== */

/* ------------------------------------------------------------- tokens ---- */
:root {
  color-scheme: light;

  --bg:        #f4f1ee;
  --bg-tint:   #ece7e1;
  --surface:   #ffffff;
  --surface-2: #faf8f6;
  --border:    #e6e0d9;
  --border-2:  #d8d0c6;
  --text:      #191822;
  --dim:       #4f4c5a;
  --faint:     #6d6979;

  --c1: #ff7a4d;   /* coral   */
  --c2: #f5427c;   /* magenta */
  --c3: #7b5cff;   /* violet  */
  --accent:      #d6215c;
  --accent-ink:  #ffffff;
  --accent-soft: #fdeaf0;
  --teal:        #0b8a82;

  --grad:      linear-gradient(120deg, var(--c1), var(--c2) 52%, var(--c3));
  --grad-soft: linear-gradient(120deg, #fff1ea, #ffe9f1 52%, #efeaff);

  --r-sm: 10px;
  --r:    16px;
  --r-lg: 22px;
  --r-xl: 28px;

  --sh-1: 0 1px 2px rgba(25, 24, 34, .05), 0 1px 3px rgba(25, 24, 34, .04);
  --sh-2: 0 2px 6px rgba(25, 24, 34, .06), 0 10px 24px -12px rgba(25, 24, 34, .16);
  --sh-3: 0 8px 20px rgba(25, 24, 34, .10), 0 28px 60px -20px rgba(25, 24, 34, .30);

  --sans: "Plus Jakarta Sans", ui-sans-serif, system-ui, -apple-system,
          "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;

  --rail: 264px;
  --pad: clamp(16px, 4vw, 40px);
  --ease: cubic-bezier(.22, .61, .36, 1);

  /* home hero photo wall (.hero-wall .row div) -- filter isn't a colour, so
     it can't ride var(--bg) through color-mix() the way the scrim does;
     it needs its own token, re-pointed alongside everything else below. */
  --hero-wall-filter: grayscale(.12) brightness(.94);
  /* .hero-profile's glass rim (site/assets/styles.css "the profile card").
     A fixed near-white regardless of theme -- it stands for light catching
     the edge of the material, not the content behind it -- but the dark
     wall behind it in dark mode reads any given alpha as more visible than
     the same alpha does over a light one, so the two need different amounts
     to look equally subtle. */
  --glass-rim: rgba(255, 255, 255, .28);
  /* .video .play-disc's glass tint (styles.css "video card"). Light mode's
     disc read as too solid at the same 55% every other theme was fine with,
     so this is its own token rather than a shared one -- dark mode is
     unchanged, only light mode goes more transparent. */
  --play-disc-bg: color-mix(in srgb, var(--surface) 38%, transparent);
  /* #search .modal-card's glass rim (styles.css "search"). Same idea as
     --glass-rim, its own token because dark mode wants less of it here
     specifically -- light mode matches --glass-rim exactly. */
  --search-glass-rim: rgba(255, 255, 255, .28);
  /* #search .modal-card's glass tint. .modal's own backdrop (styles.css
     "modal") is a fixed near-black scrim behind every modal regardless of
     theme, by design -- it's what makes a dialog read as focused. That's
     fine under a dark card, which blends into it, but a light, mostly-
     transparent card over an always-dark backdrop doesn't read as "light
     glass" -- it reads as grey, because a third to two-thirds of what's
     showing through it is that near-black scrim. Light mode needs to stay
     far more opaque to actually look light; dark mode has no such problem
     and keeps the same 39% every other glass surface uses. */
  --search-glass-bg: color-mix(in srgb, var(--surface) 85%, transparent);
  /* #search .modal-card's top-edge highlight (the inset in its box-shadow).
     Every other glass surface hardcodes this at .5 and it's fine there, but
     against how dark the search card's own dark-mode tint already is, that
     reads as a bright line rather than a subtle catch of light -- its own
     token so light mode (where .5 is fine) is untouched. */
  --search-glass-highlight: rgba(255, 255, 255, .5);
}

/* Dark palette. Three theme states: data-theme="light" pins light,
   "dark" pins dark, and "auto" (the default) follows the OS. Every token is
   defined on bare :root above, then only re-pointed here. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    color-scheme: dark;
    --bg:        #0e0d13;
    --bg-tint:   #16151e;
    --surface:   #191823;
    --surface-2: #21202d;
    --border:    #2a2937;
    --border-2:  #383646;
    --text:      #f3f1f7;
    --dim:       #a9a6b8;
    --faint:     #8b889b;
    --accent:      #ff86ab;
    --accent-ink:  #24040f;
    --accent-soft: #2b1622;
    --teal:        #37d6cb;
    --grad-soft: linear-gradient(120deg, #2a1a18, #2a1622 52%, #1d1a35);
    --sh-1: 0 1px 2px rgba(0, 0, 0, .5);
    --sh-2: 0 2px 8px rgba(0, 0, 0, .45), 0 14px 30px -14px rgba(0, 0, 0, .7);
    --sh-3: 0 10px 26px rgba(0, 0, 0, .5), 0 34px 70px -22px rgba(0, 0, 0, .85);
    --hero-wall-filter: grayscale(.25) brightness(.6);
    --glass-rim: rgba(255, 255, 255, .14);
    --play-disc-bg: color-mix(in srgb, var(--surface) 55%, transparent);
    --search-glass-rim: rgba(255, 255, 255, .09);
    --search-glass-bg: color-mix(in srgb, var(--surface) 39%, transparent);
    --search-glass-highlight: rgba(255, 255, 255, .16);
  }
}

:root[data-theme="dark"] {
  color-scheme: dark;
  --bg:        #0e0d13;
  --bg-tint:   #16151e;
  --surface:   #191823;
  --surface-2: #21202d;
  --border:    #2a2937;
  --border-2:  #383646;
  --text:      #f3f1f7;
  --dim:       #a9a6b8;
  --faint:     #8b889b;
  --accent:      #ff86ab;
  --accent-ink:  #24040f;
  --accent-soft: #2b1622;
  --teal:        #37d6cb;
  --grad-soft: linear-gradient(120deg, #2a1a18, #2a1622 52%, #1d1a35);
  --sh-1: 0 1px 2px rgba(0, 0, 0, .5);
  --sh-2: 0 2px 8px rgba(0, 0, 0, .45), 0 14px 30px -14px rgba(0, 0, 0, .7);
  --sh-3: 0 10px 26px rgba(0, 0, 0, .5), 0 34px 70px -22px rgba(0, 0, 0, .85);
  --hero-wall-filter: grayscale(.25) brightness(.6);
  --glass-rim: rgba(255, 255, 255, .14);
  --play-disc-bg: color-mix(in srgb, var(--surface) 55%, transparent);
  --search-glass-rim: rgba(255, 255, 255, .09);
  --search-glass-bg: color-mix(in srgb, var(--surface) 39%, transparent);
  --search-glass-highlight: rgba(255, 255, 255, .16);
}

/* --------------------------------------------------------------- reset --- */
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }

/* the overlays below set display:grid, which would otherwise beat the user
   agent's [hidden] rule and leave them covering the page */
[hidden] { display: none !important; }

body {
  margin: 0;
  min-height: 100vh;
  background: var(--bg);
  color: var(--text);
  font: 400 15.5px/1.6 var(--sans);
  letter-spacing: -.011em;
  -webkit-font-smoothing: antialiased;
}

h1, h2, h3, h4 { margin: 0; font-weight: 700; letter-spacing: -.028em; line-height: 1.2; }
h1 { font-size: clamp(1.7rem, 4.4vw, 2.5rem); font-weight: 800; }
h2 { font-size: clamp(1.12rem, 2.4vw, 1.35rem); }
h3 { font-size: 1.02rem; }
p  { margin: 0; }
img { max-width: 100%; display: block; }
a  { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
button { font: inherit; color: inherit; }
:focus-visible { outline: 2.5px solid var(--c3); outline-offset: 2px; border-radius: 6px; }

.skip {
  position: fixed; top: -60px; left: 12px; z-index: 999;
  background: var(--surface); padding: 10px 16px; border-radius: var(--r-sm);
  box-shadow: var(--sh-2);
}
.skip:focus { top: 12px; }

.i { width: 20px; height: 20px; flex: none; fill: none; stroke: currentColor;
     stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }

/* ---------------------------------------------------------------- shell -- */
.app { display: grid; grid-template-columns: var(--rail) minmax(0, 1fr); }

.sidebar {
  position: sticky; top: 0; align-self: start;
  height: 100vh; padding: 16px 18px 20px;
  display: flex; flex-direction: column; gap: 22px;
  border-right: 1px solid var(--border);
  background: var(--surface);
}

.brand { display: flex; align-items: center; gap: 12px; color: inherit; }
.brand:hover { text-decoration: none; }
/* The wordmark's own intrinsic size is declared in its <img> tag so neither
   the rail nor the top bar shifts when it decodes -- and so height:auto
   derives the right ratio, which is also what keeps the image from
   rendering squashed. Raw, no filter -- rendered exactly as the SVG
   draws it. */
.brand-logo {
  display: block; width: 168px; height: auto;
  transition: transform .2s var(--ease);
}
/* Centred in the rail: the side padding is symmetric, so centring the flex
   items lands the logo on the true centre.

   --nudge is optical, not geometric. The artwork fills its canvas edge to
   edge, but the burst and the 4 pile the weight to the right -- the
   alpha-weighted centre of mass sits 8.6px right of the middle at this size,
   which reads as off-centre even when the box is dead centre. A third of that
   correction settles it; the full amount over-swings, because the eye reads
   the bounding box too. It lives in a property because the hover below has to
   restate it. */
.sidebar .brand { flex-direction: column; align-items: center; --nudge: -3px; }
.sidebar .brand-logo { transform: translateX(var(--nudge)); }

/* A sheen sweeping across a mark's own silhouette -- shared by whatever traced
   artwork on the page wants "light moving across metal" as its motion. Not
   brand-specific: the About page's mark uses this same rule. Each instance
   clips its own rect to its own silhouette id via inline clip-path, so one
   animation definition serves every mark on the page independently. */
.sheen-bar { animation: sheen-sweep 9s ease-in-out infinite; }
@keyframes sheen-sweep {
  0%, 10%  { transform: skewX(-12deg) translateX(0); }
  62%, 100% { transform: skewX(-12deg) translateX(1500px); }
}
@media (prefers-reduced-motion: reduce) { .sheen-bar { display: none; } }

/* Reacts like everything else here: scale only. Nothing shifts position on
   hover anywhere on this site, because an element that moves under the cursor
   can slide its own hit box away and chatter between states.

   Two things to keep in mind. transform replaces rather than accumulates, so
   the sidebar rule repeats the nudge -- drop it and the logo snaps back to
   centre on the way in. And it needs the extra .sidebar to outrank the
   generic hover: .brand:hover .brand-logo is already three classes, one more
   than .sidebar .brand-logo, so without it the nudge loses on specificity. */
.brand:hover .brand-logo,
.brand:focus-visible .brand-logo { transform: scale(1.03); }
.sidebar .brand:hover .brand-logo,
.sidebar .brand:focus-visible .brand-logo {
  transform: translateX(var(--nudge)) scale(1.03);
}
.nav { display: flex; flex-direction: column; gap: 3px; }
.nav a {
  display: flex; align-items: center; gap: 12px;
  padding: 10px 12px; border-radius: 12px;
  color: var(--dim); font-weight: 600; font-size: .935rem;
  transition: background .16s var(--ease), color .16s var(--ease);
}
.nav a:hover { background: var(--bg-tint); color: var(--text); text-decoration: none; }
.nav a .i { stroke-width: 1.8; opacity: .85; }
.nav a.on { background: var(--accent-soft); color: var(--accent); }
.nav a.on .i { opacity: 1; }
.nav a .tag {
  margin-left: auto; font-size: .7rem; font-weight: 700;
  color: var(--faint); font-variant-numeric: tabular-nums;
}

.sidebar-foot { margin-top: auto; display: flex; flex-direction: column; gap: 8px; }
.chip {
  display: flex; align-items: center; gap: 10px; width: 100%;
  padding: 9px 12px; border-radius: 11px; cursor: pointer;
  background: var(--surface-2); border: 1px solid var(--border);
  color: var(--dim); font-weight: 600; font-size: .875rem; text-align: left;
  transition: border-color .16s, color .16s;
}
.chip:hover { border-color: var(--border-2); color: var(--text); }
.chip kbd {
  margin-left: auto; font: 600 .72rem var(--sans);
  background: var(--bg-tint); border: 1px solid var(--border);
  border-radius: 5px; padding: 1px 6px; color: var(--faint);
}
.fineprint { color: var(--faint); font-size: .74rem; line-height: 1.5; padding: 4px 2px; }

/* ---------------------------------------------------- TEMPORARY colour lab
   Exploration tool, not a real feature -- see the HTML comment above the
   button in index.html. Everything it touches is a custom property already;
   it works by overwriting a handful of them on :root and removing the
   overrides again on reset, so no component here needed to change.

   position:fixed and body-level in the markup, not position:absolute inside
   the sidebar -- the sidebar is position:sticky, which always opens its own
   stacking context regardless of z-index (unlike position:relative, which
   only does that if you give it one). A panel positioned relative to
   something inside the sidebar would have its z-index compared against
   other content in the sidebar's sticky context, never against the page's
   own content next to it -- exactly the "covered by other things" bug this
   replaced. Fixed and top-level, like the lightbox and the two modals below
   it, sidesteps the question entirely: nothing on the page outranks it.
   colorlabInit() sets its left/top in JS on open, since a fixed element has
   no natural position to inherit from being next to the button in the DOM. */
.colorlab-panel {
  position: fixed; z-index: 120;
  width: 220px; background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--r-lg); box-shadow: var(--sh-3); padding: 14px;
}
.cl-swatches { display: flex; flex-wrap: wrap; gap: 9px; margin-bottom: 12px; }
.cl-swatch {
  width: 28px; height: 28px; border-radius: 50%; padding: 0; cursor: pointer;
  border: 2px solid var(--border); background: var(--sw);
  transition: transform .16s var(--ease), border-color .16s;
}
.cl-swatch:hover { transform: scale(1.1); }
.cl-swatch.on { border-color: var(--text); }
.cl-custom {
  display: flex; align-items: center; gap: 9px; margin-bottom: 12px;
  color: var(--dim); font-weight: 600; font-size: .82rem; cursor: pointer;
}
.cl-custom input[type="color"] {
  width: 28px; height: 28px; border-radius: 50%; border: 2px solid var(--border);
  padding: 0; cursor: pointer; background: none;
}
.cl-custom input[type="color"]::-webkit-color-swatch-wrapper { padding: 0; border-radius: 50%; overflow: hidden; }
.cl-custom input[type="color"]::-webkit-color-swatch { border: none; }
.cl-reset {
  display: block; width: 100%; padding: 8px 10px; border-radius: 9px;
  border: 1px solid var(--border); background: var(--surface-2);
  color: var(--dim); font-weight: 600; font-size: .82rem; cursor: pointer;
  transition: border-color .16s, color .16s;
}
.cl-reset:hover { border-color: var(--border-2); color: var(--text); }

/* the toggle shows the theme you are currently in */
.i-moon { display: none; }
:root[data-theme="dark"] .i-sun { display: none; }
:root[data-theme="dark"] .i-moon { display: block; }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .i-sun { display: none; }
  :root:not([data-theme="light"]) .i-moon { display: block; }
  :root[data-theme="light"] .i-sun { display: block; }
  :root[data-theme="light"] .i-moon { display: none; }
}

.topbar, .tabbar { display: none; }

/* News and About sit in the desktop sidebar, so their More tiles are only
   needed on the narrow layout where the tab bar has no room for them.
   CSS decides, not JS, so it survives a resize or an orientation change.
   The .tile pairing is needed on specificity: a bare .only-mobile ties with
   .tile's own display:flex and loses on source order. */
.only-mobile { display: none; }
.tile.only-mobile { display: none; }
/* Same idea, for the "back to More" crumb those two pages also only need on
   the narrow layout: on desktop they open straight from the sidebar, so a
   link back to a More page the visitor never passed through would point at
   the wrong place. Paired with .crumb for the same reason as .tile above --
   a bare .only-mobile ties with .crumb's own display:inline-flex on
   specificity and loses on source order. */
.crumb.only-mobile { display: none; }

#view { min-width: 0; padding: var(--pad) var(--pad) 96px; }
/* The one page-level transition on the whole site, deliberately: a route
   swap used to be an instant hard cut, which reads as fast but also as
   broken -- nothing acknowledges that anything happened. First cut of this
   was 110ms and 5px, and it turned out to sit right under the threshold
   where an eye actually registers "something moved" rather than "the page
   just changed" -- correct on a frame-timing chart, invisible in practice.
   230ms / 10px is the version that reads as motion without reading as a
   wait. Content is already live and clickable the instant it appears --
   opacity/transform don't block hit-testing -- so nothing about this can
   get in the way, only decorate what already happened. Kept to #view alone;
   every hover/focus micro-interaction elsewhere already has its own short
   transition and does not need this. */
.view-in { animation: view-in .23s var(--ease); }
@keyframes view-in {
  from { opacity: 0; transform: translateY(10px); }
}
@media (prefers-reduced-motion: reduce) { .view-in { animation: none; } }
.wrap { max-width: 1120px; margin: 0 auto; }

/* -------------------------------------------------------------- generic -- */
.icon-btn {
  display: grid; place-items: center; width: 38px; height: 38px;
  border-radius: 11px; cursor: pointer;
  background: var(--surface-2); border: 1px solid var(--border); color: var(--dim);
  transition: color .16s, border-color .16s, background .16s;
}
.icon-btn:hover { color: var(--text); border-color: var(--border-2); }
.icon-btn.ghost { background: transparent; border-color: transparent; }
.icon-btn.ghost:hover { background: rgba(255, 255, 255, .12); color: #fff; }

.btn {
  display: inline-flex; align-items: center; gap: 8px;
  padding: 11px 18px; border-radius: 999px; cursor: pointer;
  font-weight: 700; font-size: .92rem; border: 1px solid transparent;
  transition: transform .16s var(--ease), box-shadow .16s, border-color .16s;
}
.btn:hover { text-decoration: none; transform: scale(1.02); }
.btn-primary {
  background: var(--grad); color: #fff;
  box-shadow: 0 4px 16px -4px rgba(245, 66, 124, .55);
}
.btn-primary:hover { box-shadow: 0 8px 24px -6px rgba(245, 66, 124, .65); }
.btn-ghost { background: var(--surface); border-color: var(--border-2); color: var(--text); }
.btn-ghost:hover { border-color: var(--dim); }

.pill {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 4px 11px; border-radius: 999px;
  font-size: .74rem; font-weight: 700; letter-spacing: .01em;
  background: var(--bg-tint); color: var(--dim);
}
.pill.hot   { background: var(--accent-soft); color: var(--accent); }
.pill.teal  { background: color-mix(in srgb, var(--teal) 14%, transparent); color: var(--teal); }
.pill.solid { background: var(--grad); color: #fff; }
.pill .i { width: 13px; height: 13px; stroke-width: 2.4; }

.card {
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--r-lg); box-shadow: var(--sh-1);
}

.section { margin-top: 44px; }
.section-head {
  display: flex; align-items: baseline; gap: 12px;
  margin-bottom: 16px; flex-wrap: wrap;
}
/* The home page stacks four unrelated things -- albums, videos, people, news.
   A hairline above each and a tighter rhythm than the rest of the site keeps
   it reading as five distinct sections at a quick, feed-like pace rather than
   one long column. Scoped to .home because the same helper builds the About
   and download sections, where consecutive blocks belong together (a rule
   between them would imply a break that is not there) and the roomier
   default spacing is what those pages actually want. */
.home .section {
  margin-top: 30px; padding-top: 18px;
  border-top: 1px solid var(--border);
}
/* The hero already reads as its own distinct block (rounded corners, its
   own shadow) -- a hairline right underneath it as well just looked like a
   stray line between the two, not a separator doing useful work the way it
   does between every other pair of sections below. */
.home .section:first-of-type { border-top: none; }
.home .section-head h2 { font-size: clamp(1.08rem, 2.2vw, 1.28rem); }
.home .section-head p { display: none; }
.section-head p { color: var(--dim); font-size: .9rem; }
.section-head .more-link { margin-left: auto; font-weight: 700; font-size: .875rem; }

.eyebrow {
  font-size: .74rem; font-weight: 800; letter-spacing: .1em;
  text-transform: uppercase; color: var(--faint); margin-bottom: 8px;
}

.lede { color: var(--dim); font-size: 1.02rem; max-width: 62ch; }

/* thumbnail blur-up: tiny 80px image sits behind the real one */
.shot { position: relative; overflow: hidden; background: var(--bg-tint); }
/* The placeholder is an 80px thumbnail stretched over the full cell, so the
   browser's own upscaling already makes it soft -- no filter needed. A CSS
   blur() here would spawn one composited layer per thumbnail (200+ on the
   biggest album) and make the page shimmer on every repaint. */
.shot .ph {
  position: absolute; inset: 0; width: 100%; height: 100%;
  object-fit: cover; transform: scale(1.06);
}
.shot .real {
  position: relative; width: 100%; height: 100%; object-fit: cover;
  opacity: 0; transition: opacity .45s var(--ease);
}
.shot .real.in { opacity: 1; }

/* ----------------------------------------------------------------- hero -- */
/* Background is a slow-drifting wall of real photos pulled from the actual
   albums (mountHome(), site/assets/app.js), not a flat colour --
   .hero-wall-clip is the fixed window, .hero-wall the wider, animated grid
   of photos panning across it. The dark scrim over the wall (see
   .hero-wall-clip::after) stays fixed regardless of theme: the photos are
   random every load, so a reliable near-black gradient is what keeps the
   card above legible no matter what gets picked. The card itself is real
   glass, though -- it reads var(--surface)/var(--text)/var(--border) straight
   off the current theme, the same color-mix idiom .timeline and the mobile
   .topbar already use, so it comes out a light frosted panel in light mode
   and a dark one in dark mode instead of forcing dark unconditionally the
   way the old aurora hero did. */
.hero {
  position: relative; overflow: hidden; border-radius: var(--r-xl);
  box-shadow: var(--sh-2); min-height: 420px;
  display: flex; align-items: center; justify-content: center;
}
.hero-wall-clip { position: absolute; inset: 0; overflow: hidden; background: var(--bg-tint); }
.hero-wall {
  position: absolute; top: 0; right: auto; bottom: 0; left: 0; width: 140%;
  display: flex; flex-direction: column; gap: 2px;
  animation: hero-drift 50s linear infinite;
}
.hero-wall .row { display: flex; gap: 2px; flex: 1; }
/* Every other row is nudged back half a tile so the grid reads as a
   scattered pile of photos instead of a spreadsheet of thumbnails. Rows are
   built wide in mountHome() (22 tiles, not just enough to cover the shift)
   so there's real width left to pan across before the drift loops. */
.hero-wall .row.odd { margin-left: -38px; }
/* Each tile paints its 1KB "_small" thumbnail (core.js's coverThumb / photos.js's
   thumb -- already generated for exactly this, see build_data.py's "blur-up
   placeholder" comment) instantly via --bg, softened with its own blur so the
   80x60 source doesn't read as pixelated when stretched across the tile. The
   real photo loads separately underneath as <img> and crossfades in over it
   once it's actually ready (mountHome(), site/assets/app.js) -- so a slow
   photo just takes its time instead of leaving the tile blank, and nothing
   has to wait for the slowest tile before any of the wall shows up. */
.hero-wall .row div {
  position: relative; overflow: hidden;
  flex: 0 0 80px; filter: var(--hero-wall-filter);
}
.hero-wall .row div::before {
  content: ""; position: absolute; inset: 0;
  background: var(--bg) no-repeat center / 190%;
  filter: blur(8px);
}
.hero-wall .row div img {
  position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover;
  opacity: 0; transition: opacity .7s var(--ease);
}
.hero-wall .row div img.in { opacity: 1; }
/* Fades to var(--bg) rather than a flat dark colour, so light mode gets a
   bright, airy wash here instead of the dark-mode moodiness re-appearing
   regardless of theme -- same color-mix idiom as .timeline's sticky bar. */
.hero-wall-clip::after {
  content: ""; position: absolute; inset: 0; z-index: 1; pointer-events: none;
  background: linear-gradient(180deg,
    color-mix(in srgb, var(--bg) 28%, transparent),
    color-mix(in srgb, var(--bg) 70%, transparent));
}
@keyframes hero-drift { from { transform: translateX(0); } to { transform: translateX(-28.57%); } }

/* The profile card floating on the wall -- styled to actually behave like
   glass rather than just "blurry and translucent": saturate() pushed up so
   colour comes through vivid instead of washed out, a bright inset line at
   the top edge stands in for a highlight catching light across the rim (the
   giveaway of every Apple frosted-glass panel), a faint inset shade at the
   bottom hints at real thickness, and var(--sh-3) lifts the whole thing off
   the wall the way a sheet of glass actually casts a shadow. The insets and
   the rim are a fixed-or-near-fixed white regardless of theme -- they
   represent light hitting the material itself, not the content behind it --
   but var(--glass-rim) still needs its own light/dark values: the same
   alpha reads as more visible against dark mode's much darker wall than it
   does against light mode's, so dark mode gets a dimmer rim to look equally
   subtle rather than equally transparent. */
.hero-profile {
  position: relative; z-index: 1;
  background: color-mix(in srgb, var(--surface) 39%, transparent);
  backdrop-filter: blur(6px) saturate(1.8);
  border: 1px solid var(--glass-rim); border-radius: var(--r-lg);
  box-shadow: var(--sh-3), inset 0 1px 0 rgba(255, 255, 255, .5), inset 0 -1px 0 rgba(0, 0, 0, .08);
  padding: clamp(22px, 3vw, 32px); max-width: 640px; margin: 24px;
  display: flex; gap: 20px; align-items: center; flex-wrap: wrap;
}
/* An <a> now (links to Friends), not a plain div -- reset the link
   defaults and give it the same hover-scale affordance .avatar-row's
   photos already use, so it reads as clickable the same way those do. */
.hero-ring {
  display: block; width: 84px; height: 84px; border-radius: 50%; padding: 3px;
  background: var(--grad); flex: none; text-decoration: none;
}
.hero-ring-in {
  position: relative; width: 100%; height: 100%; border-radius: 50%;
  overflow: hidden; border: 3px solid var(--surface);
  transition: transform .2s var(--ease);
}
.hero-ring:hover .hero-ring-in, .hero-ring:focus-visible .hero-ring-in { transform: scale(1.06); }
/* Rotates through real profile pics now (rotateHeroAvatar(), app.js) rather
   than one fixed photo -- those are the same ~97px avatars .avatar-in crops,
   white frame baked in and all, so this needs the exact same over-zoom past
   that frame .avatar-in img already uses, not just object-fit:cover on its
   own. Each rotation stacks the incoming photo on top of the outgoing one
   (both sit here at once, absolutely positioned) and fades the new one in
   over it, same as .hero-wall's tiles -- .hero-ring itself is the gradient
   ring around this circle, so fading a single <img> out to nothing first,
   the way the very first version of this did, bared that gradient through
   the gap for a moment instead of one photo dissolving into the next. */
.hero-ring-in img {
  position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover;
  transform: scale(1.14); transform-origin: center;
  /* Duration matches HERO_FADE_MS (app.js), which is a bit shorter than
     HERO_ROTATE_MS there -- the difference is HERO_PAUSE_MS, a brief rest
     at full opacity before the next rotation starts. Change all three
     together. Plain linear rather than var(--ease): that curve front-loads
     almost all its motion into the first third of the duration then
     crawls the rest of the way, which is right for a snappy hover/press
     but reads as a long stall right before every rotation here. Linear
     keeps the opacity changing at the same steady rate for the full
     2.6s, so the fade itself is a genuine constant blend rather than
     "quick change, then a long crawl". */
  opacity: 0; transition: opacity 2.6s linear;
}
.hero-ring-in img.in { opacity: 1; }
.hero-profile-main { flex: 1; min-width: 240px; }
.hero-name-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 8px; }
/* A soft, surface-toned glow right around the letterforms (not a hard drop
   shadow) -- reads as extra contrast against whatever photo is behind that
   spot on the wall, without the card itself needing to get any more opaque. */
.hero-name-row h1,
.hero .lede,
.hero-counts {
  text-shadow: 0 1px 2px color-mix(in srgb, var(--surface) 90%, transparent),
    0 0 20px color-mix(in srgb, var(--surface) 62%, transparent);
}
.hero-name-row h1 { margin: 0; }
.hero .lede { font-size: .96rem; max-width: 52ch; }
.hero-counts { display: flex; gap: 16px; margin-top: 12px; font-size: .84rem; flex-wrap: wrap; color: var(--dim); }
.hero-counts b { color: var(--text); font-variant-numeric: tabular-nums; }
/* shared "row of buttons" class, reused sitewide (profiles, contact form,
   gate screens, the guestbook login popover) -- not just this hero. */
.hero-cta { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 24px; }
.hero-profile .hero-cta { margin-top: 14px; }
.hero-badges { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }
/* min-width:0 overrides a flex item's default min-width:auto, which
   otherwise refuses to shrink an item below its content's natural size --
   .hero-profile's content could win that fight and force the card wider
   than its own margin left room for, overflowing .hero on both sides
   instead of sitting inset from it. Unconditional rather than only inside
   the narrow-phone query below: the same overflow risk exists at any width
   narrow enough for it, not just below one fixed breakpoint. */
.hero-profile { min-width: 0; }
/* Come say hi! / Watch the intro side by side at every width, not just
   inside the phone breakpoint below -- .hero-cta's shared flex-wrap:wrap
   was still winning in the gap between "phone" and "wide enough for two
   full-size buttons", which is exactly the range .hero-ring shares the row
   with .hero-profile-main and leaves it less width than the viewport alone
   would suggest. clamp() scales the buttons continuously with viewport
   width instead of one fixed size below a breakpoint and a different fixed
   size above it, so there's no width where "the fixed size for this side"
   happens not to fit -- they're never a fixed size to begin with. */
.hero-profile .hero-cta { flex-wrap: nowrap; gap: clamp(6px, 2vw, 10px); }
.hero-profile .hero-cta .btn {
  flex: 0 1 auto; white-space: nowrap; min-width: 0;
  padding: clamp(8px, 2vw, 11px) clamp(10px, 3vw, 18px);
  gap: clamp(4px, 1.4vw, 8px);
  font-size: clamp(.78rem, 2.1vw, .92rem);
}
.hero-profile .hero-cta .btn .i { flex: none; }
@media (max-width: 560px) {
  /* margin trimmed at this width specifically -- 24px plus even the
     smallest end of the buttons' clamped range doesn't leave quite enough
     room on a genuinely narrow phone, so this favours keeping the buttons
     legible over keeping the exact same margin as wider screens. */
  .hero-profile { flex-direction: column; text-align: center; margin: 14px; }
  .hero-name-row, .hero-counts, .hero-profile .hero-cta { justify-content: center; }
}
@media (max-width: 340px) {
  /* below this, even the clamped floor sizes above don't leave room for
     both buttons on one line -- .hero-cta was overflowing .hero's own
     overflow:hidden edge and silently clipping "Watch the intro"'s last
     few letters. No real 2026 phone is this narrow (360px+ is the modern
     baseline), but rather than leave invisible clipping on the rare
     window/device that is, fall back to a clean two-row stack: still
     centered, still full pill buttons, just not side by side. */
  .hero-profile .hero-cta { flex-wrap: wrap; }
}

/* -------------------------------------------------------------- grids ---- */
.grid { display: grid; gap: 18px; }
.grid.albums  { grid-template-columns: repeat(auto-fill, minmax(215px, 1fr)); }
.grid.videos  { grid-template-columns: repeat(auto-fill, minmax(275px, 1fr)); }
.grid.people  { grid-template-columns: repeat(auto-fill, minmax(158px, 1fr)); }
.grid.more    { grid-template-columns: repeat(auto-fill, minmax(268px, 1fr)); }
.grid.photos  { grid-template-columns: repeat(auto-fill, minmax(178px, 1fr)); gap: 12px; }
/* home runs its own denser rhythm -- smaller tiles, tighter gap -- without
   touching the Photos/Videos pages' full listings, which share these same
   .grid.albums/.grid.videos classes and want the roomier default sizing. */
.home .grid { gap: 12px; }
.home .grid.albums { grid-template-columns: repeat(auto-fill, minmax(168px, 1fr)); }
.home .grid.videos { grid-template-columns: repeat(auto-fill, minmax(222px, 1fr)); }

/* Card hovers scale rather than translate on purpose. Lifting a card with
   translateY slides its hit box off the cursor near the bottom edge, which
   cancels the hover and starts an endless flicker loop; growing cannot. */

/* album card ------------------------------------------------------------- */
.album {
  position: relative; display: flex; flex-direction: column;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--r-lg); overflow: hidden; color: inherit;
  box-shadow: var(--sh-1);
  transition: transform .2s var(--ease), box-shadow .2s var(--ease), border-color .2s;
}
.album:hover {
  text-decoration: none; transform: scale(1.014);
  box-shadow: var(--sh-2); border-color: var(--border-2);
}
.album .shot { aspect-ratio: 4 / 3; }
.album:hover .shot .real { transform: scale(1.04); }

/* A few scans have a thin white edge. .zoomed nudges just that card's art in
   far enough to hide it; the hover scale multiplies rather than replaces it, so
   the two compose. Scoped to .zoomed on purpose -- a transform on every
   thumbnail would promote 200+ layers on a big album. */
.shot.zoomed .real { transform: scale(var(--cover-zoom, 1)); }
.album:hover .shot.zoomed .real {
  transform: scale(calc(var(--cover-zoom, 1) * 1.04));
}
/* Same composition, for a zoomed photo inside an album's own grid (an entire
   album can carry a uniform crop -- see PHOTO_ZOOM in build_data.py -- rather
   than just its one cover card). Without this, .photo:hover .real below wins
   the tie on source order and the crop would vanish on every hover. */
.photo:hover .shot.zoomed .real,
.photo:focus-visible .shot.zoomed .real {
  transform: scale(calc(var(--cover-zoom, 1) * 1.05));
}
.album .shot .real { transition: opacity .45s var(--ease), transform .5s var(--ease); }
.album-body { padding: 13px 15px 15px; display: flex; gap: 11px; align-items: flex-start; }
.album-icon {
  width: 36px; height: 36px; flex: none; border-radius: 10px;
  border: 1.5px solid var(--border-2); object-fit: cover; margin-top: 1px;
  background: var(--bg-tint);
}
.album-meta { min-width: 0; }
.album-meta strong {
  display: block; font-size: .935rem; font-weight: 700; letter-spacing: -.02em;
  line-height: 1.3;
}
.album-meta span { color: var(--faint); font-size: .78rem; font-weight: 600; }
/* the old collection name, kept as a label so same-named albums stay distinct */
.album-meta em { font-style: normal; color: var(--accent); }
.album .corner {
  position: absolute; top: 10px; right: 10px; z-index: 2;
  background: rgba(18, 16, 24, .72); color: #fff;
}

/* home: compact rhythm -- smaller body padding/icon/type, and a flat card
   (shadow only on hover) rather than sh-1 at rest, to suit the tighter grid
   above. Scoped to .home; the Photos page's own album grid is unaffected. */
.home .album { box-shadow: none; }
.home .album:hover { box-shadow: var(--sh-1); }
.home .album-body { padding: 9px 11px 11px; gap: 8px; }
.home .album-icon { width: 27px; height: 27px; border-radius: 8px; }
.home .album-meta strong { font-size: .84rem; }
.home .album-meta span { font-size: .72rem; }

/* a locked album's cover photo is itself behind the password, so show a lock
   panel rather than requesting an image the server will refuse */
.locked-shot {
  display: grid; place-items: center;
  background: var(--grad-soft);
  border-bottom: 1px solid var(--border);
}
.lock-glyph {
  width: 38px; height: 38px; fill: none; stroke: currentColor;
  stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round;
  color: var(--accent); opacity: .75;
}

/* video card ------------------------------------------------------------- */
.video {
  position: relative;
  display: flex; flex-direction: column; text-align: left; cursor: pointer;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--r-lg); overflow: hidden; padding: 0;
  box-shadow: var(--sh-1);
  transition: transform .2s var(--ease), box-shadow .2s var(--ease), border-color .2s;
}
.video:hover { transform: scale(1.014); box-shadow: var(--sh-2); border-color: var(--border-2); }
/* .video-art is load-bearing: .play, .len and .badge-rm are absolutely
   positioned, so without a positioned wrapper around just the artwork they
   resolve against the viewport -- a full-page dark overlay with the badges
   stranded in the page corners. */
.video-art { position: relative; display: block; }
.video .shot { aspect-ratio: 16 / 9; }
.video .shot .real { object-position: center; }
.video .play {
  position: absolute; inset: 0; display: grid; place-items: center; z-index: 2;
  background: linear-gradient(180deg, rgba(10, 8, 16, .05), rgba(10, 8, 16, .45));
}
/* Same glass recipe as .hero-profile (styles.css, "The profile card floating
   on the wall"): color-mix over var(--surface) instead of a flat colour,
   backdrop-filter to actually blur/saturate what's behind it, var(--glass-rim)
   for a theme-correct rim, and the same inset-highlight/inset-shade pair for
   a top edge that looks like it's catching light. Blur is a good deal
   lighter than the hero card's, though -- at 52px across, a 16-20px blur
   would smear almost the whole disc into one flat colour instead of reading
   as frosted glass over the poster underneath it. */
.video .play-disc {
  width: 52px; height: 52px; border-radius: 50%; display: grid; place-items: center;
  background: var(--play-disc-bg);
  backdrop-filter: blur(6px) saturate(1.8);
  border: 1px solid var(--glass-rim);
  box-shadow: 0 6px 20px rgba(0, 0, 0, .35), inset 0 1px 0 rgba(255, 255, 255, .5), inset 0 -1px 0 rgba(0, 0, 0, .08);
  transition: transform .2s var(--ease);
}
.video:hover .play-disc { transform: scale(1.1); }
/* the glyph paints with fill="currentColor", so set colour not fill. Reads
   off var(--text) rather than a fixed dark colour now that the disc itself
   is glass, not a solid near-white -- dark mode's disc is a dark tint, and a
   fixed dark triangle would all but vanish on it. The drop-shadow is the
   same soft surface-toned glow .hero-name-row h1 uses, doing the same job:
   a bit of contrast insurance against whatever's directly behind the glyph.
   margin-left nudges the glyph off the disc's true centre -- the path (M8
   5.5 19 12 8 18.5z) is a right-pointing triangle whose own geometric
   centroid already sits almost exactly in the middle of its 24x24 viewBox,
   so it barely needs any nudge; 3px was overshooting it visibly to the
   right, 1px is what actually lands it centred. */
.video .play-tri {
  width: 21px; height: 21px; color: var(--text);
  fill: currentColor; stroke: none; margin-left: 1px;
  filter: drop-shadow(0 0 6px color-mix(in srgb, var(--surface) 65%, transparent));
}
.video .len {
  position: absolute; bottom: 9px; right: 9px; z-index: 3;
  background: rgba(12, 10, 18, .86); color: #fff;
  font-variant-numeric: tabular-nums;
}
.video .badge-rm { position: absolute; top: 9px; left: 9px; z-index: 3; }
.video-body { display: block; padding: 14px 16px 16px; }
.video-body strong { display: block; font-size: .96rem; letter-spacing: -.02em; margin-bottom: 5px; }
.video-body p {
  color: var(--dim); font-size: .845rem; line-height: 1.5;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}

/* home: compact rhythm -- see the matching .home .album block above for why
   this is scoped rather than changing .video itself (shared with Videos). */
.home .video { box-shadow: none; }
.home .video:hover { box-shadow: var(--sh-1); }
.home .video-body { padding: 9px 11px 11px; }
.home .video-body strong { font-size: .85rem; margin-bottom: 2px; }
.home .video-body p { font-size: .78rem; -webkit-line-clamp: 1; }

/* person card ------------------------------------------------------------ */
.person {
  padding: 20px 14px 16px; text-align: center; border-radius: var(--r-lg);
  background: var(--surface); border: 1px solid var(--border); box-shadow: var(--sh-1);
  transition: transform .2s var(--ease), box-shadow .2s var(--ease);
}
.person:hover { transform: scale(1.02); box-shadow: var(--sh-2); }
.person .avatar {
  display: block; width: 86px; height: 86px; margin: 0 auto 12px;
  border-radius: 50%; padding: 2.5px; background: var(--grad);
}

/* The 97px avatars from the old site carry a ~3px white frame on all four
   sides, and a circular crop sits exactly on it. .avatar-in clips to the
   circle so the photo can be scaled up until that frame is outside the
   visible area -- about 5px off each edge, which the portraits can spare. */
.avatar-in {
  display: block; width: 100%; height: 100%; overflow: hidden;
  border-radius: 50%; background: var(--bg-tint);
  border: 2.5px solid var(--surface);
}
.avatar-in img {
  width: 100%; height: 100%; object-fit: cover;
  transform: scale(1.14); transform-origin: center;
}
/* friends tabs ---------------------------------------------------------- */
.tabs {
  display: flex; gap: 8px; flex-wrap: wrap;
  margin: 28px 0 0; padding-bottom: 14px;
  border-bottom: 1px solid var(--border);
}
.tab {
  display: inline-flex; align-items: center; gap: 8px;
  padding: 9px 16px; border-radius: 999px; cursor: pointer;
  background: transparent; border: 1px solid var(--border);
  color: var(--dim); font-weight: 700; font-size: .9rem;
  transition: color .16s, border-color .16s, background .16s, transform .2s var(--ease);
}
.tab:hover { color: var(--text); border-color: var(--border-2); }
.tab .tab-n {
  font-size: .74rem; color: var(--faint); font-variant-numeric: tabular-nums;
}
.tab.on {
  background: var(--grad); border-color: transparent; color: #fff;
  box-shadow: 0 4px 16px -5px rgba(245, 66, 124, .5);
}
.tab.on .tab-n { color: rgba(255, 255, 255, .82); }
.panel { margin-top: 20px; }
.panel-blurb { color: var(--dim); font-size: .9rem; margin-bottom: 16px; }

.person strong { display: block; font-size: .98rem; letter-spacing: -.02em; }
.person .who { color: var(--faint); font-size: .76rem; font-weight: 600; margin-bottom: 11px; }
.person { display: block; color: inherit; }
.person:hover { text-decoration: none; }
.person .links { display: flex; gap: 6px; justify-content: center; flex-wrap: wrap; }
.person .links span {
  padding: 5px 10px; border-radius: 9px; font-size: .74rem; font-weight: 700;
  background: var(--surface-2); border: 1px solid var(--border); color: var(--dim);
}
.person:hover .links span { border-color: var(--border-2); }

/* ------------------------------------------------------------- profile --- */
/* Deliberately never stacks, at any width -- flex-wrap used to let
   .profile-id (below) drop under the photo once the row ran out of room,
   which on a phone meant the header suddenly looked like a completely
   different layout instead of the same one at a smaller size. .profile-id
   already has min-width:0 and the name/pill/count/button beneath it all
   already reflow/shrink on their own (h1 is a clamp(), .hero-cta's own
   buttons already handle narrow widths), so keeping this row side by side
   just lets that column narrow instead of the whole header re-flowing. */
.profile-head {
  display: flex; gap: clamp(14px, 3vw, 32px); align-items: center;
  flex-wrap: nowrap; margin-bottom: 28px;
}
.profile-shot {
  width: clamp(88px, 24vw, 150px); flex: none; padding: 4px; border-radius: var(--r-lg);
  background: var(--grad); box-shadow: var(--sh-2); overflow: hidden;
}
.profile-shot img {
  width: 100%; aspect-ratio: 110 / 125; object-fit: cover;
  border-radius: calc(var(--r-lg) - 5px); background: var(--bg-tint);
}

.profile-id { min-width: 0; flex: 1 1 320px; }
.profile-id h1 { margin: 10px 0 0; }
.profile-id .tagline { color: var(--accent); font-weight: 700; margin-top: 6px; }
/* .count (shared, general rule elsewhere) is nowrap for short standalone
   stats, but this one is a longer "N photos · updated ..." sentence -- now
   that the photo no longer gives way and this column can get genuinely
   narrow on a phone, nowrap just ran the text off the edge instead of
   wrapping it. */
.profile-id .count { color: var(--faint); font-size: .85rem; font-weight: 600; margin-top: 8px; white-space: normal; }
.profile-id .hero-cta { margin-top: 18px; }

/* one card per fact rather than a divider grid, so a short last row does not
   leave a stripe of empty cells. Wide screens should still fan this out to
   as many columns as comfortably fit (that's what auto-fill is for), but
   plain minmax(196px, 1fr) dropped to a single column once the viewport
   couldn't fit two 196px cards side by side, which on a phone meant every
   card went full-width instead of staying paired up -- a flat
   repeat(2, 1fr) fixed the phone case but then also flattened the wide
   case down to a rigid 2 columns everywhere, which is its own regression.
   min(196px, calc(50% - 5px)) is the fix for both at once: on anything
   wide enough for two 196px cards, that's smaller than half the
   container, so min() picks 196px and auto-fill behaves exactly as
   before, fanning out to 3/4/5+ columns as space allows. Only once the
   container can't fit two 196px cards does half-the-container (minus
   half the gap) become the smaller number -- at which point the track
   minimum IS "exactly half, minus the gap," so auto-fill can always fit
   two and only two, never one. */
.facts {
  display: grid; gap: 10px; margin: 0;
  grid-template-columns: repeat(auto-fill, minmax(min(196px, calc(50% - 5px)), 1fr));
}
.fact {
  background: var(--surface); padding: 13px 16px;
  border: 1px solid var(--border); border-radius: var(--r);
  box-shadow: var(--sh-1);
}
.fact dt {
  font-size: .7rem; font-weight: 800; letter-spacing: .07em;
  text-transform: uppercase; color: var(--faint); margin-bottom: 3px;
}
.fact dd { margin: 0; font-weight: 600; font-size: .95rem; letter-spacing: -.02em; }

.prose.bio p { color: var(--text); }
.prose.bio p:first-child::first-letter {
  float: left; font-size: 3.1rem; line-height: .82; font-weight: 800;
  padding: 4px 10px 0 0; color: var(--accent);
}

/* compact rhythm: this row is home-only already (the Friends page uses
   .grid.people/.person instead), so its own rules just get tightened
   in place rather than needing a .home-scoped copy. */
.avatar-row { display: flex; flex-wrap: wrap; gap: 7px; }
.avatar-row a {
  display: block; width: 72px; height: 72px; border-radius: 50%;
  padding: 2px; background: var(--grad); position: relative;
}
/* z-index so a scaled-up avatar renders over its neighbours instead of
   getting clipped under them -- at this size increase it genuinely
   overlaps the next avatar in the row, same as any hover-zoomed grid. */
.avatar-row a:hover, .avatar-row a:focus-visible { z-index: 2; }
/* The zoom lives on .avatar-in, not the <a> itself, on purpose: the
   tooltip below is positioned relative to the <a>, and a transform on an
   element scales everything painted inside it, tooltip included -- with
   the scale on the link, the tooltip's own gap and text size were both
   getting multiplied by the same factor along with the avatar, which read
   as sitting too far above it. Scaling this inner element instead means
   the <a> (and the tooltip anchored to it) never moves, only the circle
   does. */
.avatar-row .avatar-in { border-width: 2px; transition: transform .2s var(--ease); }
.avatar-row a:hover .avatar-in, .avatar-row a:focus-visible .avatar-in { transform: scale(1.6); }

/* Custom tooltip, replacing the browser's native one (mountHome() puts the
   name in data-name now instead of title -- title is what triggers the
   native tooltip, so dropping it is what turns this one off). A small glass
   pill instead of the browser's plain grey box, matching the same recipe
   .hero-profile/.play-disc use. Pure CSS -- no JS needed to position or
   time it, and :focus-visible means it's reachable by keyboard too, not
   just a mouse hover. */
.avatar-row a::after {
  content: attr(data-name);
  /* Below the circle instead of above, and overlapping it on purpose --
     top:calc(100% + 6px) starts the pill 6px past the box's own bottom
     edge (the box itself doesn't scale on hover, only .avatar-in inside
     it does, same reasoning as the comment above), so it sits with a
     small clear gap under the avatar instead of touching it. */
  position: absolute; top: calc(100% + 6px); left: 50%;
  transform: translate(-50%, -4px);
  white-space: nowrap; padding: 6px 12px; border-radius: 999px;
  background: color-mix(in srgb, var(--surface) 88%, transparent);
  backdrop-filter: blur(6px) saturate(1.8);
  border: 1px solid var(--glass-rim);
  color: var(--text); font-size: .86rem; font-weight: 700;
  box-shadow: var(--sh-2);
  opacity: 0; visibility: hidden; pointer-events: none;
  transition: opacity .15s var(--ease), transform .15s var(--ease);
  z-index: 3;
}
.avatar-row a:hover::after, .avatar-row a:focus-visible::after {
  opacity: 1; visibility: visible; transform: translate(-50%, 0);
}

/* home-page guestbook preview: the 3 most recent entries, live-fetched --
   overflow-wrap matches .post-body above, since guestbook text is
   unmoderated and at least one real entry is a single unbroken ~50-char
   "word" that would otherwise force the row wider than the viewport.
   Home-only too (the full Guestbook page renders entries into .feed/.post
   instead), so -- same as .avatar-row above -- compact rhythm just tightens
   these numbers in place. */
.gb-row { display: grid; gap: 10px; grid-template-columns: repeat(3, 1fr); }
.gb-card {
  display: block; color: inherit;
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-lg);
  padding: 12px 13px;
  transition: transform .2s var(--ease), box-shadow .2s var(--ease), border-color .2s;
}
.gb-card:hover { text-decoration: none; transform: scale(1.02); box-shadow: var(--sh-1); border-color: var(--border-2); }
.gb-card:hover strong { color: var(--accent); }
.gb-card .who { display: flex; align-items: center; gap: 9px; margin-bottom: 10px; }
.gb-card .dot {
  width: 30px; height: 30px; border-radius: 50%; background: var(--grad); color: #fff;
  display: grid; place-items: center; flex: none;
}
.gb-card .dot .i { width: 14px; height: 14px; }
.gb-card strong { font-size: .88rem; letter-spacing: -.01em; }
.gb-card small { display: block; color: var(--faint); font-size: .72rem; font-weight: 600; }
.gb-card p {
  margin: 0; color: var(--dim); font-size: .86rem; line-height: 1.5; overflow-wrap: break-word;
  display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;
}
@media (max-width: 780px) { .gb-row { grid-template-columns: 1fr; } }

/* more card -------------------------------------------------------------- */
.tile {
  position: relative;                 /* containing block for .tile-ext */
  display: flex; gap: 14px; align-items: flex-start; padding: 18px;
  border-radius: var(--r-lg); color: inherit;
  background: var(--surface); border: 1px solid var(--border); box-shadow: var(--sh-1);
  transition: transform .2s var(--ease), box-shadow .2s var(--ease), border-color .2s;
}
.tile:hover { text-decoration: none; transform: scale(1.014);
  box-shadow: var(--sh-2); border-color: var(--border-2); }
.tile .glyph {
  width: 42px; height: 42px; flex: none; border-radius: 12px;
  display: grid; place-items: center; background: var(--grad-soft);
  border: 1px solid var(--border); color: var(--accent);
}
.tile strong { display: flex; align-items: center; gap: 8px; font-size: .96rem;
  letter-spacing: -.02em; margin-bottom: 5px; }
.tile p { color: var(--dim); font-size: .855rem; line-height: 1.5; }
/* Leaves-the-site badge. It lives in the corner so titles stay clean, and it
   inherits stroke from .i -- the previous inline icon carried a bespoke class
   that skipped .i, so it painted as a solid black fill and read as a smudge,
   worst of all on dark surfaces. */
.tile-ext {
  position: absolute; top: 12px; right: 12px;
  display: grid; place-items: center;
  width: 26px; height: 26px; border-radius: 50%;
  background: var(--surface-2); border: 1px solid var(--border);
  color: var(--faint);
  transition: color .16s var(--ease), border-color .16s var(--ease),
              background .16s var(--ease);
}
.tile-ext .i { width: 13px; height: 13px; stroke-width: 2.1; }
.tile:hover .tile-ext {
  color: var(--accent); border-color: var(--accent); background: var(--accent-soft);
}
/* keep the title clear of the badge */
.tile.is-ext > div { padding-right: 26px; }

/* offscreen but readable by assistive tech */
.sr {
  position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap;
}

/* ------------------------------------------------------- download pages - */
.grid.dl { grid-template-columns: repeat(auto-fill, minmax(258px, 1fr)); }
.grid.dpics { grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); gap: 14px; }

.dl-card {
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--r-lg); overflow: hidden; box-shadow: var(--sh-1);
}
.dl-art { display: block; }
.dl-art .shot { aspect-ratio: 4 / 3; }
.dl-art:hover .shot .real { transform: scale(1.04); }
.dl-art .shot .real { transition: opacity .45s var(--ease), transform .5s var(--ease); }
.dl-body { padding: 13px 15px 15px; }
.dl-body strong { display: block; font-size: .95rem; letter-spacing: -.02em; margin-bottom: 10px; }
.dl-sizes { display: flex; gap: 7px; flex-wrap: wrap; }
.dl-sizes a {
  display: inline-flex; align-items: center; gap: 5px;
  padding: 6px 11px; border-radius: 999px;
  font-size: .78rem; font-weight: 700; color: var(--dim);
  background: var(--surface-2); border: 1px solid var(--border);
  transition: color .16s, border-color .16s;
}
.dl-sizes a:hover { text-decoration: none; color: var(--accent); border-color: var(--accent); }
.dl-sizes .i { width: 13px; height: 13px; stroke-width: 2.2; }

.dpic {
  display: block; color: inherit; border-radius: var(--r);
  background: var(--surface); border: 1px solid var(--border);
  overflow: hidden; box-shadow: var(--sh-1);
  transition: transform .2s var(--ease), box-shadow .2s var(--ease), border-color .2s;
}
.dpic:hover { text-decoration: none; transform: scale(1.03);
  box-shadow: var(--sh-2); border-color: var(--border-2); }
.dpic .shot { aspect-ratio: 118 / 124; }
.dpic-name {
  display: block; padding: 8px 10px; text-align: center;
  font-size: .8rem; font-weight: 700; letter-spacing: -.02em;
}

/* ---------------------------------------------------------- album detail - */
.crumb {
  display: inline-flex; align-items: center; gap: 7px; margin-bottom: 18px;
  color: var(--dim); font-weight: 600; font-size: .875rem;
}
.crumb:hover { color: var(--accent); text-decoration: none; }
.crumb .i { width: 17px; height: 17px; }

.album-head { margin-bottom: 26px; }
.album-head .hgroup { display: flex; gap: 16px; align-items: center; margin-bottom: 12px; }
.album-head .album-icon { width: 54px; height: 54px; border-radius: 14px; }
.album-head .lede { margin-top: 10px; }

.photo {
  position: relative; padding: 0; border: 0; cursor: zoom-in; display: block;
  border-radius: var(--r); overflow: hidden; background: var(--bg-tint);
  box-shadow: var(--sh-1);
}
.photo .shot { aspect-ratio: 4 / 3; border-radius: var(--r); }
.photo .real { transition: opacity .4s var(--ease), transform .45s var(--ease); }
.photo:hover .real { transform: scale(1.05); }
.photo .cap {
  position: absolute; inset: auto 0 0 0; z-index: 2; padding: 20px 11px 9px;
  background: linear-gradient(transparent, rgba(10, 8, 16, .86));
  color: #fff; font-size: .76rem; line-height: 1.35; text-align: left;
  opacity: 0; transform: translateY(6px); transition: .2s var(--ease);
  display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;
}
.photo:hover .cap, .photo:focus-visible .cap { opacity: 1; transform: none; }

/* locked album gate ----------------------------------------------------- */
.gate {
  text-align: center; padding: clamp(32px, 6vw, 56px) 24px;
  border-radius: var(--r-xl); border: 1px solid var(--border);
  background: var(--surface); background-image: var(--grad-soft); box-shadow: var(--sh-1);
}
.gate .lock {
  width: 60px; height: 60px; margin: 0 auto 18px; border-radius: 18px;
  display: grid; place-items: center; background: var(--grad); color: #fff;
  box-shadow: 0 8px 22px -8px rgba(245, 66, 124, .7);
}
.gate .lock .i { width: 27px; height: 27px; stroke-width: 2.1; }
.gate h2 { margin-bottom: 10px; }
.gate p { color: var(--dim); max-width: 52ch; margin: 0 auto 8px; }
.gate .hero-cta { justify-content: center; }
.gate code {
  background: var(--bg-tint); border: 1px solid var(--border); border-radius: 6px;
  padding: 1px 7px; font-size: .86em; font-family: ui-monospace, Menlo, Consolas, monospace;
}

/* ----------------------------------------------------------------- news -- */
.feed { max-width: 760px; }
/* news timeline --------------------------------------------------------- */
/* Sticks under whatever else is pinned, so the year you are reading stays
   visible through a twenty-year scroll. The blur needs a translucent ground to
   be worth its cost, hence the color-mix rather than a flat surface. */
.timeline {
  position: sticky; top: 0; z-index: 20;
  margin: 26px 0 4px; padding: 8px 0;
  background: color-mix(in srgb, var(--bg) 86%, transparent);
  backdrop-filter: blur(10px) saturate(1.3);
  border-bottom: 1px solid var(--border);
  display: flex; align-items: center; gap: 6px;
}
.timeline-track {
  flex: 1; min-width: 0;
  display: flex; gap: 6px; overflow-x: auto; scroll-behavior: smooth;
  scrollbar-width: none; -ms-overflow-style: none;
}
.timeline-track::-webkit-scrollbar { display: none; }

/* A swipe is the obvious thing to try on touch, but a mouse has no
   equivalent gesture, and the scrollbar above is hidden on purpose (it reads
   as noise on a strip like this) -- these are the discoverable, clickable
   stand-in. mountTimelineArrows() in app.js only un-hides whichever one
   still has somewhere left to go. */
.tl-arrow {
  flex: none; display: grid; place-items: center;
  width: 30px; height: 30px; border-radius: 50%; padding: 0; cursor: pointer;
  background: var(--surface); border: 1px solid var(--border); color: var(--dim);
  transition: color .16s, border-color .16s, background .16s;
}
.tl-arrow:hover { color: var(--text); border-color: var(--border-2); background: var(--bg-tint); }
.tl-arrow .i { width: 15px; height: 15px; }
.tl-arrow.tl-next .i { transform: scaleX(-1); }
.tl-year {
  display: flex; align-items: baseline; gap: 6px; flex: none;
  padding: 7px 13px; border-radius: 999px; cursor: pointer;
  background: transparent; border: 1px solid var(--border);
  color: var(--dim); font-weight: 700; font-size: .82rem;
  transition: color .16s, border-color .16s, background .16s;
}
.tl-year:hover { color: var(--text); border-color: var(--border-2); }
.tl-year .tl-n {
  font-size: .68rem; font-weight: 700; color: var(--faint);
  font-variant-numeric: tabular-nums;
}
.tl-year.on {
  background: var(--grad); border-color: transparent; color: #fff;
  box-shadow: 0 4px 14px -5px rgba(245, 66, 124, .55);
}
.tl-year.on .tl-n { color: rgba(255, 255, 255, .82); }

.year-rule {
  display: flex; align-items: center; gap: 14px; margin: 34px 0 18px;
  color: var(--faint); font-size: .8rem; font-weight: 800; letter-spacing: .1em;
  scroll-margin-top: 120px;
}
.year-rule::after { content: ""; flex: 1; height: 1px; background: var(--border); }
.year-rule:first-child { margin-top: 0; }

.post {
  padding: 20px 22px; border-radius: var(--r-lg); margin-bottom: 14px;
  background: var(--surface); border: 1px solid var(--border); box-shadow: var(--sh-1);
}
/* home: compact rhythm. Scoped, not a change to .post itself -- the News
   archive and the Guestbook page both render into the same .feed of .post
   cards and keep the roomier default. */
.home .post { padding: 13px 15px; margin-bottom: 9px; box-shadow: none; }
.post-head { display: flex; align-items: center; gap: 11px; margin-bottom: 12px; }
.post-head .dot {
  width: 34px; height: 34px; flex: none; border-radius: 50%;
  background: var(--grad); display: grid; place-items: center; color: #fff;
}
.post-head .dot .i { width: 17px; height: 17px; }
.post-head strong { font-size: .94rem; letter-spacing: -.02em; overflow-wrap: break-word; }
.post-head small { display: block; color: var(--faint); font-size: .75rem; font-weight: 600; }
/* News bodies are pre-sanitised at scrape time and never hit this, but a
   guestbook message is raw text from a stranger, and at least one 2005
   entry is a single ~50-character word with no spaces at all (see it for
   yourself: it's a joke about a very long, very rude domain name). With
   nowhere to break, that forces the whole page wider than the viewport on
   mobile -- overflow-wrap gives the browser a place to break it instead. */
.post-body { color: var(--dim); font-size: .93rem; overflow-wrap: break-word; }
.post-body p { margin: 0 0 11px; }
.post-body p:last-child { margin-bottom: 0; }
.post-body img.emoji {
  display: inline-block; width: 19px; height: 19px; vertical-align: -4px; margin: 0 1px;
}
/* the odd screenshot that got pasted into a news post */
.post-body img.inline-img {
  max-width: 100%; width: auto; border-radius: var(--r-sm);
  border: 1px solid var(--border); margin: 6px 0;
}
.post-body a { font-weight: 600; }

/* ---------------------------------------------------------------- prose -- */
.prose { max-width: 68ch; }
.prose h2 { margin: 32px 0 12px; }
.prose h2:first-child { margin-top: 0; }
.prose p { color: var(--dim); margin-bottom: 14px; }
.prose .sig {
  margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border);
  font-weight: 700; color: var(--text);
}

/* ------------------------------------------------------------- lightbox -- */
.lightbox {
  position: fixed; inset: 0; z-index: 100;
  display: grid; grid-template-columns: auto 1fr auto; align-items: center;
  background: rgba(8, 7, 12, .94);
  backdrop-filter: blur(14px);
  animation: fade .2s var(--ease);
}
@keyframes fade { from { opacity: 0; } }
.lb-bar {
  position: absolute; top: 0; left: 0; right: 0; z-index: 6;
  display: flex; align-items: center; gap: 12px; padding: 14px 16px;
  background: linear-gradient(rgba(8, 7, 12, .8), transparent);
}
.lb-count { color: rgba(255, 255, 255, .8); font-size: .85rem; font-weight: 700;
  font-variant-numeric: tabular-nums; }
.lb-tools { margin-left: auto; display: flex; gap: 6px; }
.lb-nav {
  z-index: 4; width: 54px; height: calc(100% - 66px); align-self: end;
  border: 0; background: transparent;
  color: rgba(255, 255, 255, .55); cursor: pointer; display: grid; place-items: center;
  transition: color .16s, background .16s;
}
.lb-nav:hover { color: #fff; background: rgba(255, 255, 255, .06); }
.lb-nav .i { width: 30px; height: 30px; stroke-width: 1.7; }
.lb-nav[disabled] { opacity: .2; cursor: default; background: none; }
.lb-stage {
  grid-column: 2; margin: 0; min-width: 0; height: 100%;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 14px; padding: 60px 4px 26px;
}
/* These are 2003-era camera photos, mostly 500-640px wide, so showing them at
   their literal pixel size (the old default here) leaves them looking small
   in the middle of a lot of black on any modern display. app.js asks for
   1.5x that once it knows the real dimensions; width:auto is what renders
   while that is still loading, and what every ratio still falls back to
   whenever a photo's native size is already bigger than these caps allow. */
.lb-stage img {
  max-width: min(100%, 1500px); max-height: calc(100vh - 160px);
  width: auto; object-fit: contain;
  border-radius: 12px; box-shadow: 0 24px 70px rgba(0, 0, 0, .7);
  background: #14121a;
}
.lb-stage figcaption {
  color: rgba(255, 255, 255, .82); font-size: .89rem; line-height: 1.5;
  text-align: center; max-width: 68ch; text-wrap: pretty;
}

/* ---------------------------------------------------------------- modal -- */
.modal {
  position: fixed; inset: 0; z-index: 110; display: grid; place-items: center;
  padding: 20px; background: rgba(8, 7, 12, .8); backdrop-filter: blur(10px);
  animation: fade .18s var(--ease);
}
.modal-card {
  width: min(880px, 100%); max-height: calc(100vh - 40px); overflow: auto;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--r-xl); box-shadow: var(--sh-3);
  animation: pop .22s var(--ease);
}
@keyframes pop { from { transform: translateY(14px) scale(.985); opacity: 0; } }
.modal-head { display: flex; gap: 14px; align-items: flex-start; padding: 20px 22px 14px; }
.modal-head h3 { font-size: 1.06rem; margin-bottom: 5px; }
.modal-head p { color: var(--dim); font-size: .87rem; line-height: 1.5; }
.modal-head .icon-btn { margin-left: auto; }
.modal-head .icon-btn.ghost { background: var(--surface-2); border-color: var(--border); }
.modal-head .icon-btn.ghost:hover { background: var(--bg-tint); color: var(--text); }
.modal-body { padding: 0 22px 22px; }
.modal-body iframe, .modal-body video, .modal-body img {
  width: 100%; aspect-ratio: 4 / 3; border: 0; border-radius: var(--r);
  background: #000; display: block;
}
.modal-body img { object-fit: contain; }

/* The video lightbox is dark regardless of site theme -- footage reads
   better against a dark surround than a bright white modal, and it matches
   how a video player conventionally behaves independent of the app chrome
   around it (YouTube's own fullscreen/theatre view included). Re-points the
   same tokens :root[data-theme="dark"] does, scoped to #player, so every
   descendant that reads var(--surface)/var(--text)/etc. -- .modal-card,
   the title/description, the close button -- resolves dark here even in
   light mode. Re-pointing --text alone isn't enough: body already resolved
   `color: var(--text)` against light mode's value, and anything under here
   that only inherits it would keep that light-mode colour on a dark panel,
   so it's declared again to re-resolve for the subtree (same reason the
   old aurora .hero once needed this exact pattern). */
#player {
  --bg:        #0e0d13;
  --bg-tint:   #16151e;
  --surface:   #191823;
  --surface-2: #21202d;
  --border:    #2a2937;
  --border-2:  #383646;
  --text:      #f3f1f7;
  --dim:       #a9a6b8;
  --faint:     #8b889b;
  --accent:      #ff86ab;
  --accent-ink:  #24040f;
  --accent-soft: #2b1622;
  --teal:        #37d6cb;
  --grad-soft: linear-gradient(120deg, #2a1a18, #2a1622 52%, #1d1a35);
  --sh-1: 0 1px 2px rgba(0, 0, 0, .5);
  --sh-2: 0 2px 8px rgba(0, 0, 0, .45), 0 14px 30px -14px rgba(0, 0, 0, .7);
  --sh-3: 0 10px 26px rgba(0, 0, 0, .5), 0 34px 70px -22px rgba(0, 0, 0, .85);
  color: var(--text);
}
/* Glass on the header specifically -- not the whole card, and not
   .modal-body where the video actually plays. backdrop-filter on an element
   with a continuously-updating video as a descendant is a known source of
   render instability in some browsers, which is exactly what "weird
   flashing" was the last time this got the whole-card treatment. The header
   has no video in it, just title/description/close, so it can be glass
   without that risk -- .modal-card's overflow:auto + border-radius already
   clips it to the card's rounded top corners, so it doesn't need its own.
   Values are hardcoded rather than reading var(--glass-rim)/the dark half
   of --search-glass-highlight because #player forces dark above regardless
   of site theme -- there's no light-mode case here to cover with a token,
   just the one state, spelled out. No border-bottom here on purpose -- a
   solid line dividing the header from the video read as a stray rule cutting
   across the lightbox; the tint change between the two is enough of a seam
   on its own. The inset highlight is dimmer than search's/the cards' .16 for
   the same reason -- .modal's own scrim behind it is already closer to
   black than what those sit on, so the same alpha read brighter here. */
#player .modal-head {
  position: relative;
  background: color-mix(in srgb, var(--surface) 39%, transparent);
  backdrop-filter: blur(6px) saturate(1.8);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, .08);
}

/* --------------------------------------------------------------- search -- */
.search-modal { align-items: start; padding-top: min(12vh, 90px); }
.search-card { width: min(620px, 100%); overflow: hidden; }
/* Same glass recipe as .hero-profile (styles.css, "The profile card floating
   on the wall"), same tuned values too: color-mix over var(--surface)
   instead of solid, backdrop-filter to actually blur/saturate the page
   behind it, var(--glass-rim) for a theme-correct rim, and the same
   inset-highlight/inset-shade pair for a top edge that catches light.
   Scoped to #search rather than .modal-card itself -- #player's video
   lightbox uses that same class and needs to stay solid and always-dark
   (see the comment above it) instead. */
#search .modal-card {
  background: var(--search-glass-bg);
  backdrop-filter: blur(6px) saturate(1.8);
  border-color: var(--search-glass-rim);
  box-shadow: var(--sh-3), inset 0 1px 0 var(--search-glass-highlight), inset 0 -1px 0 rgba(0, 0, 0, .08);
}
.search-input { display: flex; align-items: center; gap: 11px; padding: 15px 18px;
  border-bottom: 1px solid var(--border); }
.search-input .i { color: var(--faint); }
.search-input input {
  flex: 1; min-width: 0; border: 0; background: none; color: var(--text);
  font: 600 1.02rem var(--sans); outline: none;
}
.search-input input::placeholder { color: var(--faint); font-weight: 500; }
.search-input kbd {
  font: 700 .7rem var(--sans); border: 1px solid var(--border);
  border-radius: 5px; padding: 2px 6px; color: var(--faint);
}
.search-results { max-height: min(56vh, 460px); overflow-y: auto; padding: 8px; }
.search-results a {
  display: flex; gap: 12px; align-items: center; padding: 9px 11px;
  border-radius: 12px; color: inherit;
}
.search-results a:hover, .search-results a.cur { background: var(--bg-tint); text-decoration: none; }
.search-results img {
  width: 42px; height: 42px; border-radius: 9px; object-fit: cover; flex: none;
  background: var(--bg-tint); border: 1px solid var(--border);
}
/* the wordmark used for news rows is 164x61 -- cropping it square leaves an
   unreadable sliver, so letterbox that one instead */
.search-results img.pad {
  object-fit: contain; padding: 5px; background: var(--bg-tint);
}
.search-results .rmeta { min-width: 0; flex: 1; }
.search-results strong { display: block; font-size: .9rem; letter-spacing: -.02em;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.search-results small { color: var(--faint); font-size: .77rem; font-weight: 600; }
.search-empty { padding: 30px 20px; text-align: center; color: var(--faint); font-size: .9rem; }

/* --------------------------------------------------------------- filter -- */
.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 20px; }
.filter {
  display: flex; align-items: center; gap: 9px; flex: 1; min-width: 220px;
  padding: 10px 14px; border-radius: 999px;
  background: var(--surface); border: 1px solid var(--border);
}
.filter .i { color: var(--faint); width: 18px; height: 18px; }
.filter input { flex: 1; min-width: 0; border: 0; background: none; color: var(--text);
  font: 500 .92rem var(--sans); outline: none; }
.count { color: var(--faint); font-size: .85rem; font-weight: 600; white-space: nowrap; }

.empty { padding: 60px 20px; text-align: center; color: var(--faint); }

/* ------------------------------------------------------------ responsive - */
@media (max-width: 1023px) {
  .app { grid-template-columns: 1fr; }
  .sidebar { display: none; }

  .topbar {
    position: sticky; top: 0; z-index: 40;
    display: flex; align-items: center; gap: 12px;
    padding: 10px 16px; padding-top: max(10px, env(safe-area-inset-top));
    background: color-mix(in srgb, var(--surface) 82%, transparent);
    backdrop-filter: blur(16px) saturate(1.4);
    border-bottom: 1px solid var(--border);
    /* --topbar-offset is written every scroll frame (app.js, the "boot"
       section) -- no transition here on purpose, so it tracks the finger
       or wheel 1:1 instead of easing toward wherever it last was. */
    transform: translateY(var(--topbar-offset, 0px));
  }
  /* The bar's height comes from its tallest item, and that is the icon buttons
     -- 36px here, not the 38px they take on desktop (see below). At the true
     177:58 ratio the logo hits 36px at 109.86px wide, so 109px is the last
     whole pixel that fits. Only about 2px of headroom existed above the
     original 108px; most of the size gain here comes from no longer squashing
     the image into a wrong aspect ratio. */
  .brand-sm .brand-logo { width: 109px; }
  /* the top bar owns the top of the viewport here, so the year strip parks
     underneath it rather than on top -- 36px button plus its 10px padding,
     the border, and whatever notch the device reports. + var(--topbar-offset)
     tracks it up in step as the top bar itself hides on scroll (app.js) --
     without that, this reserved gap stayed put even once the bar it was
     reserved for had slid away, leaving a blank strip above the timeline. */
  .timeline { top: calc(47px + max(10px, env(safe-area-inset-top)) + var(--topbar-offset, 0px)); }
  .topbar-actions { margin-left: auto; display: flex; gap: 8px; }
  .icon-btn { width: 36px; height: 36px; }

  #view { padding: 20px 16px calc(92px + env(safe-area-inset-bottom)); }

  /* The hero is the very top of the page, so on a phone it reads better
     breaking all the way out to the screen edges and sitting flush
     against the top bar above it, instead of sitting inside #view's
     usual gutter the way every other section here does. Negative
     margins matching that gutter exactly (20px top, 16px sides) pull it
     back out; the rounded corners go with it, since a rounded corner
     sitting flush against the viewport's own edge just reads as a
     mistake. */
  .hero { margin: -20px -16px 0; border-radius: 0; }

  .tile.only-mobile { display: flex; }
  .crumb.only-mobile { display: inline-flex; }

  .tabbar {
    position: fixed; bottom: 0; left: 0; right: 0; z-index: 40;
    display: grid; grid-auto-flow: column; grid-auto-columns: 1fr;
    padding: 6px 4px; padding-bottom: max(6px, env(safe-area-inset-bottom));
    background: color-mix(in srgb, var(--surface) 88%, transparent);
    backdrop-filter: blur(18px) saturate(1.4);
    border-top: 1px solid var(--border);
  }
  .tabbar a {
    display: flex; flex-direction: column; align-items: center; gap: 3px;
    padding: 7px 2px 5px; border-radius: 12px;
    color: var(--faint); font-size: .66rem; font-weight: 700; letter-spacing: -.01em;
  }
  .tabbar a:hover { text-decoration: none; }
  .tabbar a.on { color: var(--accent); }
  .tabbar a .i { width: 22px; height: 22px; }
  .tabbar a .tag { display: none; }

  .grid.photos { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 9px; }
  .grid.albums { grid-template-columns: repeat(auto-fill, minmax(158px, 1fr)); gap: 12px; }
  .grid.people { grid-template-columns: repeat(auto-fill, minmax(138px, 1fr)); gap: 12px; }
  .lb-nav { width: 40px; }
  .lb-nav .i { width: 24px; height: 24px; }
  .lb-stage { padding: 56px 2px 20px; }
  .modal-head { padding: 16px 16px 12px; }
  .modal-body { padding: 0 16px 16px; }
}

@media (max-width: 460px) {
  .grid.photos { grid-template-columns: repeat(2, 1fr); }
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation-duration: .001ms !important; transition-duration: .001ms !important; }
}

/* a search hit lands on its post -- flash it so it is obvious which one */
.post.flash {
  animation: post-flash 1.6s var(--ease);
}
@keyframes post-flash {
  0%, 12% { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft), var(--sh-2); }
  100%    { border-color: var(--border); box-shadow: 0 0 0 0 transparent, var(--sh-1); }
}

/* --------------------------------------------------------------- contact -- */
.contact-wrap { max-width: 660px; }
/* position:relative scopes the off-screen honeypot below to the form rather
   than the viewport */
.contact-form { position: relative; display: block; margin-top: 26px; padding: clamp(20px, 3vw, 30px); }
.contact-form .hp { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }

.field { display: block; margin-bottom: 18px; }
.field > span {
  display: block; margin-bottom: 7px;
  font-size: .8rem; font-weight: 800; letter-spacing: .05em;
  text-transform: uppercase; color: var(--faint);
}
.field input, .field textarea {
  width: 100%; padding: 12px 14px; border-radius: var(--r-sm);
  background: var(--surface-2); border: 1px solid var(--border-2);
  color: var(--text); font: 500 .96rem var(--sans); resize: vertical;
  transition: border-color .16s, background .16s;
}
.field input:focus, .field textarea:focus {
  outline: none; border-color: var(--accent); background: var(--surface);
}
.field input::placeholder, .field textarea::placeholder { color: var(--faint); }
.counter {
  display: block; margin-top: 6px; text-align: right;
  font-size: .74rem; font-weight: 600; font-style: normal; color: var(--faint);
  font-variant-numeric: tabular-nums;
}

.form-status:empty { display: none; }
.form-status {
  margin: 4px 0 16px; padding: 11px 14px; border-radius: var(--r-sm);
  font-size: .89rem; font-weight: 600; line-height: 1.45;
}
.form-status.is-error {
  background: var(--accent-soft); color: var(--accent);
  border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
}
.form-status.is-success {
  background: color-mix(in srgb, var(--teal) 12%, transparent); color: var(--teal);
  border: 1px solid color-mix(in srgb, var(--teal) 35%, transparent);
}
.contact-form .hero-cta { margin-top: 4px; }
.contact-form button[disabled] { opacity: .6; cursor: default; transform: none; }

/* ----------------------------------------------------------------- about -- */
.about-hero {
  border-radius: var(--r-xl); padding: 2px; background: var(--grad);
  box-shadow: var(--sh-2); overflow: hidden;
}
.about-hero-in {
  border-radius: calc(var(--r-xl) - 2px); background: var(--surface);
  background-image: var(--grad-soft);
  padding: clamp(22px, 3.4vw, 40px);
  display: flex; flex-direction: column;   /* align-items stays the stretch
    default: every child fills the panel's width, so the heading and lede
    always wrap against a true, reliable boundary. align-items:flex-start
    here looked identical while Plus Jakarta Sans was the rendered font, but
    it hands h1/p a fit-content box instead of a stretched one -- sized by
    whatever font actually measured them, wider under a fallback face -- and
    .about-hero's overflow:hidden then guillotines mid-word instead of
    wrapping. Only .about-mark should size to its own content; it opts out
    below rather than the whole panel opting in. */
  gap: clamp(16px, 2.6vw, 22px);
}
/* The mark is a wordmark, wide rather than square, so it sits above the
   heading as a small banner instead of beside it in the square badge's old
   slot -- forcing a 3.2:1 shape into a portrait side-column just left dead
   space either side of it. align-self opts this one child out of the
   stretch above; it is the only thing in this panel that should be sized by
   its own content instead of the panel's width. */
.about-mark { display: block; align-self: flex-start; filter: drop-shadow(0 10px 22px rgba(0, 0, 0, .18)); }
.about-logo { display: block; width: clamp(200px, 32vw, 360px); height: auto; }

/* how it got from there to here -------------------------------------- */
/* A real chronology, so a timeline is the right structural device here in a
   way it would not be decoration everywhere else. The connecting line is
   .ms-line, a single absolutely-positioned element rather than a border on
   each dot, so it reads as one continuous thread instead of four
   coincidentally-aligned fragments. It is also the first child, which is
   what lets the dots after it paint on top without any z-index at all --
   plain DOM order among siblings that share the default z-index:auto. */
.milestones {
  position: relative; margin-top: 48px;
  display: grid; grid-template-columns: repeat(4, 1fr); gap: 18px;
}
.ms-line {
  position: absolute; top: 6px; left: 12.5%; right: 12.5%; height: 2px;
  background: var(--border);
}
.milestone { position: relative; padding-top: 30px; text-align: center; }
.milestone .ms-dot {
  position: absolute; top: 0; left: 50%; transform: translateX(-50%);
  width: 14px; height: 14px; border-radius: 50%;
  background: var(--grad); box-shadow: 0 0 0 5px var(--surface);
}
.milestone b { display: block; font-size: 1.25rem; font-weight: 800; letter-spacing: -.02em; }
.milestone small {
  display: block; font-weight: 700; color: var(--accent); font-size: .78rem;
  text-transform: uppercase; letter-spacing: .04em; margin: 2px 0 8px;
}
.milestone p { color: var(--dim); font-size: .87rem; line-height: 1.5; }

/* Four columns of prose gets cramped well before the sidebar itself has any
   trouble, so this switches on its own breakpoint rather than piggybacking
   on the nav's. The line rotates from a bar under the dots to a rail beside
   them -- the standard shape a timeline takes once it has to read top to
   bottom instead of left to right. */
@media (max-width: 720px) {
  .milestones { grid-template-columns: 1fr; gap: 26px; padding-left: 28px; }
  .ms-line { top: 6px; bottom: 6px; left: 5px; right: auto; width: 2px; height: auto; }
  .milestone { text-align: left; padding-top: 0; }
  .milestone .ms-dot { top: 3px; left: -28px; transform: none; }
}

/* the original prose, with a drop cap on the opening paragraph */
.prose.drop > p:first-child::first-letter {
  float: left; font-size: 3.1rem; line-height: .82; font-weight: 800;
  padding: 4px 10px 0 0; color: var(--accent);
}

/* Jordan's note, set as a letter rather than body copy */
.letter {
  margin: 0; padding: clamp(20px, 3vw, 30px); max-width: 68ch;
  background: var(--surface); border: 1px solid var(--border);
  border-left: 3px solid var(--accent);
  border-radius: var(--r-lg); box-shadow: var(--sh-1);
}
.letter p { color: var(--dim); margin-bottom: 13px; }
.letter p:last-of-type { margin-bottom: 0; }
.letter figcaption {
  margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border);
  font-weight: 800; letter-spacing: -.02em; color: var(--text);
}

/* -------------------------------------------------------------- guestbook -- */
.guestbook-form { max-width: 620px; }

/* Matches .feed's own max-width below it, so "Admin" lines up with the
   right edge of the cards it actually controls instead of stretching out
   to the full .wrap width -- which reads as orphaned on a wide desktop. */
.guestbook-toolbar {
  display: flex; align-items: flex-start; justify-content: space-between;
  gap: 14px; flex-wrap: wrap; margin: 36px 0 18px; max-width: 760px;
}
.gb-count { color: var(--dim); font-size: .88rem; font-weight: 600; padding-top: 9px; }
.gb-admin { display: flex; align-items: flex-start; gap: 10px; }

/* Fixed and positioned from the gear button's own rect (see positionPopover()
   in mountGuestbook) rather than sitting inline in the toolbar's flex row --
   an inline popup there wraps and squeezes depending on how much room the
   row has left, which read as ugly and off-centre. Same reasoning as the
   color-lab panel elsewhere on the site, just anchored downward instead of
   up, since this button sits near the top of the page rather than the
   bottom of the sidebar. */
.gb-login-popover {
  position: fixed; z-index: 70; width: 260px; max-width: calc(100vw - 24px);
  padding: 18px; box-shadow: var(--sh-3);
}
.gb-login-popover .field { margin-bottom: 14px; }
.gb-login-popover .form-status { margin-bottom: 12px; }
.gb-login-popover .hero-cta { margin-top: 0; }

.btn-sm { padding: 8px 15px; font-size: .82rem; }

/* entries reuse .post/.post-head/.post-body from the news feed -- the same
   card language, just with a name where a date used to be the headline */
.gb-entry .post-head { flex-wrap: wrap; row-gap: 8px; }
.gb-meta { margin-left: auto; display: flex; align-items: center; gap: 10px; }
.gb-ip { font-size: .72rem; color: var(--faint); font-variant-numeric: tabular-nums; }
.gb-del {
  font-size: .78rem; font-weight: 700; color: var(--faint);
  background: none; border: none; cursor: pointer; padding: 2px 4px;
}
.gb-del:hover { color: var(--accent); }

/* answers to questions the old guestbook asked that this one does not --
   kept on the migrated entries that have them, set off from the message
   proper so it clearly reads as an old artifact rather than part of it */
.gb-legacy { margin-top: 14px; padding-top: 12px; border-top: 1px dashed var(--border); }
/* One question per row rather than the previous two-column grid -- packing
   a short label and a possibly-long answer (a whole email address, a URL)
   side by side left both cramped. Label/value contrast matches .fact's own
   dt/dd elsewhere on the site (the profile "facts" cards) on purpose, same
   small-uppercase-label-above-bold-value idea, so this reads the same way
   the moment it looks familiar. */
.gb-legacy dl { display: flex; flex-direction: column; gap: 12px; margin: 0; }
.gb-legacy dt {
  font-size: .7rem; font-weight: 800; letter-spacing: .07em;
  text-transform: uppercase; color: var(--faint); margin-bottom: 3px;
}
.gb-legacy dd { margin: 0; color: var(--text); font-weight: 600; font-size: .9rem;
  letter-spacing: -.02em; word-break: break-word; }

.gb-actions { margin-top: 14px; }
/* narrower here than a standalone page -- the padding/margin .contact-form
   normally sets would read as a gap in the middle of a post, not a form */
.gb-reply-form.contact-form { margin-top: 14px; padding: 0; }

.gb-replies {
  margin-top: 16px; padding-left: 22px; border-left: 2px solid var(--border);
  display: flex; flex-direction: column; gap: 12px;
}
.gb-reply {
  padding: 14px 16px; border-radius: var(--r-sm);
  background: var(--surface-2); border: 1px solid var(--border);
}
.gb-reply .post-head .dot { width: 28px; height: 28px; }
.gb-reply .post-head .dot .i { width: 14px; height: 14px; }
