/* ==========================================================================
   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;

  /* On request ("too much purple/pink") -- c3 was violet, and c2/accent
     both sat closer to magenta than red (hue ~340 deg, verified directly
     rather than eyeballed). All three shifted into the red/crimson family
     instead -- c1 stays the warm coral start, c2 and c3 now read as red
     and deep crimson rather than pink and purple, and --accent (the solid
     colour used everywhere -- nav, pills, links) moved the same ~15-20
     degrees toward true red. */
  --c1: #ff7a4d;   /* coral   */
  --c2: #f5323f;   /* red     */
  --c3: #b21e3d;   /* crimson */
  --accent:      #d6213f;
  --accent-ink:  #ffffff;
  --accent-soft: #fdece8;
  --teal:        #0b8a82;
  --blue:        #1c6dd0;
  --green:       #0a8f5c;
  /* .pill.hot's own text colour (styles.css "generic") -- plain var(--accent)
     here in light mode, same as everywhere else --accent is used. Dark
     mode (below, and :root[data-theme="dark"] / #player) gives this its
     own, less saturated value instead -- on request ("the Lincolner pill,
     and all red pills on the site, are too strong in color in dark mode"):
     the full-strength --accent red as flat pill TEXT against the pill's
     own dark --accent-soft background read as noticeably more vivid/neon
     than the same colour does as a link or a button fill, which is why
     this is its own token rather than a change to --accent itself (that
     would also dim every nav/link/button use no one complained about).
     The mix itself went 70/30, then 52/48, then 36/64 accent/--dim, each
     time on the same follow-up request ("keep going even less strong") --
     still mixed toward --dim, not straight to a flatter grey, so it keeps
     reading as a (quieter) red rather than losing the colour entirely. */
  --pill-hot-ink: var(--accent);
  /* .pill.hot's own background/text colours, everywhere -- plain
     var(--accent-soft)/var(--accent) here in light mode (identical to
     this rule doing nothing at all), same as --pill-hot-ink just above.
     Dark mode (below) points these at its own frozen copies instead of
     --accent/--accent-soft directly, so a "make the red theme less
     strong" request that only means the general-purpose tokens (buttons,
     links, the avatar gradient) has somewhere to land without quietly
     retuning every pill along with it -- see dark mode's own comment. */
  --accent-pill:      var(--accent);
  --accent-soft-pill: var(--accent-soft);

  --grad:      linear-gradient(120deg, var(--c1), var(--c2) 52%, var(--c3));
  /* On request ("the Photo + Video Spotlight box is too similar to the
     actual page background") -- verified directly: both sat at ~95%
     lightness, .featured-block's own wash barely distinguishable from
     --bg's own 94.5% regardless of the hue difference between them,
     since lightness this close to white compresses any hue toward
     looking plain white no matter how saturated it technically is.
     Brought down to ~92-93% lightness instead -- still a soft, quiet
     wash ("just slightly" more visible, on request, not a bold card)
     but no longer within a hair of the page's own tone. */
  --grad-soft: linear-gradient(120deg, #ffe7db, #ffdcd9 52%, #fcdcd9);

  --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);

  /* .hero-wall-clip::after's flat wash (styles.css "hero"): how much of
     var(--bg-tint) gets mixed into the wall. Its own token, not a fixed
     amount baked into that rule, because dark mode wants more of it than
     light mode does -- asked for one without the other, where the whole
     point of using var(--bg-tint) here (see that rule's own comment) is
     that a single shared amount already looks right in both. Light mode's
     own value has come down five times on request now, 42% to 34% to
     22% to 18% to 14% to 10% -- the wash toward bg-tint's light colour
     kept reading as too washed-out/faded, so this is toned back further
     still toward the actual photos underneath. Dark mode (below) is its
     own separate token and was last left at 38%, untouched this round. */
  --hero-wash-pct: 10%;
  /* .hero-profile's glass background (styles.css "the profile card"): how
     much var(--surface) gets mixed into it, the rest transparent -- was a
     flat 39% shared by both themes until light mode asked to be less
     see-through specifically, so this became its own token the same way
     --hero-wash-pct already had to; dark mode (below) keeps the original
     39% it was already fine at. Nudged up again on request each time it's
     still read as too see-through: 39% to 48% to 56%, now 66%. */
  --hero-profile-bg-pct: 66%;
  /* .pill.grey's background (styles.css "generic"): how much var(--text)
     gets mixed into var(--surface-2). Its own token, not a fixed amount
     for both themes, because light mode's came out a touch darker than
     wanted at the shared value dark mode is fine at. */
  --pill-grey-pct: 10%;
  /* .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 amount every other theme was fine
     with, so this is its own token rather than a shared one. Both this and
     the dark-mode values below have been nudged transparent several times
     on request, then dialed slightly back up once that went a bit too far. */
  --play-disc-bg: color-mix(in srgb, var(--surface) 10%, 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;
    /* On request ("a lot of the theme stays the exact same red when you
       switch to dark mode") -- c1/c2/c3 (and --grad, built from them)
       were never re-pointed here at all before, unlike --accent/
       --accent-soft/--grad-soft just below, which already had their own
       dark values. Every var(--grad) surface (buttons, the "solid"
       pill, avatar rings, active tabs...) rendered pixel-identical in
       both themes as a result.
       Since narrowed further ("make the colored buttons fit the ruby
       theme more") -- the first pass here kept light mode's own hues
       (a coral c1 fading through red to crimson), which put a visibly
       orange stop at one end next to the now-ruby --accent right below.
       This drops the orange entirely: all three stops sit in the same
       tight, deep-red hue band --accent itself uses (~344-355deg),
       stepping from a brighter ruby highlight (c1) down to a deep ruby
       shadow (c3) rather than spanning hues. Light mode's own c1/c2/c3
       (top of file) are untouched -- this is dark mode only. */
    /* Dark mode's whole red family, "cherry" -- a deliberately different
       direction from the previous "ruby" pass (jewel-toned, magenta-
       leaning, ~350deg hue, capped at ~55% lightness even at its
       brightest) rather than a tweak of it: warmer and truer-red (hue
       0-358deg, barely past pure red rather than leaning magenta),
       noticeably more saturated (85-90% throughout, ruby topped out
       around 78%) and pushed brighter at the top end (c1 at 60%
       lightness vs ruby's 50%) -- reads as vivid and energetic rather
       than deep and moody, closer to a classic saturated red than a
       gemstone. Every token below (c1/2/3, accent, accent-soft,
       grad-soft) moved together to keep the same internal consistency
       ruby had -- one hue family throughout, soft/shadow tokens re-
       derived from accent's own hue rather than left at an old value.
       Light mode is untouched either way -- this is dark mode only.
       The full ruby state (exact hex values, the reasoning that led to
       each one) is saved in memory to restore on request.

       c1/c2/c3/accent nudged down again on a follow-up request ("make the
       red theme slightly less strong"), each mixed about 10% toward
       --dim -- accent-soft and grad-soft (below) are already a quiet,
       muted backing tone rather than the vivid part anyone would read as
       "strong," so both stayed put. --accent-pill/--accent-soft-pill
       (just below) hold the exact pre-this-request values instead, frozen
       on the same request ("do not modify the color of any red pills") --
       see their own comment for why the pills need their own copies
       rather than continuing to read var(--accent)/var(--accent-soft)
       directly. */
    --c1: #ef4f48;
    --c2: #e72935;
    --c3: #b52441;
    --accent:      #ea343f;
    --accent-ink:  #24040f;
    --accent-soft: #361618;
    /* Every .pill.hot's own colours, pinned to exactly what --accent/
       --accent-soft used to be, so a future "make the red theme..."
       request (like the one that just detached these) can keep tuning
       the general-purpose tokens without quietly retuning every pill on
       the site along with them -- on request ("do not modify the color
       of any red pills, they are all OK as is now"). */
    --accent-pill:      #f12731;
    --accent-soft-pill: #361618;
    /* On request ("too strong in color") -- see --pill-hot-ink's own
       comment (top of file) for why this is its own token rather than a
       change to --accent itself. Reads from --accent-pill, not --accent,
       since this same request explicitly excluded pill colours. */
    --pill-hot-ink: color-mix(in srgb, var(--accent-pill) 36%, var(--dim));
    --teal:        #37d6cb;
    --blue:        #4d9fff;
    --green:       #34d399;
    --grad-soft: linear-gradient(120deg, #2b1817, #271617 52%, #221416);
    --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);
    /* Down from 55%, then 46%, both on request ("slightly less dark");
       see --hero-wash-pct's own comment (top of file) for the light-mode
       half of this same round. */
    --hero-wash-pct: 38%;
    --hero-profile-bg-pct: 39%;
    --pill-grey-pct: 15%;
    --glass-rim: rgba(255, 255, 255, .14);
    --play-disc-bg: color-mix(in srgb, var(--surface) 18%, 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;
  /* Same "cherry" red family as the prefers-color-scheme block above --
     see its own comment. */
  --c1: #ef4f48;
  --c2: #e72935;
  --c3: #b52441;
  --accent:      #ea343f;
  --accent-ink:  #24040f;
  --accent-soft: #361618;
  --accent-pill:      #f12731;
  --accent-soft-pill: #361618;
  --pill-hot-ink: color-mix(in srgb, var(--accent-pill) 36%, var(--dim));
  --teal:        #37d6cb;
  --blue:        #4d9fff;
  --green:       #34d399;
  --grad-soft: linear-gradient(120deg, #2b1817, #271617 52%, #221416);
  --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);
  /* Same dark-mode reduction as the prefers-color-scheme block above --
     see its own comment. */
  --hero-wash-pct: 38%;
  --hero-profile-bg-pct: 39%;
  --pill-grey-pct: 15%;
  --glass-rim: rgba(255, 255, 255, .14);
  --play-disc-bg: color-mix(in srgb, var(--surface) 18%, 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);
}

/* ?bg=white (app.js, "embed" -- see html[data-bg] there): a narrow,
   deliberate exception for one specific kind of shared link
   (?navbar=hidden&theme=light&bg=white#/guestbook/view and the like),
   not a real third theme -- only --bg changes, so body and
   .safe-area-fill (the only two rules that read it) go pure white
   instead of the usual light-mode --bg (#f4f1ee), while every card,
   border and tinted panel stays exactly as light mode already designed
   them (--surface was already #ffffff; only the canvas behind it
   wasn't). Compound attribute selector requires the explicit
   ?theme=light to have actually resolved to data-theme="light" first --
   ?bg=white alone, or paired with a dark/OS-dark page, does nothing. */
:root[data-theme="light"][data-bg="white"] { --bg: #ffffff; }

/* --------------------------------------------------------------- reset --- */
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; overflow-anchor: none; }

/* 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;
  /* Scroll anchoring fighting wireHeroEngulf() (app.js) is a repeat
     offender at this point -- .hero's own transform tripped it once
     already (overflow-anchor:none there fixed that specific case), then
     giving .hero-profile a second transform component (translateY
     alongside its existing scale, on request) tripped it again even with
     overflow-anchor:none already sitting on .hero-profile itself,
     confirmed directly: scrollY drifting on its own, unprompted, no scroll
     or hashchange event behind it, moments after any reload of this page.
     Rather than hunting down and annotating every element a future tweak
     here might make the browser pick as its next anchor candidate, this
     turns the whole feature off for the document's own scrolling box --
     nothing on this page depends on scroll anchoring's actual intended
     benefit (holding your place while async content loads above you) in
     any way that'd be missed. */
  overflow-anchor: none;
  /* Shifts everything below the fixed .offline-banner down by exactly its
     own height (app.js sets --offline-h whenever the banner's shown state
     changes; 0px, the default, the rest of the time) -- see the comment
     on .offline-banner, above. */
  padding-top: var(--offline-h, 0px);
  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; }
/* focusWithoutRing() (app.js) -- the lightbox/player close button lands
   keyboard focus the instant the dialog opens, not from an actual Tab
   press; removed again the moment focus actually leaves (that function's
   own one-time blur listener), so a real, later Tab back onto the same
   button still rings normally. */
.no-auto-ring:focus-visible { outline: none; }

.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); }
/* ?navbar=hidden (app.js, "embed") for linking a page into an <iframe>
   elsewhere -- html[data-chrome="hidden"] is set once, at boot, straight
   off that query param. Two class selectors here (0,2,0) beats every
   plain .sidebar/.topbar/.tabbar/.app rule these override, .sidebar's and
   .app's own included (both 0,1,0) -- true at any viewport width, so this
   doesn't also need repeating inside the responsive section further down
   just because that's where .sidebar/.topbar/.tabbar's own display
   normally changes hands between desktop and mobile. */
html[data-chrome="hidden"] .sidebar,
html[data-chrome="hidden"] .topbar,
html[data-chrome="hidden"] .tabbar { display: none; }
html[data-chrome="hidden"] .app { grid-template-columns: 1fr; }

/* index.html's own static markup inside #view, wiped out by the first
   route() call (app.js) -- see the comment there for why this doesn't need
   its own removal logic. Sized to read as a real, deliberate loading
   state rather than an empty page that just happens to have a spinner on
   it -- min-height keeps it from collapsing to the spinner's own tiny box
   before anything else in the page has laid out. */
.boot-loader {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 22px; min-height: 70vh; padding: 40px 16px;
}
.boot-loader-logo { width: 140px; height: auto; opacity: .85; }
.boot-loader-spin {
  width: 26px; height: 26px; border-radius: 50%; flex: none;
  border: 3px solid var(--border); border-top-color: var(--accent);
  animation: boot-spin .8s linear infinite;
}
@keyframes boot-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
  .boot-loader-spin { animation: none; }
}

/* Fixed and pinned for as long as it shows, on purpose -- unlike a one-time
   toast, "you're offline" stays true until it isn't, so it stays visible
   through scrolling rather than being seen once and then scrolling away.
   That means it can't just push .topbar/.sidebar down through normal
   document flow (tried first) -- fixed content doesn't push anything, and
   naively giving it flow height instead broke position:sticky on both of
   those entirely (top:0 stopped sticking the moment the banner had any
   height, a grid+sticky interaction that wasn't worth chasing further).
   app.js measures this element's real rendered height and writes it to
   --offline-h on <html> instead, every time the banner's own hidden state
   changes; .sidebar/.topbar's own top offsets and body's padding-top
   (below) read that variable to shift down by exactly that amount, and
   collapse back to 0 the moment it hides. */
.offline-banner {
  position: fixed; top: 0; left: 0; right: 0; z-index: 95;
  display: flex; align-items: center; justify-content: center; gap: 8px;
  padding: 10px 16px; padding-top: max(10px, env(safe-area-inset-top));
  background: var(--surface); color: var(--text); border-bottom: 1px solid var(--border);
  box-shadow: var(--sh-1); font-size: .85rem; font-weight: 600; text-align: center;
}
.offline-banner::before {
  content: ""; width: 8px; height: 8px; border-radius: 50%; flex: none;
  background: #e0a030;
}

.sidebar {
  /* var(--offline-h): see .offline-banner's own comment, above. */
  position: sticky; top: var(--offline-h, 0px); align-self: start;
  /* calc, not a flat 100vh -- with top already offset down by the same
     amount (above), an unadjusted 100vh would run exactly --offline-h past
     the bottom of the viewport. */
  height: calc(100vh - var(--offline-h, 0px)); padding: 16px 18px 20px;
  display: flex; flex-direction: column; gap: 22px;
  border-right: 1px solid var(--border);
  background: var(--surface);
}

/* position:relative -- positions the logo-shimmer overlay below (.brand::after) */
.brand { display: flex; align-items: center; gap: 12px; color: inherit; position: relative; }
.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), filter .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; } }

/* Scale, same as everything else here (nothing shifts POSITION on hover
   anywhere on this site, since an element that moves under the cursor can
   slide its own hit box away and chatter between states) -- plus a glow
   now, on request. drop-shadow, not box-shadow: this is a wordmark on a
   transparent background, most of its own box empty space, so a shadow
   shaped like the box instead of the artwork itself would mostly just
   glow in the gaps between letters. Two stacked, a tighter/brighter one
   behind a wider/softer one, for a bit of depth instead of one flat
   ring -- same "blur is the lever for dispersed, not spread" idea as the
   spotlight avatar's own hover-glow, translated to the filter this
   actually needs given the image it's applied to has no solid edge for
   spread to measure from in the first place.

   Two things to keep in mind about the transform part. It 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. filter doesn't need that
   same repeating -- .sidebar's own version below only restates
   transform, so filter here just falls through from this rule
   regardless of which one wins. */
.brand:hover .brand-logo,
.brand:focus-visible .brand-logo {
  transform: scale(1.03);
  /* Reported too strong three rounds running (6px/18px @ .55/.3, then
     12px/30px @ .35/.18) -- each pass had only trimmed alpha a little
     while growing blur for "dispersed," so brightness never actually
     dropped much. This time alpha takes the real cut (roughly 60% down
     on both layers) and the radii come down only slightly, so it stays
     dispersed but reads as genuinely faint rather than incrementally
     less strong. */
  filter: drop-shadow(0 0 10px rgba(255, 255, 255, .14)) drop-shadow(0 0 24px rgba(255, 255, 255, .06));
}
.sidebar .brand:hover .brand-logo,
.sidebar .brand:focus-visible .brand-logo {
  transform: translateX(var(--nudge)) scale(1.03);
}
/* Logo shimmer -- a soft diagonal band of light swept across the
   wordmark's own silhouette, triggered a couple of seconds after load
   and again every 60s (fireLogoShimmer(), app.js) rather than looping
   the way .sheen-bar (above) does: this one's meant to read as an
   occasional flourish, not constant motion.

   .brand-logo is a plain <img src=...svg> here, unlike the About page's
   own wordmark (comment above .sheen-bar) -- deliberately not inlined,
   since that ~110KB of traced path data sitting in the DOM on every
   single page just for this would be a bad trade for a effect this
   small. There's no internal path data here to clip-path against
   directly because of that, so instead this masks a moving gradient bar
   by that same SVG file as an external mask image -- same "light moves
   across the letters, not just a rectangle laid over them" result,
   without inlining anything. .brand is this pseudo-element's positioned
   ancestor -- it has no padding/border of its own (above), so its box
   already matches .brand-logo's exactly, nothing extra needed to line
   the two up. */
.brand::after {
  content: ""; position: absolute; inset: 0; pointer-events: none;
  /* opacity:0 here, not just a parked background-position -- belt and
     braces against this ever being visible outside the animation below:
     the mask image (same file the visible <img> already loaded, so
     normally resolved instantly from cache, but not provably always) is
     a separate resource from that <img>, and a mask that hasn't
     resolved yet renders some browsers' masked element unmasked -- a
     plain rectangle of gradient, not clipped to the wordmark -- rather
     than not at all. Opacity 0 makes that moot: nothing paints here at
     all until the animation itself explicitly raises it, parked
     position or not, mask loaded or not. */
  opacity: 0;
  /* no-repeat matters here, not just tidiness -- background-size's 300%
     makes one tile exactly 3x this box's width, and the sweep below
     moves it by exactly that much (150% to -150%). Left to the default
     repeat, any sub-pixel overshoot past that exact distance reveals a
     sliver of the NEXT tile wrapping in right at the tail -- a second,
     much briefer flash immediately after the real sweep, since it's only
     ever the edge of a tile rather than a full pass. That's exactly what
     "happens twice, the second one fast" would look like. */
  background: linear-gradient(100deg, transparent 35%, rgba(255, 255, 255, .85) 50%, transparent 65%) no-repeat;
  background-size: 300% 100%; background-position: 150% 0;
  -webkit-mask-image: url(l4l-logo-main.svg); mask-image: url(l4l-logo-main.svg);
  -webkit-mask-size: contain; mask-size: contain;
  -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
  -webkit-mask-position: center; mask-position: center;
}
/* 8s -- slower still, on request a second time (3.5s, up from the
   original 1.2, still read as too fast). */
.brand.shimmer-play::after { animation: logo-shimmer 8s ease; }
@keyframes logo-shimmer {
  0% { opacity: 0; background-position: 150% 0; }
  15%, 85% { opacity: 1; }
  100% { opacity: 0; background-position: -150% 0; }
}
@media (prefers-reduced-motion: reduce) { .brand.shimmer-play::after { animation: none; } }
.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);
}
/* 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; }
/* Guestbook's own tile (moreTile(), app.js) needs a narrower window than
   the rule above: unlike News/About, Guestbook is ALSO a tab bar entry
   in its own right above 330px (.tabbar a[href="#/guestbook"], below),
   so showing this tile at every width .tile.only-mobile would (up to
   1023px) means the same link twice there. Hidden here by default, the
   narrower media query below only reveals it under 330px, where the tab
   bar drops its own copy and this goes back to being the only way to
   reach it on mobile -- same reasoning as News/About's tiles, just at a
   different cutoff. */
.tile.only-mobile-guestbook { 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; }
/* Guestbook's own crumb (V.guestbook(), app.js) -- exact same reasoning and
   same 330px cutoff as .tile.only-mobile-guestbook, above, just for the
   crumb instead of the tile: above 330px Guestbook is its own tab bar
   entry, reached directly, so a "back to More" link there points at
   somewhere the visitor never actually came from. Was wrongly sharing
   plain .only-mobile with News/About's crumbs (which really do want every
   mobile width, having no tab of their own at any of them) -- reported
   live as the crumb showing even with Guestbook's own tab visible. */
.crumb.only-mobile-guestbook { 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 {
  /* -10px, not +10px -- #view starting BELOW its resting position (positive
     Y) briefly makes its own transformed bounding box taller than its final
     layout height, which is exactly the kind of thing html/body's scrollable
     overflow reacts to even though transforms don't affect normal layout:
     confirmed directly (polling document.documentElement.scrollHeight
     frame-by-frame through the transition), a page with no scrollbar at
     rest measured a real 10px of overflow at the very first frame, decaying
     to 0 as the animation settled -- a real scrollbar, flashed for the
     ~150ms it took to decay, on a page that never actually needed one.
     Starting ABOVE instead (negative Y) settles downward into place instead
     of upward -- same motion, same distance, but every transformed frame
     along the way sits at or above the resting height rather than past it,
     so there's never a taller moment for scrollable overflow to react to. */
  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);
  /* Confirmed directly (rendered this exact background/border/radius
     combo off-DOM and read the actual pixels back): without this, the
     single most-curved pixel at the dead centre of each rounded end --
     the far left and right tips of a pill -- samples var(--grad) wrong,
     as if antialiasing right at that tangent point wraps past the
     gradient's own 0%/100% ends. The left tip comes back tinted like the
     gradient's LAST stop, the right tip like its FIRST, on an otherwise
     flawless gradient everywhere else. Only happens with an actual
     `border` property present, even fully transparent like .btn's own
     reserved one above -- the same gradient/radius with no border
     property at all is fine. padding-box moves where the gradient
     itself gets clipped in by 1px, so that tangent-point pixel falls to
     the (transparent) border instead of the gradient -- transparent
     either way, so there's nothing left for it to get wrong.
     Has to sit here, not on .btn above with the border itself: the
     `background` shorthand resets EVERY sub-property it doesn't
     mention, background-clip included, back to its initial border-box
     -- so a copy on .btn (or .btn-ghost, whose own background is a
     solid colour, hence in no need of it) would just get clobbered
     right back by this rule's own `background:` line, which necessarily
     comes after it to actually paint the gradient at all. */
  background-clip: padding-box;
}
.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-pill); color: var(--pill-hot-ink); }
/* Home and About's own "Since 2003" hero badge (V.home()/V.about(), app.js)
   -- on request, kept at full-strength var(--accent-pill) even in dark
   mode, while every other .pill.hot (kind tags on the Friends grid and the
   Home spotlight card) stays on the softened --pill-hot-ink (its own
   comment, top of file). Reads --accent-pill rather than --accent itself
   for the same reason --pill-hot-ink does now -- see that token's own
   comment. Three classes beats .pill.hot's two on specificity alone, no
   !important needed. */
.pill.hot.pill-hero { color: var(--accent-pill); }
.pill.teal  { background: color-mix(in srgb, var(--teal) 14%, transparent); color: var(--teal); }
.pill.blue  { background: color-mix(in srgb, var(--blue) 14%, transparent); color: var(--blue); }
.pill.green { background: color-mix(in srgb, var(--green) 14%, transparent); color: var(--green); }
.pill.solid { background: var(--grad); color: #fff; }
/* A second, deliberately still-grey option, distinct from the bare .pill's
   own default (flat var(--bg-tint)) rather than a colour -- two pills side
   by side (styles.css "The Lincolners and friends" spotlight) reading as
   the same tag twice, without introducing a hue neither tag's info
   actually calls for. Mixed toward var(--text), not a fixed grey hex or a
   flat var(--surface-2): --text is always the theme's own far end of
   contrast from its surfaces (near-black in light mode, near-white in
   dark), so mixing a bit of it into --surface-2 darkens the chip in light
   mode and lightens it in dark mode automatically -- one rule that inverts
   itself correctly either way, rather than two opposite hardcoded colours
   to keep in sync by hand. */
.pill.grey {
  background: color-mix(in srgb, var(--text) var(--pill-grey-pct), var(--surface-2));
  color: var(--dim); border: 1px solid var(--border);
}
.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; }
/* On request, more breathing room between this page's own sections
   specifically -- .wrap.about (V.about(), app.js), not a change to the
   shared, site-wide 44px default every other page keeps. Went 72 -> 56
   -> 48 -> 42 -> 36 -> 42, easing down in steps on request then back up
   one step once 36 turned out to be one step too far -- now under the
   site-wide 44px default itself, which is fine; this page asked for its
   own amount, not to stay above everyone else's. .milestones (below)
   gets the same value: it doesn't use the .section class at all (its
   own bespoke block, between the masthead and "A note from Jordan"),
   but reads as one more section break in the same run, so it'd look
   inconsistent left at a different amount than these. */
.about .section { margin-top: 42px; }
/* max-width on request -- lines the timeline up with "A note from Jordan"
   (.letter) and the other 68ch-capped sections further down the same
   page, rather than the full .wrap width it ran before. */
.about .milestones { margin-top: 42px; max-width: 68ch; }
.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.
   No hairline between them (removed on request) -- the spacing alone reads
   as separate sections without a rule doing it too. Went 30 -> 40 -> 24 for
   a tighter, feed-like pace, then on request brought back up to 56, then
   eased down again alongside it to 48, 42, 36, and back up to 42 once 36
   turned out to be one step too far -- matching the About page's own
   between-section gap (.about .section, above) each time it moves, since
   this is the same rule the About page's spacing comes from too (Scoped
   to .home only because that's the one page still asking for its own
   amount; without it every other page sharing this same section() helper
   would move too). Doesn't touch the hero-to-Featured gap, on request
   specifically kept as-is through all of this -- that gap is
   .featured-block's own margin-top (below), a separate rule this was
   never going to reach anyway, since .featured-block sits outside any
   .section itself (a .featured-block .section right inside it gets
   margin-top:0, below, specifically so this rule's spacing couldn't
   double up with that outer one). */
.home .section { margin-top: 42px; }
/* flex + a growing ::after, not a fixed-width rule -- .section-head is
   already a flex row (h2, then .more-link pushed to its far end by that
   link's own margin-left:auto), so making h2 flex:1 lets it claim
   whatever space isn't the more-link's, and the trailing rule (below)
   claim whatever isn't the title text's, however wide either happens to
   be for any given heading or viewport. min-width:0 is the usual
   flex-item fix for the title text not to fight that shrink/grow with
   its own default auto min-width. */
.home .section-head h2 {
  font-size: clamp(1.08rem, 2.2vw, 1.28rem);
  flex: 1; display: flex; align-items: center; gap: 12px; min-width: 0;
}
/* The trailing rule itself -- begins right after the title (the gap,
   above), sits on the text's own vertical centre (align-items:center,
   above, on a 1px-tall line), and fades to nothing rather than ending on
   a hard edge, which is what keeps something this plain from reading as
   a stray underline. var(--border) is already this site's subtle-
   divider colour (panel borders, etc.), so this needs no colour of its
   own to stay correct in both themes. Excluded on the Featured
   carousel's own heading (.featured-block override, below) -- that one's
   already a small uppercase eyebrow label with an icon, not a full
   section title, and reads as cluttered with a rule tacked on too. */
.home .section-head h2::after {
  content: ""; flex: 1; height: 1px;
  background: linear-gradient(to right, var(--border), transparent);
}
.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; }

/* A profile's own "Photos of X" heading, on request: the heading and the
   size toggle always share one row, toggle right-justified, and the
   description always sits alone on its own row under that -- at every
   width, not just narrow ones, so this needs no media query. Both rows
   are flex-basis:100% items of .gallery-head's own row (flex-wrap:wrap
   from .section-head, above) -- a 100%-wide item can never share a line
   with anything else, which is what forces each onto its own line
   unconditionally rather than only once they stop fitting together. */
.gallery-title-row {
  display: flex; align-items: center; gap: 12px; flex: 1 1 100%; min-width: 0;
}
/* Ellipsis is the release valve for a long name plus the toggle at a
   narrow width -- min-width:0 is what actually lets this shrink below its
   own text's natural width in the first place, flex children default to a
   min-width that ignores overflow settings otherwise. flex:1 so it's the
   heading that absorbs any shrinking, not the toggle beside it (that gets
   its own flex:0 0 auto, below, specifically so it never does). */
.gallery-title-row h2 {
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1 1 auto;
}
.gallery-title-row .size-toggle { flex: 0 0 auto; }
.gallery-head p { flex: 1 1 100%; }

.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; }
/* Album hover-preview slideshow (startAlbumPreview(), app.js) -- each frame
   its own <img>, stacked on top of the last and faded in (app.js's own
   paintFrame() comment covers why only ever fading one in, never the
   previous one out, is what actually matters here). z-index: 1 so a locked
   album's corner pill (.album .corner, z-index: 2) would still stay above
   it, same reasoning as .video-art video.preview. */
.shot img.preview {
  position: absolute; inset: 0; width: 100%; height: 100%;
  object-fit: cover; opacity: 0; transition: opacity .35s var(--ease); z-index: 1;
}
.shot img.preview.in { opacity: 1; }
/* .zoomed + --cover-zoom set directly on each preview <img> (app.js), not
   inherited from .shot's own -- the slideshow cycles through photos from
   all over the album (PHOTO_ZOOM) while .shot's own zoom is tuned for just
   the one cover photo (COVER_ZOOM), and those two crops can genuinely
   differ for the same album. */
img.preview.zoomed { transform: scale(var(--cover-zoom, 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, dimmed a bit (see .hero-wall's own filter,
   below) with a vignette on the edges (.hero-wall-clip::after) so the
   welcome card reads as the foreground. 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.
   .featured-block below is its own separate card again, not joined to this
   one -- a flush, squared-corner seam between them (and, before that, an
   overlap plus a fade-to-background gradient) both got tried and reverted
   on request; back to a plain card with its own rounded corners and a gap
   above it, like every other section on this page. */
.hero {
  position: relative; z-index: 0; overflow: hidden; overflow-anchor: none; border-radius: var(--r-xl);
  box-shadow: var(--sh-2); min-height: 420px;
  display: flex; align-items: center; justify-content: center;
}
/* wireHeroEngulf() (app.js) drives .hero's own transform/opacity and
   .wrap.home's scroll position directly against each other -- see that
   function's own comment for the full effect. The z-index here (and
   .wrap.home's, below) is what lets .wrap.home's own solid background
   actually paint over .hero once they visually overlap, rather than .hero
   (position:relative, so it already takes part in stacking, unlike a
   plain static element) winning just because normal-flow painting alone
   would never let a same-context sibling's later DOM order beat an
   explicit z-index difference. */
/* The actual "dark/light background slides up over the hero" surface.
   .wrap.home's own box used to be the centred, max-width:1120px column
   everything else on the site uses -- far narrower than .hero's
   edge-to-edge bleed, so painting the solid colour directly on it would
   have left the hero visible down both sides on anything wider than that
   column. Going full-bleed on .wrap.home itself instead, the exact same
   two rules (just below) .hero already uses to reach #view's real edges,
   fixes that -- and .home-inner (app.js's V.home(), one level further in
   now) is what still gives its own content that original centred column
   back, so nothing here actually reads any different, only what's behind
   it once you scroll. z-index:1 (vs .hero's 0, above) is the only reason
   this paints over it rather than the other way around; background is
   what actually blocks it out once it does. */
.wrap.home {
  position: relative; z-index: 1; max-width: none;
  /* A flat var(--bg) fill used to end in a hard, dead-straight line right
     at .wrap.home's own top edge -- solid colour right up against
     whatever of the wall hadn't been covered yet. Crossfaded instead, on
     request ("blend together", not an actual blur -- tried that first, it
     wasn't it): this gradient is the whole effect now, transparent at the
     very top shading to fully opaque by --home-boundary down, so the
     takeover's leading edge blends into the wall over that span instead
     of cutting across it in one hard line. */
  --home-boundary: 160px;
  background: linear-gradient(to bottom, transparent, var(--bg) var(--home-boundary));
}
@media (min-width: 1024px) {
  .wrap.home { margin: 0 calc(var(--pad) * -1); }
}
/* Restores exactly the padding .wrap.home's own negative margin (above)
   just cancelled, so its actual content sits back where it always did --
   var(--pad) is #view's own side padding at this width; the mobile
   breakpoint (further down) overrides this to that width's own fixed 16px
   the same way .wrap.home's bleed there does too.

   box-sizing:border-box, and max-width padded out to match, so that
   padding comes OUT of the 1120px budget instead of adding on top of it --
   content-box (the default) would have shrunk the actual content down to
   1120 - 2*var(--pad), a real, confirmed regression this caused: the
   Lincolners-and-friends spotlight (spotFit(), app.js) measures its own
   stage's real clientWidth to decide how many peeks fit each side, and
   the narrower box was leaving room for one fewer per side than before
   this whole restructuring. border-box keeps the actual content column
   exactly 1120px, same as .wrap always was, regardless of padding. */
.home-inner {
  max-width: calc(1120px + var(--pad) * 2); box-sizing: border-box;
  margin: 0 auto; padding: 0 var(--pad);
}
/* Full-bleed on the sidebar layout too now, matching the mobile treatment
   further down (styles.css responsive section, "the hero is the very top
   of the page"). #view's own padding is var(--pad) on every side (see
   "shell" above) -- .hero sits directly inside #view rather than nested in
   .wrap (app.js, V.home()) specifically so a negative margin the same size
   as that padding is all this needs, at any window width, rather than a
   viewport-relative calc to reach past .wrap's max-width and centring.
   Square corners for the same reason mobile's version gives it up: a
   rounded corner sitting flush against the viewport's own edge (and,
   here, the sidebar's) reads as a mistake. */
@media (min-width: 1024px) {
  .hero { margin: calc(var(--pad) * -1) calc(var(--pad) * -1) 0; border-radius: 0; }
}
/* Via transparency, not .hero-wall's old brightness() filter -- and toward
   var(--bg-tint) specifically, not a flat dark colour, since the actual
   goal isn't "darker", it's "blends into the page": light mode's bg-tint is
   light, so the same color-mix that darkens the wall in dark mode lightens
   it in light mode, fading it toward whichever background it's actually
   sitting on either way. Two layers: a flat, even wash across the whole
   wall for the base fade, and the same radial-gradient vignette as before
   on top of it, fading the edges further still, all the way to fully
   opaque var(--bg-tint). */
.hero-wall-clip {
  position: absolute; inset: 0; overflow: hidden; background: var(--bg-tint);
}
.hero-wall-clip::after {
  content: ""; position: absolute; inset: 0; z-index: 1; pointer-events: none;
  background:
    radial-gradient(120% 100% at 50% 50%, transparent 22%, var(--bg-tint) 100%),
    color-mix(in srgb, var(--bg-tint) var(--hero-wash-pct), transparent);
}
/* width:max-content, not a fixed percentage -- each row is now built from
   two identical back-to-back copies of its tiles (paintHeroWall(), app.js),
   sized (via that same JS, reading this clip's actual width) to comfortably
   cover any screen on their own, ultrawide monitors included -- a fixed
   tile count came up short there, leaving bare clip on the right where the
   tiles ran out before the row did. max-content lets the row's real,
   measured width win instead of an arbitrary percentage guess. */
.hero-wall {
  position: absolute; top: 0; right: auto; bottom: 0; left: 0; width: max-content;
  display: flex; flex-direction: column; gap: 2px;
  /* 50s here is only a fallback for the rare case paintHeroWall() can't
     measure anything to compute a real one -- normally it sets
     animationDuration itself (app.js, HERO_DRIFT_PX_S), scaled to how wide
     one copy of a row actually is, so the drift keeps the same visual
     speed at any screen width instead of covering more ground in the same
     50s on a wider one. */
  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. */
.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;
}
.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; }
/* Same fix, same variable, as .shot.zoomed .real below -- a handful of
   albums' source scans carry a faint white print-border that object-fit:
   cover alone doesn't reliably crop off (COVER_ZOOM/PHOTO_ZOOM,
   build_data.py; fillHeroWall(), app.js) -- scaling just those tiles up a
   little pushes that border outside the tile's own overflow:hidden edge
   instead. */
.hero-wall .row div img.zoomed { transform: scale(var(--cover-zoom, 1)); }
/* -50%, not the old arbitrary -28.57% -- with each row now built from two
   identical copies of its tiles back to back (paintHeroWall(), app.js),
   -50% is exactly one copy's width, so the loop point lands precisely on
   the seam between two pixel-identical copies instead of snapping back to
   a visibly different start every time it loops. */
@keyframes hero-drift { from { transform: translateX(0); } to { transform: translateX(-50%); } }

/* 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; overflow-anchor: none;
  background: color-mix(in srgb, var(--surface) var(--hero-profile-bg-pct), 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 its own hover-scale affordance (below), so it
   reads as clickable rather than just decorative. */
/* Hidden on request -- rotateHeroAvatar() (app.js) still runs and keeps
   this updated in the background regardless, so restoring it later is
   just switching this one line back to display: block. .hero-profile-main
   (below) already grows to fill the full row on its own (flex:1, with
   nothing left to share the row with), so the only other change hiding
   this needs is centering its own content -- also below, and the exact
   same rule the narrow-phone media query already applied, just no longer
   scoped to only that width. */
.hero-ring {
  display: none; 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; text-align: center; }
.hero-name-row, .hero-counts, .hero-profile .hero-cta { justify-content: center; }
.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: clamp(4px, 1.6vw, 8px); flex-wrap: wrap; margin-bottom: 16px; }
/* Scales down continuously with the viewport, rather than either staying
   full-size until they wrap (four of these easily wrap by phone width) or
   jumping to one smaller fixed size at a single breakpoint -- on request,
   keeps all four on one line at as many widths as actually have room for
   it, four smoothly-shrinking pills rather than a sudden size change or
   an early wrap. white-space:nowrap keeps a pill's own text from wrapping
   internally before the row itself does (flex-wrap, above, stays the
   fallback for whatever width still can't fit all four even shrunk). */
.hero-badges .pill {
  white-space: nowrap;
  font-size: clamp(.6rem, 1.8vw, .74rem);
  padding: clamp(3px, .8vw, 4px) clamp(6px, 1.6vw, 11px);
}
/* 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; }
}
@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 ---- */
/* --tile-min is a plain, instant custom property, not an animated one --
   see flipResize() (app.js) for why: transitioning grid-template-columns
   directly (which an animated --tile-min, registered via @property, was
   built to enable) means reflowing every single card on every frame of
   the transition, not just once -- choppy on a big grid, and on some size
   combinations dropped enough frames that no animation played at all.
   flipResize() gets the same "flow between sizes" a different way,
   animating a transform instead of the layout itself, so this only needs
   to change instantly; the illusion of smoothness is entirely its job now,
   not this rule's. */
.grid { display: grid; gap: 18px; }
.grid.albums, .grid.videos, .grid.photos {
  grid-template-columns: repeat(auto-fill, minmax(var(--tile-min), 1fr));
}
.grid.albums  { --tile-min: 215px; }
.grid.videos  { --tile-min: 275px; }
.grid.people  { grid-template-columns: repeat(auto-fill, minmax(158px, 1fr)); }
.grid.more    { grid-template-columns: repeat(auto-fill, minmax(268px, 1fr)); }
/* The More page's own out-of-the-way way in to #/admin (V.more(), app.js
   -- see that function's own comment on why this exists at all). Real
   distance from the tile grid above it, not just this rule's own
   padding, so it doesn't read as one more tile in that grid -- something
   you'd have to keep scrolling past everything else to even find.
   Faint by default, same color a disabled control or a footnote would
   use, brightening only on hover/focus -- enough of a hint for whoever's
   actually looking for it, nothing that reads as "click me" to anyone
   just passing through. */
.more-admin-corner { display: flex; justify-content: flex-end; margin-top: 64px; }
.more-admin-link {
  display: grid; place-items: center; width: 32px; height: 32px;
  border-radius: 50%; color: var(--faint); opacity: .45;
  transition: opacity .16s, color .16s, background .16s;
}
.more-admin-link:hover, .more-admin-link:focus-visible {
  opacity: 1; color: var(--dim); background: var(--bg-tint);
}
.more-admin-link .i { width: 15px; height: 15px; stroke-width: 2.1; }
.grid.photos  { --tile-min: 178px; gap: 12px; }
/* Grid-size toggle (wireGridSize(), app.js) -- sm/lg widen or narrow the
   same --tile-min the three rules above already feed into their shared
   minmax(). "md" (the default above, untouched) is never repeated here as
   its own rule, since a grid with no data-size -- or one explicitly set
   back to "md" -- should fall through to exactly what was already there.

   auto-fill's own column count, not --tile-min directly, is what a viewer
   actually SEES change -- and once two sizes both still fit the same
   number of columns at .wrap's capped width, 1fr stretches both to that
   same final column width regardless of which --tile-min asked for, so
   the toggle can visibly "do nothing" even though it changed the variable
   correctly. Reported live at .wrap's own 1120px cap: videos' own lg
   (360px) and md (275px) both still fit exactly 3 columns there, landing
   on the identical final width (fixed by widening lg's own gap below,
   past the point a 4th -- well, 3rd, here -- column stops fitting).
   Confirmed directly (getComputedStyle'd gridTemplateColumns before/after)
   that albums' and photos' own sm/md/lg already land on three genuinely
   different column counts at this same width, so neither needed the same
   treatment. */
.grid.albums[data-size="sm"] { --tile-min: 150px; }
.grid.albums[data-size="lg"] { --tile-min: 290px; }
.grid.videos[data-size="sm"] { --tile-min: 195px; }
.grid.videos[data-size="lg"] { --tile-min: 375px; }
.grid.photos[data-size="sm"] { --tile-min: 125px; gap: 9px; }
.grid.photos[data-size="lg"] { --tile-min: 235px; }
/* 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); }
/* The preview slideshow (app.js) only ever shows up during this same hover,
   so it gets the same zoom-in the static cover art would have -- otherwise
   the art would visibly un-zoom the instant the slideshow starts covering
   it. */
.album:hover .shot img.preview { transform: scale(1.04); }
.album:hover .shot img.preview.zoomed { transform: scale(calc(var(--cover-zoom, 1) * 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); margin-top: 1px;
  background: var(--bg-tint); overflow: hidden; display: block;
}
.album-icon img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* --icon-zoom set on the <img> itself (app.js, albumIcon()) -- .album-icon
   is the fixed-size, overflow:hidden frame (same division of labour as
   .shot/.shot .real), so the centered scale() here crops within that frame
   instead of growing the element past its own box. */
.album-icon img.zoomed { transform: scale(var(--icon-zoom, 1)); }
.album-meta { min-width: 0; }
/* A handful of the old site's album titles are genuinely long ("More Random
   Pics & Nick, Amanda, Adams Night Out") -- clamped to 3 lines so one long
   title doesn't stretch its own card noticeably taller than every other
   card in the same grid row (CSS Grid rows all match the tallest cell),
   which is what made it look broken rather than just "a longer name" --
   most visible in the "sm" grid size (the toggle just above), where
   narrower cards wrap more titles in the first place. */
.album-meta strong {
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; line-clamp: 3;
  overflow: hidden; text-overflow: ellipsis;
  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;
}
/* Signed in to the pass-protected set -- same dark scrim, tinted with the
   site's own teal (already how a second, calmer pill colour reads elsewhere,
   .pill.teal) rather than introducing a new hue just for this one badge. */
.album .corner.unlocked {
  background: color-mix(in srgb, var(--teal) 55%, rgba(18, 16, 24, .72));
}

/* 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; }
/* The hover-preview clip (app.js) -- sits over the static poster (.shot,
   above) at the exact same box, z-index 1 so it still sits under .play/.len
   /.badge-rm (2/3 below) rather than covering them: the play button and
   duration staying visible through a playing preview is the point, the
   same way YouTube/Netflix's own hover previews keep their own chrome on
   top of the clip rather than hiding it. */
.video-art video.preview {
  position: absolute; inset: 0; z-index: 1;
  width: 100%; height: 100%; object-fit: cover;
}
.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. Trimmed even lighter (and
   the tint made near-transparent, see --play-disc-bg) on request, so the
   poster shows through almost unobstructed. */
.video .play-disc {
  width: 52px; height: 52px; border-radius: 50%; display: grid; place-items: center;
  background: var(--play-disc-bg);
  backdrop-filter: blur(3px) 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. Fixed
   white regardless of theme, on request -- it used to read off var(--text)
   so it would still show up against the glass disc's dark tint in dark
   mode, but that meant it switched to near-black in light mode too, which
   wasn't wanted. Now that it's always white, the drop-shadow behind it has
   to always be dark for the same contrast job (a light halo behind a white
   glyph would do nothing) -- flat rgba rather than the old theme-matched
   color-mix, since a fixed glyph colour needs a fixed glow to go with it.
   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: #fff;
  fill: currentColor; stroke: none; margin-left: 1px;
  filter: drop-shadow(0 0 6px rgba(0, 0, 0, .55));
}
.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; }
/* On request -- this used to stay its one, full size regardless of grid
   size, which crowded a "sm" card's own poster once the card itself was
   the one actually shrinking. Not scoped to any particular viewport
   width: confirmed live that a width-scoped first attempt at this
   (narrower than "sm" itself is even reachable at -- .size-btn's own
   "sm" option is desktop-only to begin with, min-width 1024px, see
   below) meant it could never actually fire in the one place "sm" can
   ever be chosen and looked at in the first place. Grid size, not
   viewport width, is what actually decides this. */
.grid.videos[data-size="sm"] .badge-rm {
  top: 6px; left: 6px; padding: 3px 8px; font-size: .62rem; gap: 4px;
}
.grid.videos[data-size="sm"] .badge-rm .i { width: 10px; height: 10px; }
/* Confirmed live on an actual phone-width screenshot: below 1023px,
   .size-btn's own "sm" option is hidden (grids media query, below) --
   "md" (2-up) is the smallest grid videos ever renders at on mobile, so
   it's the one that needs this same shrink there, on the exact same
   breakpoint that removes the "sm" button in the first place. Desktop's
   "md" is unaffected -- this only fires under 1023px. */
@media (max-width: 1023px) {
  .grid.videos[data-size="md"] .badge-rm {
    top: 6px; left: 6px; padding: 3px 8px; font-size: .62rem; gap: 4px;
  }
  .grid.videos[data-size="md"] .badge-rm .i { width: 10px; height: 10px; }
}
.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; }

/* Its own panel, not just another item in the home page's hairline-separated
   stack -- right below the hero is prime real estate, and a plain heading +
   row of tiles read as no more important than Videos or Albums further down.
   The border/background/shadow is the same "elevated card" language
   .hero-profile and .video already use elsewhere, just applied to a whole
   section instead of one card. */
.featured-block {
  background: var(--surface); background-image: var(--grad-soft); border: 1px solid var(--border);
  border-radius: var(--r-lg); box-shadow: var(--sh-1);
  margin-top: 15px; padding: 16px clamp(16px, 4vw, 26px) 10px;
}
.featured-block .section { margin-top: 0; }
/* More room around the "Featured" label itself (it was reading squished
   against the panel's top edge and the carousel below it) -- offset by
   shrinking the carousel tiles a touch (.feat-card, below) so the extra
   breathing room here doesn't make the section taller overall, net a tad
   shorter instead, per request. */
.featured-block .section-head { margin-bottom: 12px; }
/* The panel itself is what's supposed to read as "important" now (border,
   background, shadow) -- the word "Featured" doesn't need to shout on top
   of that too. Same treatment as .eyebrow (styles.css, used for "Downloads"
   on the wallpaper page etc.) rather than a heading competing with the
   panel for attention. Overrides .home .section-head h2's shared size. */
.featured-block .section-head h2 {
  font-size: .74rem; font-weight: 800; letter-spacing: .1em;
  text-transform: uppercase; color: var(--faint);
  /* Centred on request -- still flex:1 from .home .section-head h2 (not
     redeclared here), so this box already spans the section-head's full
     width; justify-content is the only change needed to centre the
     icon+text within it rather than sitting at the left edge. Size/
     weight/colour untouched -- it's still the same small, quiet eyebrow,
     just recentred, not made into a bigger title. */
  display: flex; align-items: center; justify-content: center; gap: 6px;
}
/* No trailing rule here (.home .section-head h2::after, above) -- this
   heading's already a small uppercase eyebrow with an icon on it, not a
   full section title, and reads as cluttered with one tacked on too. */
.featured-block .section-head h2::after { content: none; }
/* Marks this heading as "featured content", not just another section label
   -- svg("sparkle", "i featured-star") in section()'s icon param, app.js.
   Sized and coloured to sit quietly next to the (already small) eyebrow
   text rather than competing with it. align-items:center on the h2 (above)
   centres this and the text's boxes exactly -- confirmed, they land on the
   same pixel -- but the sparkle glyph's own path only runs from y3.5 to
   y17.5 of its 24x24 viewBox, not the full height, so its drawn shape sits
   a bit above centre within that box regardless. Same fix as .video
   .play-tri's own margin-left nudge elsewhere in this file: a small manual
   offset to correct for where the glyph actually is, not just where its
   box is. */
.featured-star { width: 13px; height: 13px; color: var(--accent); flex: none; margin-top: 2px; }

/* featured carousel -------------------------------------------------------
   A photo or a video, picked at random (app.js fillFeaturedCarousel()) --
   deliberately short: one square-ish thumb plus a single caption line, so
   a row of them reads as a quick scan-past strip under the hero, not
   another full section of tall cards. Used to also mix in people and
   albums, with arrow buttons to click through it a card at a time --
   narrowed to just photos and videos, and turned into the continuous
   looping marquee below, on request.

   The track's own content is rendered twice, back to back
   (fillFeaturedCarousel(), app.js) -- the animation here slides it left by
   exactly one copy's width (translateX(-50%)) and repeats forever, and
   since the two copies are identical that loop point is invisible: the
   instant the first copy has scrolled fully out of view, the second one
   is sitting exactly where the first one started, so restarting the
   animation there reads as one endless strip, not a visible reset.
   .car-viewport is what actually clips that -- .car-track itself is
   deliberately wider than it (width:max-content, its own full
   doubled-content width, not whatever the section happens to be), so
   overflow:hidden has to live on a separate, fixed-width ancestor rather
   than on the element that's actually moving. */
/* Round arrow-button shape/colour, shared with the spotlight further
   down -- this carousel dropped its own arrows for the marquee below,
   but the spotlight's still use this rule directly (its own comment
   there says "styles.css, above," which is this). Accidentally deleted
   outright rather than just trimmed when this section's own arrow-
   specific rules (margin-top, the width:640px hide) came out; restored. */
.car-nav {
  flex: none; width: 38px; height: 38px; display: grid; place-items: center;
  border-radius: 50%; cursor: pointer; background: var(--surface-2);
  border: 1px solid var(--border); color: var(--dim);
  transition: color .16s, border-color .16s, background .16s;
}
.car-nav:hover { color: var(--text); border-color: var(--border-2); }
.car-viewport { overflow: hidden; }
.car-track {
  display: flex; gap: 14px; width: max-content;
  animation: feat-marquee var(--feat-duration, 45s) linear infinite;
}
/* :focus-within too, not just :hover -- a keyboard user tabbed into a
   card is just as "in" this strip as a mouse hovering it, and having it
   keep sliding a focused card out from under them is worse for them, not
   less. */
.car-viewport:hover .car-track, .car-viewport:focus-within .car-track {
  animation-play-state: paused;
}
@keyframes feat-marquee {
  from { transform: translateX(0); }
  to { transform: translateX(-50%); }
}
@media (prefers-reduced-motion: reduce) { .car-track { animation: none; } }

/* The tile size is the lever for the section's overall height -- padding
   around the "Featured" label went the other way this round (more, not
   less, see .featured-block above), so this shrank a bit further to keep
   the section a tad shorter net, rather than taller. */
.feat-card {
  flex: 0 0 80px; min-width: 0; text-align: left; color: inherit;
  cursor: pointer; border: none; background: none; padding: 0; font: inherit;
}
.feat-thumb {
  position: relative;
  aspect-ratio: 1; border-radius: var(--r); overflow: hidden;
  background: var(--surface); border: 1px solid var(--border);
  transition: transform .18s var(--ease), border-color .18s;
}
/* Marks a video-type card -- just the glyph, no disc behind it the way
   .video .play-disc gets elsewhere: at this thumb's size (~80px) a 44-52px
   circle would all but cover the photo it's sitting on. Same drop-shadow
   approach .video .play-tri uses instead of a background, for the same
   reason -- contrast against a photo that could be any colour, without
   painting over it. */
.feat-play {
  position: absolute; inset: 0; display: grid; place-items: center;
  pointer-events: none; z-index: 2;   /* stays on top of a hover-preview clip (video.preview, below) */
}
.feat-play .i {
  width: 22px; height: 22px; color: #fff; fill: currentColor;
  stroke: rgba(0, 0, 0, .55); stroke-width: 1px; stroke-linejoin: round;
  filter: drop-shadow(0 1px 4px rgba(0, 0, 0, .65));
}
.feat-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* The hover-preview clip (app.js) -- same treatment as .video-art's own
   version, above: sits over the static poster at the exact same box,
   z-index 1 so .feat-play (2, above) still shows on top of it. */
.feat-thumb video.preview {
  position: absolute; inset: 0; z-index: 1;
  width: 100%; height: 100%; object-fit: cover;
}
.feat-card:hover .feat-thumb { transform: scale(1.04); border-color: var(--border-2); }
.feat-cap { margin-top: 7px; font-size: .78rem; line-height: 1.3; }
/* Two lines, not the old single truncated one -- a lot of these (photo
   descriptions especially) are a full sentence, and one line plus an
   ellipsis was cutting them down to just the first few words. -webkit-box
   is the only way to clamp multi-line text to a line count (no line-based
   text-overflow exists otherwise); it's a very old flexbox-adjacent spec,
   but it's exactly what every major browser still uses for this, `-webkit-`
   prefix and all, since nothing has ever replaced it. Fixed height keeps
   every card the same size regardless of whether its title actually wraps
   to one line or two. */
.feat-title {
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
  overflow: hidden; height: 2.6em;
}
/* inline-flex, not block, now that a small icon (svg("photos"/"videos"),
   app.js) sits in front of the word itself -- on request. */
.feat-kind {
  display: inline-flex; align-items: center; gap: 4px;
  font-size: .64rem; font-weight: 700; letter-spacing: .05em;
  text-transform: uppercase; color: var(--faint); margin-bottom: 1px;
}
.feat-kind .i { width: 10px; height: 10px; stroke-width: 2.6; }

/* person card ------------------------------------------------------------ */
.person {
  /* 14px -> 11px on request -- "Lincolner" plus a 3-digit photo count
     (person-meta, below) was wrapping onto two lines for exactly the
     people with the most photos (Jordan, Jamie), while everyone else's
     shorter 2-digit count fit fine on one -- the actual card width was
     just a few px short of both pills together, only exposed once a
     count reached 3 digits. The reclaimed width here (plus person-meta's
     own tighter pill sizing, below) closes that gap. */
  padding: 20px 11px 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);
  /* On request, back to the photo and its ring zooming together with the
     rest of the card on hover -- every attempt at excluding just the
     avatar from .person:hover's own scale(1.02) (a counter-scale, then
     moving the growth to a ::before instead) traded the reported ring
     artifact for the avatar not reacting to hover at all, which reads as
     its own bug. will-change is the one piece of that worth keeping: a
     hint to rasterize this clipped circle on its own layer rather than
     fresh each frame, which is real (if not airtight) mitigation for the
     diagonal-angle antialiasing inconsistency a circle's edge can show
     when its rendered size changes -- confirmed directly this happens
     identically whether the clip is done via border-radius+overflow
     (here) or clip-path, so it isn't a fixable choice between those two,
     just something to reduce as far as possible. */
  border: 2.5px solid var(--surface);
  will-change: transform;
}
.avatar-in img {
  width: 100%; height: 100%; object-fit: cover;
  transform: scale(1.14); transform-origin: center;
}
/* p.photo (avatar(), app.js) was never saved with the small avatars' own
   ~3px white frame, so the over-zoom above has nothing to crop past here
   -- would just needlessly zoom into the actual photo instead. Same fix
   the spotlight carousel's own avatar/peeks already needed, same reason
   (that rule's own comment, app.js). */
.avatar-in-photo img { transform: none; }
/* friends tabs ---------------------------------------------------------- */
/* .tabs-row holds the margin/underline that used to sit on .tabs itself,
   now that .tabs shares this row with the filter box (see V.us(), app.js)
   -- .tabs keeps its own layout rules for when it's the only thing in the
   row (there's no other caller today, but no reason to make it depend on
   a wrapper it didn't always have). The tab buttons and, now, the sort
   dropdown (.mini-dd, below) stay at their natural size (flex: 0 0 auto --
   neither has a truncation style to fall back on, unlike the filter box,
   which does), so .filter is the only thing that ever gives, shrinking
   through the same 3-tier placeholder/min-width floor the Photos/Videos
   filters already use -- see wireResponsivePlaceholder() in app.js. Used
   to never wrap at all, back when this only ever held two items (tabs +
   filter, which always fit together); a real sort control (on request,
   .sort-dd below) made three, so this wraps now too -- harmless for the
   original pair, since two items that already fit on one row never had a
   reason to wrap in the first place, and lets the sort dropdown drop to
   its own line rather than getting crushed once a phone-width screen runs
   out of room for all three. */
.tabs-row {
  display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
  margin: 28px 0 0; padding-bottom: 14px;
  border-bottom: 1px solid var(--border);
}
.tabs-row .tabs { flex: 0 0 auto; margin: 0; padding-bottom: 0; border-bottom: none; }
.tabs-row .filter { flex: 1 1 auto; }
/* Second attempt at the narrow-width problem -- the first (shrink the
   filter to a bare "Filter" pill, expand it to cover/replace the tabs on
   focus) worked, but on request got thrown out for something simpler
   rather than refined further. Two full pill tabs (each carrying a count
   badge) are the actual space hog on a phone-width screen, not the
   filter -- so instead of squeezing the filter down to make room for
   them, this swaps the tabs themselves for a single compact dropdown
   below 640px (.tabs-dd, right below), which says the same thing
   ("Lincolners (19)") in a fraction of the width. That leaves the filter
   with enough room to just be a normal filter at every width, same
   3-tier placeholder/min-width floor the Photos/Videos filters already
   use (wireResponsivePlaceholder(), app.js) and nothing else -- no
   expand-on-focus, no covering or hiding anything else on the row. */
.tabs-dd { display: none; }
@media (max-width: 640px) {
  .tabs-row .tabs { display: none; }
  .tabs-dd { display: block; }
}
/* .mini-dd/.mini-dd-trigger (V.us()/mountUs(), app.js): the group picker
   above and the sort control below, both a button that opens a real
   .admin-dropdown panel instead of a native <select> -- on request
   ("make the dropdown menu itself similar to the Admin dropdown menu
   that appears on the nav bar when logged in as admin"), since a native
   select's own open menu can't be restyled to match anything. Kept this
   trigger's own closed look close to the plain bordered pill a <select>
   already read as here (unlike .admin-trigger, above -- that one's
   accent tint specifically signals "you're signed in as admin," which
   doesn't apply to a sort control), so this only changes what opens when
   it's pressed, not how it looks sitting closed next to the filter box.
   wireMiniDropdown() (app.js) is what actually drives the open/position/
   close behaviour; wireResponsivePlaceholder()'s 3-tier .filter sizing
   nearby is untouched by any of this. */
.mini-dd { flex: 0 0 auto; }
.mini-dd-trigger {
  display: inline-flex; align-items: center; justify-content: space-between; gap: 8px;
  width: 100%; border: 1px solid var(--border); border-radius: 999px;
  background: var(--surface); color: var(--text);
  font: 700 .88rem var(--sans); cursor: pointer;
  padding: 9px 14px 9px 16px;
  transition: border-color .16s;
}
.mini-dd-trigger:hover { border-color: var(--border-2); }
/* A native <select>'s own text never wraps regardless of how tight the
   box is; a plain flex row of inline text does, which is exactly what
   read as "Lincolners" and "(19)" splitting onto two lines the moment
   this became a real <button> instead -- confirmed directly from a
   screenshot. nowrap plus a little headroom on the two widths below is
   what a <select> got for free. */
.mini-dd-trigger span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mini-dd-trigger .i {
  width: 15px; height: 15px; stroke-width: 2.2; color: var(--faint); flex: none;
  transition: transform .16s var(--ease);
}
/* Same "already open" chevron-flip .admin-trigger gets (styles.css,
   above) -- aria-expanded is set/cleared by wireMiniDropdown() itself. */
.mini-dd-trigger[aria-expanded="true"] .i { transform: rotate(180deg); }
/* Sized to the wider of each pair's own options ("Lincolners (19)"/"Sort:
   Most photos") so picking the other one never resizes the control out
   from under a still-open tap -- same reasoning the old <select> versions
   of both already used. Wider than either actually measures at, on
   request ("the text... should not wrap") -- a <select>'s own text can
   run right up to the box edge with no ill effect; a flex row like this
   one needs real breathing room past that same measurement or the wrap
   fix above still has nowhere to put the text but a smaller font. */
.tabs-dd .mini-dd-trigger { width: 190px; }
.sort-dd .mini-dd-trigger { width: 190px; font-size: .82rem; padding: 8px 12px 8px 15px; }
.sort-dd .mini-dd-trigger .i { width: 14px; height: 14px; }
/* The currently-picked sort, highlighted inside its own panel -- the
   group picker's panel (just below it in the markup) has no equivalent,
   on purpose: unlike sort, picking a group immediately navigates away
   from whatever was showing (the whole point of a tab), so there's
   nothing left open afterward for a highlight to still be looking at.
   Not used by the real Admin dropdown itself (its own three rows are
   actions, not a persistent choice), just this reuse of its markup/CSS. */
.admin-dropdown-item[aria-current="true"] { background: var(--accent-soft); color: var(--accent); }
.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); }
/* Circled, matching .tl-year .tl-n (the same count-badge idea on the News/
   Guestbook year tabs) on request -- same padding/radius/background, just
   its own font-size to match this tab's own slightly bigger type. */
.tab .tab-n {
  padding: 2px 7px; border-radius: 999px; background: var(--bg-tint);
  font-size: .72rem; font-weight: 700; color: var(--faint);
  font-variant-numeric: tabular-nums; line-height: 1.4;
}
.tab.on {
  background: var(--grad); border-color: transparent; color: #fff;
  box-shadow: 0 4px 16px -5px rgba(245, 66, 124, .5);
  /* Same gradient-tangent-point bug .btn-primary's own comment (styles.css,
     above) explains -- and, same reason given there, has to sit on THIS
     rule specifically rather than the shared .tab above: that `background:`
     shorthand right above resets background-clip back to border-box
     otherwise. */
  background-clip: padding-box;
}
.tab.on .tab-n { background: rgba(255, 255, 255, .25); color: #fff; }
.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; margin-bottom: 8px; }
.person { display: block; color: inherit; }
.person:hover { text-decoration: none; }
/* "Refined directory" redesign, on request -- kind + photo count used to
   be two plain-text lines here (.who, then .links); personCard() (app.js)
   now puts both on one row as pills instead, reusing the shared, site-
   wide .pill/.pill.hot/.pill.grey classes the Home spotlight card already
   established for this exact "kind + photo count" pairing (.spot-tags) --
   nothing new to style beyond the row layout itself. */
.person-meta { display: flex; gap: 5px; justify-content: center; flex-wrap: wrap; }
/* Tighter than the shared .pill default (styles.css "generic") specifically
   here -- two pills side by side is exactly what over ran a card's width
   for a 3-digit photo count (see .person's own comment, above). Same
   colours/weight, just less padding and a smaller icon so both always fit
   on one line at every card size this grid actually renders (down to the
   138px mobile minmax, .grid.people below). */
.person-meta .pill { padding: 4px 9px; gap: 4px; }
.person-meta .pill .i { width: 11px; height: 11px; }
/* On request ("the friend pills on the friends page" should match "the
   home page") -- the plain (non-hot) "Friend" pill's own background/text
   colour were already identical in both places (plain .pill, styles.css
   "generic"); what actually differed was the card underneath it. .spot-main
   (the Home spotlight's card) sits on --surface-2, so the pill's flat
   --bg-tint reads as a visibly darker chip against it -- .person sits on
   plain --surface instead, close enough to --bg-tint in DARK mode
   specifically (confirmed directly: barely distinguishable) that the exact
   same chip read as "darker"/washed into the card rather than a distinct
   pill. --surface-2 restores the same contrast the spotlight card already
   has -- dark mode only, since light mode's --bg-tint already contrasted
   fine against --surface there (a plain --surface-2 override read as
   nearly invisible instead, confirmed directly, so this stays scoped
   rather than "fixing" a theme that was never reported broken). */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .person-meta .pill:not(.hot):not(.grey) { background: var(--surface-2); }
}
:root[data-theme="dark"] .person-meta .pill:not(.hot):not(.grey) { background: var(--surface-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; }
/* The photo buttons (Jordan's photos / Contact Jordan) sit on their own
   row below the whole header, not squeezed beside the photo or inside
   .profile-id -- tried a narrow photo-width column first, but that
   wrapped their text onto two lines and, once there were two of them
   stacked under it, pushed .profile-id's name/pill down out of line with
   the photo. Full header width to work with here instead, so .hero-cta's
   own default row (styles.css, shared) keeps both side by side at any
   width that matters, wrapping only if it truly has to. margin:0 on top
   zeroes hero-cta's own margin-top since .profile-head's margin-bottom
   (above) already opens that gap; the 28px on bottom matches it going
   into whatever comes next (.facts has margin:0 of its own, so this is
   the only thing spacing it off in that case). */
.profile-head + .hero-cta { margin: 0 0 28px; }

/* 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);
}

/* "Lincolners and Friends" spotlight (app.js, wireSpotlight()) -- an
   auto-rotating look at this site's people, one at a time. Same panel
   language as
   .featured-block (border/background/shadow) -- floating the controls and
   a bare avatar directly on the page background, with nothing framing the
   section as its own component, was what read as unfinished/broken.
   .car-nav (styles.css, above) supplies the arrow buttons' own shape and
   colour, reused as-is here rather than duplicated -- this is the one
   place left that still uses it as arrows to click, now that the
   featured carousel (styles.css, above) is a passive, auto-scrolling
   strip with none of its own.

   align-items:flex-start, not :center -- centring the arrows against
   .spot-stage's full height (avatar, name, tagline, button and all)
   levelled them with the CARD's centre rather than the AVATAR's, same
   mismatch as .spot-peek's own (comment, above). alignSpotPeeks() (app.js)
   gives them a measured margin-top for the same reason it does peeks: the
   avatar's position here isn't a fixed offset the way, say, a square
   thumbnail sitting first in a fixed-layout card would be, so it has to
   be read from the real render, not assumed. */
.spotlight {
  display: flex; align-items: flex-start; gap: 18px;
  padding: 16px clamp(16px, 4vw, 26px);
}
/* flex:1 with no max-width, unlike the first pass at this -- that version
   capped out at 480px and then centred the whole prev/stage/next cluster
   as one block, leaving the rest of this panel's actual full width (it
   matches every other section, ~1120px) as dead space on both sides
   instead of the stage using it. justify-content:space-between, not
   :center, for the same reason one level up -- centre still clustered the
   three items together in the middle and left two large empty rectangles
   between them and the arrows, just inside the stage instead of outside
   it. space-between pushes the peeks out toward the arrows and lets the
   gap itself (a minimum now, not a fixed distance) absorb the rest of the
   width, so the whole row actually uses the space instead of just being a
   wider box around the same clustered content. */
/* gap must match SPOT_GAP in app.js exactly -- spotFit() there sums
   real peek widths plus this same gap to decide how many fit, so a bigger
   value here than it assumes is exactly how peeks ended up overflowing
   past the panel's edges (it thought less width was spoken for than
   actually was, per pair, and that error compounds with every extra
   peek). */
/* align-items:flex-start, not :center -- .spot-main is a tall card (pill
   row, avatar, name, tagline, button) with the avatar nowhere near ITS OWN
   vertical middle, so centering every item's full box put each peek's
   centre level with the CARD's centre, not with the AVATAR inside it --
   peeks and the avatar they're supposed to be flowing into/out of didn't
   actually line up. Every item starts flush at the stage's top instead,
   and alignSpotPeeks() (app.js, called after every render) gives each
   peek its own margin-top, measured against the real rendered avatar
   position rather than assumed, so its centre matches the avatar's
   exactly regardless of how the card's own internal spacing changes. */
.spot-stage {
  position: relative;   /* anchors a departing peek's own position:absolute (goSpot(), app.js) */
  flex: 1; min-width: 0;
  display: flex; align-items: flex-start; justify-content: space-between; gap: 8px;
  /* Lets a touch that's mostly vertical keep scrolling the page normally --
     the browser decides that axis on its own rather than JS guessing, so
     wireSpotSwipe() (app.js) only ever sees a drag that's already reading
     as horizontal. */
  touch-action: pan-y;
}
/* The people on either side, as many deep as spotFit() (app.js) fits the
   panel's actual width -- a single prev/next read as two stray thumbnails
   sitting next to a card, not as a carousel; several receding on each
   side, continuously smaller with distance (inline width/height,
   peekSize(), app.js -- not fixed steps here, so it scales smoothly
   rather than in visible jumps), reading as grayscale rather than full
   colour (filter, below), is what actually reads as one, the way a real
   coverflow does -- full opacity throughout on request, so size and the
   grayscale filter carry the whole effect now. transform is the one
   property goSpot()'s FLIP animation (app.js) actually drives -- that's
   what makes clicking left/right visibly slide and resize every avatar
   between its old spot and its new one, rather than each one just popping
   straight to its new position and size. */
.spot-peek {
  position: relative;
  flex: none; border-radius: 50%; overflow: hidden;
  padding: 0; cursor: pointer; background: var(--surface-2);
  /* Same border as .spot-avatar (below) -- these are the same person's
     photo, just not the one currently centred, so the frame around them
     should read as one consistent treatment across the whole section. */
  border: 3px solid var(--border);
  filter: grayscale(.4);
  transition: transform .38s var(--ease), filter .18s;
}
.spot-peek:hover { filter: grayscale(0); transform: scale(1.1); }
/* Custom glass tooltip, shared by both these peeks and .spot-avatar (the
   centred showcase card's own picture): spotName() (app.js) only ever
   shows a first name plus a last initial there, so this is where the
   person's full name (data-name is set to p.fullname there, just the
   short p.name here) actually shows up. overflow:hidden on .spot-peek
   only clips the avatar image itself, not this -- ::after paints on the
   button's own box, which isn't clipped, so the tooltip escaping past
   the circle's edge is fine. */
.spot-peek::after, .spot-avatar::after {
  content: attr(data-name);
  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;
}
.spot-peek:hover::after, .spot-peek:focus-visible::after,
.spot-avatar:hover::after, .spot-avatar:focus-visible::after {
  opacity: 1; visibility: visible; transform: translate(-50%, 0);
}
/* No over-zoom here, unlike every other avatar on this page (.avatar-in):
   that's cropping out a ~3px white frame the old site's 97px avatars all
   carry, which a plain circular crop would otherwise land right on.
   Every image in this carousel -- peeks
   included, not just .spot-avatar -- points at p.photo instead
   (spotPerson()/renderSpotlight(), app.js): the profile page's own,
   larger picture, on request, which was never saved with that frame in
   the first place (V.person() already gets away with a plain circular
   crop on that same source). */
.spot-peek img, .spot-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* Its own card, one layer up from .spotlight's own panel -- a lighter
   --surface-2 against the panel's --surface so the currently-featured
   person visibly sits "on" the panel rather than just floating loose
   inside it, which is what "highlighted in a card" asked for. */
.spot-main {
  /* width, not max-width -- max-width only caps how wide the card COULD
     get, so it still shrank to fit whatever was actually inside it: a
     shorter "Friend" vs "Lincolner" pill, or the photo-count pill gaining
     a digit, both nudged the card's own edges in or out from one person to
     the next. A fixed width holds it steady regardless of what any given
     person's content happens to need -- alignSpotPeeks() (app.js) already
     depends on this card's box being stable for exactly this reason.
     196px was measured against every one of the 36 people's actual content
     width (temporarily width:max-content, one at a time) and took the real
     widest (Jamie, 195px; Jordan close behind at 194px) plus a hair of
     rounding room, rather than an arbitrary ceiling like the 260px this
     started at that left everyone shorter than the widest with visibly
     excess space on both sides. 210px, scaled up from that alongside a
     bigger .spot-avatar (below) -- back on request, knowingly capping this
     panel's own real max width (spotFit(), app.js) at 4 peeks a side
     instead of 5 on any screen, rather than staying smaller for a 5th. */
  flex: none; display: flex; flex-direction: column; align-items: center;
  text-align: center; gap: 4px; width: 210px;
  background: var(--surface-2); border: 1px solid var(--border);
  border-radius: var(--r); box-shadow: var(--sh-1);
  padding: 12px 24px 14px;
}
/* No animation of its own -- .spot-main is the fixed "showcase" the
   avatars move through, not something that itself moves. It used to fade
   and pop in on every change, which is exactly what read as the box
   "flickering": the frame has no reason to animate since it's the same
   size and position regardless of who's showing. The actual motion is
   .spot-avatar's, below -- same data-slug mechanism as .spot-peek, so
   goSpot()'s FLIP (app.js) treats the avatar slot as just another stop a
   person can occupy, and a peek arriving here (or the previous centred
   person leaving for a peek slot) slides and resizes exactly like peek-to-
   peek movement does, in and out of this one static frame. */
/* Kind pill and photo-count pill sharing one row above the avatar -- the
   count used to be its own plain text line under the name ("28 photos");
   a second pill next to "Lincolner"/"Friend" instead keeps both bits of
   at-a-glance metadata together in the one place a viewer's eye already
   goes first, and is one less line of text taking up the card's height. */
/* margin-bottom, not a bigger .spot-main gap -- that gap is shared by
   every row in the card (avatar-to-name, name-to-tagline, etc.), so
   raising or lowering it would space out/in all of them; this adds a bit
   of extra room under just this one row, on top of that shared gap,
   before the avatar. Down from 12px on request ("slightly shorten the
   gap... to the top of the profile picture") -- still its own value on
   top of .spot-main's shared 4px gap, not zeroed out entirely, so this
   row still reads as a little more separated from the avatar below it
   than the avatar is from the name below that. */
.spot-tags { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
/* 160px, back on request over the smaller 146px this briefly went back to
   -- .spot-main (above) widened by the same amount alongside it. Knowingly
   caps this panel's own real max width (956px, set by .wrap's cap and the
   sidebar beside it, neither touched here) at 4 peeks a side instead of 5,
   since 5 needs 1002px at this size and never gets it -- bigger avatars
   over a 5th peek, deliberately, this time. Still clearly the biggest
   circle on the stage (the nearest peek tops out at 119px now,
   peekSize()/SPOT_SIZES, app.js) so it still reads as the one everything
   else is growing toward. */
/* border-color is var(--border) -- the same colour .spotlight's own panel
   border uses (styles.css, above) -- rather than a dedicated white/grey
   token this used to have; asked to just match the section it sits in
   instead. */
.spot-avatar {
  position: relative;
  display: block; width: 160px; height: 160px; border-radius: 50%; overflow: hidden;
  border: 3px solid var(--border); box-shadow: var(--sh-2);
  background: var(--surface);
  /* No opacity here -- it used to also be one of this transition's
     watched properties, to fade a dim peek up to full visibility as it
     became the newly-centred avatar. Peeks are full opacity throughout
     now (.spot-peek, above), so there's no faded "from" state left for
     this element to ever animate away from.

     box-shadow IS still watched, even though nothing here ever sets an
     inline box-shadow the way goSpot() does transform -- see
     .spot-flipping, below, for what actually drives it. Same .38s/ease
     as transform, and released at the same instant transform is
     (goSpot(), app.js) -- shadow and slide ease in together, not the
     shadow arriving as a separate step once the slide's already done. */
  transition: transform .38s var(--ease), box-shadow .38s var(--ease);
}
/* .spot-peek has no box-shadow of its own (above), so a peek becoming
   the newly-centred avatar has no shadow to invert FROM the way it does
   a transform -- goSpot() (app.js) adds this class immediately before
   forcing the "from" state, so the shadow-less peek's box-shadow is
   forced to none right as that state gets committed, then removes it in
   the very same breath the transform is released, a couple of lines
   later -- not on a delay. That's what makes it a real "from" value for
   the transition above to animate away from, starting at the same
   instant as the slide rather than only once the slide has finished:
   released together, eased in together. A version of this that held the
   class on a timer, only lifting it once the slide's own transition had
   fully finished, was tried first -- the shadow arrived as a distinctly
   separate, delayed pop rather than part of the same motion, which read
   as more of a glitch than a finishing touch. */
.spot-avatar.spot-flipping { box-shadow: none; }
/* z-index so the grow reads as this circle lifting slightly above its
   neighbours (the pills row right above it, the name right below, both
   only 4px away -- .spot-main's own gap) rather than being clipped by
   whichever one happens to paint after it. Reuses both transitions
   above, same .38s/ease as everything else this element already
   animates on -- no separate rule needed. The glow is a second,
   additional box-shadow layer stacked after the base --sh-2 one, not a
   replacement for it -- losing the grounding shadow while gaining a
   glow read as the circle floating rather than lighting up. Inert while
   goSpot()'s own inline transform is actively driving the FLIP slide
   (inline always wins over a stylesheet rule regardless of :hover),
   which is fine: there's no meaningful "hover" on an avatar that's
   mid-flight anyway. */
.spot-avatar:hover {
  transform: scale(1.05); z-index: 2;
  /* Blur is the lever for "dispersed" -- spread (the 4th value) sets how
     big the shape is before it starts fading, blur sets how gradually it
     fades once it does. History: 0 0 20px 6px at .55 alpha, then 0 0 10px
     1px at .3 (too strong, then too tight), then 0 0 22px 0 at .25 (still
     too strong) -- each pass nudged blur/spread more than alpha, so
     brightness barely moved. This time alpha takes the real cut (roughly
     half again) with blur trimmed only a little, same reasoning as the
     logo glow's own history above it (.brand:hover .brand-logo). */
  box-shadow: var(--sh-2), 0 0 14px 0 rgba(255, 255, 255, .13);
}
.spot-main h3 { font-size: 1.05rem; }
/* One line, ellipsised, not a wrapped paragraph -- a tagline that happened
   to run to two lines was the single biggest source of extra height here,
   and made the card's own height inconsistent from person to person. */
.spot-tagline {
  color: var(--dim); font-size: .82rem; line-height: 1.3; max-width: 100%;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.spot-main .btn { margin-top: 2px; padding: 6px 14px; font-size: .82rem; }
.spot-main .btn .i { width: 15px; height: 15px; }
@media (max-width: 640px) {
  /* spotFit() (app.js) shrinks peeks to fit a narrow panel rather than
     this stylesheet hiding them outright once they no longer fit at full
     size -- but a truly tiny stage can still legitimately compute zero,
     leaving .spot-main as the only child in .spot-stage. space-between's
     rule for a single item is to plant it at the start rather than the
     middle, so this stays needed as a fallback for exactly that case. */
  .spot-stage { justify-content: center; }
}

/* 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 a compact rhythm just tightens these numbers in place.

   auto-fit + minmax(min(200px, calc(50% - 5px)), 1fr), not a flat
   repeat(3, 1fr) -- a fixed 3-up track only ever has two states, 3
   equal columns or (past the media query below) 1, so a card that's
   still got plenty of room right before that breakpoint gets crushed
   to a third of it a pixel later. This is the same fix .facts and
   .albums (above) already use for their own "don't skip straight past
   2 columns" version of this: on anything wide enough for three 200px
   cards, min() picks 200px and auto-fit fans out to however many of
   those actually fit (3 here, since there are only 3 items); once the
   container can't fit three, the track minimum becomes "half the
   container," which auto-fit can always fit exactly two of -- 2 columns
   holds all the way down from there instead of skipping straight to 1.
   Genuinely too narrow for even that (media query below) is the one
   case this can't fix itself: a card given a real half-container-or-more
   share of a phone-width screen is still a phone-width card. */
.gb-row { display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(min(200px, calc(50% - 5px)), 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;
}
/* 280px, not a round guess -- .gb-card's content (a small dot, a short
   name/date pair, a message clamped to 3 lines) has nothing in it that
   actually needs more room than a real half-container column gives it;
   checked directly down to 320px, essentially the narrowest viewport any
   real device uses, and two columns still read cleanly there. This is
   further down than that on purpose, as a floor for anything even
   narrower (an embedded/split-screen view, say) rather than a width
   expected to actually get hit. */
@media (max-width: 280px) { .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; }

/* About page's own "Previous versions" section (V.about(), app.js) --
   its three era-tagged tiles keep a "Time Capsule" look, on request,
   ported from the More-page redesign mockups' own "Two zones" option
   (mockups/more-page-redesign-mockups.html): a small fake browser-chrome
   frame on each, rather than moreTile()'s plain card the real More page's
   own matching section still uses (untouched by any of this).

   The dark background box these tiles used to sit inside is gone, on a
   follow-up request ("remove the box with rounded corners" / "the
   background box") -- the heading above this grid is back to a plain
   section() (app.js), same as every other block on this page, and the
   tiles below just sit directly on the page now, no boxed-in backdrop
   behind them.

   Colours were fixed regardless of site theme at first (a deliberately
   aged, sepia palette, same reasoning as .polaroid's own comment further
   down -- "a physical thing sitting on the page"), then moved onto the
   site's own shared tokens instead on a second follow-up request ("theme
   the boxes... to be closer colors to the actual site") -- var(--accent)
   for the era number in particular, so it reads as the same red the rest
   of the site already uses rather than an unrelated amber. Still its own
   card-base-adjacent look (chrome bar, era number) rather than an actual
   .card-base, since the chrome bar has no equivalent there. */
.tc-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; }
.tc-tile {
  display: block; border-radius: 8px; overflow: hidden; color: inherit;
  background: var(--surface-2); border: 1px solid var(--border);
  box-shadow: var(--sh-1);
  transition: transform .2s var(--ease), box-shadow .2s var(--ease), border-color .2s;
}
.tc-tile:hover { text-decoration: none; transform: scale(1.014); box-shadow: var(--sh-2); border-color: var(--border-2); }
.tc-chrome { display: flex; align-items: center; gap: 5px; padding: 8px 10px; background: var(--bg-tint); border-bottom: 1px solid var(--border); }
.tc-chrome .tc-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--border-2); }
/* "opens in a new tab" marker, same glyph as moreTile()'s .tile-ext (app.js)
   but living in the chrome bar rather than a corner badge -- pushed to the
   bar's far end, past the traffic-light dots, the same place a real browser
   tab strip would put an icon like this. */
.tc-ext { margin-left: auto; display: grid; place-items: center; color: var(--faint); }
.tc-ext .i { width: 12px; height: 12px; stroke-width: 2.3; }
.tc-tile:hover .tc-ext { color: var(--accent); }
.tc-body { display: block; padding: 16px; }
.tc-era { display: block; font-size: 1.05rem; font-weight: 800; color: var(--accent); letter-spacing: .02em; }
.tc-body strong { display: block; font-size: .94rem; font-weight: 800; margin: 4px 0 6px; color: var(--text); letter-spacing: -.01em; }
.tc-body p { margin: 0; color: var(--dim); font-size: .82rem; line-height: 1.45; }

/* 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 ----------------------------------------------------- */
/* 760px, matching .feed -- the guestbook's and news's own width -- and left
   where the rest of the page's own content (heading, description, grid)
   already starts, not centred in the middle of a much wider column: a card
   floating centred in a sea of empty space either side of it on a wide
   desktop screen reads as a stray hero rather than a normal box sitting
   where it belongs on the page. .pp-form (below) keeps its own narrower
   measure regardless -- a single password field stretched out to this
   card's full width would look just as stray the other way. */
.gate {
  max-width: 760px;
  text-align: center; padding: clamp(20px, 4vw, 28px) 22px;
  border-radius: var(--r-xl); border: 1px solid var(--border);
  background: var(--surface); box-shadow: var(--sh-1);
}
.gate .lock {
  width: 42px; height: 42px; margin: 0 auto 14px; border-radius: 13px;
  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: 19px; height: 19px; stroke-width: 2.1; }
/* The shackle pivots open on its own bottom-left corner (its own path
   starts there, see the "lock" entry in ICON) rather than morphing into the
   separately hand-drawn "unlock" glyph used elsewhere -- that path has a
   different number of commands (see app.js), so a straight `d` morph would
   not interpolate cleanly. A rigid swing reads as clearly "unlocked" without
   needing the two shapes to match. Only ever animates on wirePpGate()'s own
   reveal(true) -- see styles.css's global prefers-reduced-motion rule for
   why nothing extra is needed here to respect that. */
.gate .lock .lock-shackle {
  transform-origin: 8px 10.5px;
  transition: transform .5s cubic-bezier(.34, 1.56, .64, 1);
}
.gate.unlocked .lock .lock-shackle { transform: rotate(-35deg); }
/* Wrong password -- shakes the whole badge (not just the shackle) so it
   reads as "that was rejected", distinct from the shackle's own open-swing
   above. Retriggered from JS by removing and re-adding this class (see
   shakeLock() in app.js), since simply leaving "shake" applied wouldn't
   replay on a second, back-to-back wrong guess. */
@keyframes lock-shake {
  10%, 90% { transform: translateX(-1px); }
  20%, 80% { transform: translateX(2px); }
  30%, 50%, 70% { transform: translateX(-4px); }
  40%, 60% { transform: translateX(4px); }
}
.gate .lock.shake { animation: lock-shake .5s var(--ease); }
.gate h2 { margin-bottom: 20px; font-size: 1.05rem; }
.gate p { color: var(--dim); font-size: .92rem; margin: 0 auto 8px; }
.gate .hero-cta { justify-content: center; }
.gate .form-status { max-width: 320px; margin-left: auto; margin-right: auto; }
.pp-form { max-width: 320px; margin: 14px auto 0; text-align: left; }
.pp-form .field { margin-bottom: 12px; }
.pp-form .hero-cta { margin-top: 0; }

/* "Signed in / Log out" row, above the (now-revealed) grid of albums --
   V.album()'s own empty <div id="pp-account"> until wirePpGate() fills it
   in, so this just needs to disappear entirely until there's something to
   show, no separate hidden attribute to manage from JS. */
.pp-account { display: flex; align-items: center; gap: 10px; margin: -6px 0 22px; }
.pp-account:empty { display: none; }

/* ----------------------------------------------------------------- news -- */
.feed { max-width: 760px; }
/* News' and the guestbook's own filter row, matched to .feed's own 760px
   (above) on request -- plain .toolbar (styles.css, "filter" section) has
   no width of its own, just flex:1 on the search box inside it, so
   without this the row it's in stretched to .wrap's full 1120px on a wide
   enough screen while the cards underneath it stayed capped at 760,
   visibly wider than the very posts/entries it's filtering. */
.toolbar.feed-toolbar { 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: center; gap: 7px; 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); }
/* A small pill of its own, not just a smaller/dimmer number next to the
   year -- reported live: "2023" plus a count of "1" right beside it read
   as one run-on number, "20231". A contained chip with its own background
   gives the count a visible edge to stop at, so it reads as a separate,
   secondary piece of information at a glance instead of needing to be
   parsed apart. Shared by the News archive and Guestbook timelines alike
   (same markup, same classes, both). */
.tl-year .tl-n {
  padding: 2px 7px; border-radius: 999px; background: var(--bg-tint);
  font-size: .68rem; font-weight: 700; color: var(--faint);
  font-variant-numeric: tabular-nums; line-height: 1.4;
}
.tl-year.on {
  background: var(--grad); border-color: transparent; color: #fff;
  box-shadow: 0 4px 14px -5px rgba(245, 66, 124, .55);
  /* Same gradient-tangent-point bug .btn-primary's own comment (styles.css)
     explains -- has to sit here, not on .tl-year above, for the same
     `background:` shorthand reason given on .tab.on. */
  background-clip: padding-box;
}
.tl-year.on .tl-n { background: rgba(255, 255, 255, .25); color: #fff; }

.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; }
/* h3 specifically -- the composer's own Heading button (renderNewsAdmin(),
   app.js) only ever produces that one level, deliberately lower than this
   page's own h1 (the post count/year-range title). h1-h4 (styles.css,
   near the top) already set margin:0/bold/tight tracking site-wide; the
   margin restored here is its own smaller rhythm, not that rule's -- full
   paragraph spacing above a heading reads as too far from whatever it's
   actually introducing. color:var(--text), not the dim body colour every
   other line in here inherits from .post-body itself -- a heading reading
   the same dim shade as the text under it barely reads as one. */
.post-body h3 { color: var(--text); font-size: 1.15rem; margin: 14px 0 6px; }
.post-body h3:first-child { margin-top: 0; }
/* ol/li need no rules of their own here, same as ul/li (the composer's
   pre-existing Bulleted list button) never got any either -- both were
   just left at the browser's own default list styling, which already
   reads fine against this dark card, numbers or bullets alike. */
.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 */
/* max-height (on request) is the one that actually matters for a very
   tall/narrow image specifically -- max-width alone still lets something
   like a 9000px-tall screenshot render just as tall once scaled down to
   75% of a normal width, since that width constraint says nothing about
   height. object-fit:contain is what lets both limits apply together
   without distorting the image -- it shrinks to whichever of the two it
   hits first, width or height, and holds its own aspect ratio either way,
   same as it would with neither limit binding at all. */
.post-body img.inline-img {
  max-width: 75%; max-height: 70vh; width: auto; height: auto;
  object-fit: contain; border-radius: var(--r-sm); cursor: pointer;
  border: 1px solid var(--border); margin: 6px 0;
}
.post-body a { font-weight: 600; }

/* ---------------------------------------------------------------- prose -- */
/* "About Lincoln Heights"'s own lead photo (V.about(), app.js) -- a plain
   img, not the .photo/lightbox pattern the actual photo grids use: on
   request, this one specifically doesn't open anything on click. Same
   max-width as .prose itself (below) so its edges line up with the text
   column under it, and the same card language (radius/border/shadow)
   the rest of the site already uses for a bordered image rather than
   introducing a new treatment for one photo. */
.article-photo { margin: 0 0 20px; max-width: 68ch; }
.article-photo img {
  display: block; width: 100%; height: auto;
  border-radius: var(--r-lg); border: 1px solid var(--border); box-shadow: var(--sh-1);
}
.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;
  background: rgba(8, 7, 12, .94);
  backdrop-filter: blur(14px);
  animation: fade .2s var(--ease);
}
@keyframes fade { from { opacity: 0; } }
/* Three tracks -- count, title, tools -- rather than the title floating on
   top via position:absolute: the two outer 1fr tracks stay equal width
   regardless of how wide the title or the tools end up, which is what
   actually keeps the title centred on the bar's full width (not just the
   count↔tools gap, which isn't centred once those two are different
   widths). It also means the title's track can never overlap the tools'
   the way an absolutely-positioned title (tried first) could on a narrow
   phone width -- grid tracks reserve their own space instead of just
   floating past whatever's already there. */
/* padding-top: max(14px, env(...)) rather than the flat 14px every other
   side keeps -- missed this one when .topbar/.tabbar/.offline-banner
   (further up) got theirs. Only shows up installed as an iOS PWA:
   apple-mobile-web-app-status-bar-style is black-translucent (index.html),
   which is exactly what asks for content to run full-screen under the
   status bar/Dynamic Island rather than starting below an opaque one --
   env(safe-area-inset-top) is how the page is supposed to know how much
   of its own top edge that overlay actually covers. Reported live: the
   close button and the "1 / N" count both sitting right under the status
   bar, unreachable/unreadable, on an installed iPhone. */
.lb-bar {
  position: absolute; top: 0; left: 0; right: 0; z-index: 6;
  display: grid; grid-template-columns: 1fr auto 1fr; align-items: center;
  gap: 12px; padding: 14px 16px; padding-top: max(14px, env(safe-area-inset-top));
  background: linear-gradient(rgba(8, 7, 12, .8), transparent);
}
.lb-count {
  /* grid-column pinned explicitly on all three of .lb-bar's children (here,
     .lb-album-title, .lb-tools) -- BUG, now fixed: auto-placement (no
     grid-column at all, relying on DOM order to land one-per-track) put
     .lb-tools here instead of its own track whenever .lb-album-title is
     hidden, since a hidden element drops out of grid placement entirely
     and the next DOM child slides up to fill the gap left behind. Pinning
     each to its own column makes that column "theirs" regardless of
     whether the one before it is even in the layout. */
  grid-column: 1; justify-self: start; white-space: nowrap;
  color: rgba(255, 255, 255, .8); font-size: .85rem; font-weight: 700;
  font-variant-numeric: tabular-nums;
}
/* mountAlbum()'s own lightbox and a person's own gallery both show this
   (app.js, showPhoto()) -- "Parent: Child" for the former, "(Name)'s
   Photos" for the latter. The spotlight's single-photo view leaves both
   current.album and current.person unset, and keeps this hidden -- it has
   .lb-from-album, below, answering the same "which album" question for
   its one photo instead.
   min-width:0 is load-bearing: a grid item defaults to auto, which floors
   at its content's width and defeats the text-overflow ellipsis below --
   this lets the track (and the ellipsis) actually win under it. */
.lb-album-title {
  /* Capped, not just min-width:0 -- an unbounded auto column happily grows
     to fit a long "Parent: Child" title, which is exactly what starves the
     count/tools columns next to it back down to wrapping/overlapping on a
     narrow phone. This is the actual fix for that; min-width:0 alone (tried
     first) stopped the overlap but not the squeeze. */
  grid-column: 2; justify-self: center; min-width: 0; max-width: min(40vw, 380px);
  text-align: center;
  /* Two lines before cutting off, not one -- a title that only just
     overflows one line used to lose most of itself to the ellipsis right
     away. line-clamp needs the -webkit-box display to do the clamping
     (long-standing cross-browser way to do this despite the prefix); plain
     line-clamp is the newer unprefixed version, kept alongside for
     browsers that support that but not the -webkit- box model it rides on
     here. */
  display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2;
  -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis;
  color: rgba(255, 255, 255, .85); font-size: .85rem; font-weight: 600;
}
/* Shown by openSinglePhoto()'s one photo and, per-photo, by a person's own
   gallery (app.js, showPhoto() -- albumForPhoto()) -- a normal album
   lightbox never un-hides these, there's nowhere further to send you,
   you're already looking at that album's own grid underneath. */
/* Dim and small on purpose -- a label for the link right below it, not a
   second caption competing with .lb-cap above. */
.lb-from-album {
  color: rgba(255, 255, 255, .5); font-size: .82rem; font-weight: 500;
  text-align: center; margin: 0;
}
/* Size history: .84rem originally, grown to 1.05rem on request, eased back
   a step to .95rem on request -- still noticeably bigger than the .84
   start, just not as heavy as 1.05.
   margin-top pulls this up out of .lb-stage's own 14px flex gap (index.html
   -- .lb-from-album sits directly above, always together, see the comment
   there) -- shortened on request; the negative margin eats into that gap
   rather than fighting it, so this is closer to .lb-from-album than to
   .lb-stage's other children without changing the gap everywhere else. */
.lb-view-album {
  color: rgba(255, 255, 255, .82); font-size: .95rem; font-weight: 600;
  text-decoration: none; padding: 6px 4px; border-radius: 8px;
  margin-top: -8px; transition: color .16s;
}
.lb-view-album:hover { color: #fff; text-decoration: underline; }
.lb-tools { grid-column: 3; justify-self: end; display: flex; gap: 18px; }
/* Round, frosted-glass button, always visible -- close is the one action
   here that should always read as a button. Lightbox scrim is a fixed
   near-black regardless of site theme (.lightbox above), so like
   #player .modal-head's glass this is spelled out in hardcoded rgba rather
   than var(--glass-rim)/var(--surface) -- there's no light-mode case to
   cover here, just the one dark state. */
#lb-close {
  z-index: 4; width: 44px; height: 44px; border-radius: 50%;
  background: rgba(255, 255, 255, .1);
  backdrop-filter: blur(16px) saturate(1.7);
  border: 1px solid rgba(255, 255, 255, .22);
  box-shadow: 0 6px 20px rgba(0, 0, 0, .35),
    inset 0 1px 0 rgba(255, 255, 255, .3), inset 0 -1px 0 rgba(0, 0, 0, .12);
  color: rgba(255, 255, 255, .82); cursor: pointer; display: grid; place-items: center;
  transition: color .16s, background .16s, border-color .16s, transform .16s;
}
#lb-close:hover {
  color: #fff; background: rgba(255, 255, 255, .18);
  border-color: rgba(255, 255, 255, .3); transform: scale(1.06);
}
/* Same 44px circle and the same glass, sized and positioned to match
   #lb-close exactly (see #lb-nav's own comment history -- a mismatched
   size/shape here is exactly what read as "not lined up" before) -- but
   transparent until hovered, on request, rather than always showing the
   disc the way #lb-close does. The glyph itself stays visible either way,
   same as .lb-nav's bare arrows; only the circle behind it is hover-only. */
#lb-open {
  z-index: 4; width: 44px; height: 44px; border-radius: 50%;
  background: transparent; border: 1px solid transparent;
  color: rgba(255, 255, 255, .82); cursor: pointer; display: grid; place-items: center;
  transition: color .16s, background .16s, border-color .16s, transform .16s,
    backdrop-filter .16s, box-shadow .16s;
}
#lb-open:hover {
  color: #fff; background: rgba(255, 255, 255, .18);
  backdrop-filter: blur(16px) saturate(1.7);
  border-color: rgba(255, 255, 255, .3);
  box-shadow: 0 6px 20px rgba(0, 0, 0, .35),
    inset 0 1px 0 rgba(255, 255, 255, .3), inset 0 -1px 0 rgba(0, 0, 0, .12);
  transform: scale(1.06);
}
/* No circle, no fill -- just the arrow, with a drop-shadow standing in for
   the contrast the glass disc used to provide, since the photo behind it
   can be any colour. Same 44px box as before so the tap target doesn't
   shrink, just nothing painted around the glyph itself. */
/* position:absolute over the photo, not a dedicated grid column either
   side of it (.lightbox, above) -- on request: a fixed-width column per
   arrow was stealing that much real width from the photo itself on every
   screen, worst on a narrow phone, where losing ~44px off each side of an
   already-narrow viewport left the actual image visibly smaller than the
   screen with plain scrim showing to either side of it -- exactly the
   "black bars" reported. Floating on top instead costs the photo nothing;
   the bare-arrow-plus-drop-shadow look (comment above) was already built
   to read against an unpredictable photo behind it, so nothing about the
   look itself had to change, just what it's laid out against.
   translateY(-50%) is the vertical centring align-items:center on
   .lightbox used to do -- carried in transform now that this is
   positioned rather than a grid/flex child, so :hover and [disabled]
   below fold it into their own transform rather than overwriting it. */
.lb-nav {
  position: absolute; top: 50%; transform: translateY(-50%);
  z-index: 4; width: 44px; height: 44px;
  background: none; border: none;
  color: rgba(255, 255, 255, .78); cursor: pointer; display: grid; place-items: center;
  filter: drop-shadow(0 1px 5px rgba(0, 0, 0, .7));
  transition: color .16s, transform .16s;
}
.lb-nav.prev { left: 10px; }
.lb-nav.next { right: 10px; }
.lb-nav:hover { color: #fff; transform: translateY(-50%) scale(1.12); }
.lb-nav .i { width: 26px; height: 26px; stroke-width: 2.2; }
.lb-nav[disabled] { opacity: .28; cursor: default; transform: translateY(-50%); }
.lb-stage {
  position: relative; overflow: hidden;   /* clips a pinch-zoomed #lb-img to this box -- see app.js's own "swipe on touch" IIFE */
  margin: 0; min-width: 0; height: 100%;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 14px; padding: 60px 4px 26px;
}
/* Touch-drag overlay (app.js, "swipe on touch") -- sits over the whole
   stage, hidden until a finger actually starts dragging. Three slides wide
   (previous/current/next photo, whichever exist) in a flex track that
   .lb-img itself sits directly under -- rather than fading it in, this
   just covers it, then app.js updates .lb-img/.lb-cap to the new photo the
   instant the drag lands before uncovering it again, so there's no visible
   swap underneath. */
/* No background here, on purpose, after two attempts that both gave it
   one: app.js now hides the real #lb-img/#lb-cap/etc. outright for as
   long as a drag is active (visibility:hidden, beginDrag()/endDrag()),
   rather than leaving them showing and trying to paint something over
   them opaque enough to hide them. That makes covering not this element's
   job at all any more -- transparent is correct, since there is nothing
   left behind it that still needs hiding.
   History, for why this isn't just transparent by default: it originally
   was, which meant the real caption stayed visible through every part of
   this box its own sliding content didn't happen to paint over, and once
   the two were made to land at the same spot (.lb-drag-slide, below) that
   read as the previous photo's caption "sticking" and overlapping the
   dragged one. Fix #1 gave this a flat opaque background, which stopped
   that but also flattened the lightbox's translucent-plus-blur look to
   solid black for as long as a drag ran. Fix #2 tried matching
   .lightbox's own rgba/blur exactly instead -- still wrong, because this
   sits *inside* .lightbox, so its own copy of that same 94%-opaque scrim
   stacked on top of .lightbox's, compounding to ~99.6% opaque right where
   it mattered (1-(1-.94)²) while the rest of the lightbox stayed at a
   single layer's 94% -- reported live as looking solid black precisely
   during a drag, everywhere else unchanged. Hiding the source instead of
   covering it sidesteps needing this element to be opaque at all. */
.lb-drag {
  position: absolute; inset: 0; z-index: 2; overflow: hidden; display: none;
}
.lb-drag.on { display: block; }
.lb-drag-track { display: flex; width: 100%; height: 100%; }
.lb-drag-track.animate { transition: transform .28s var(--ease); }
/* Swipe-down-to-close (app.js, same "swipe on touch" IIFE) -- .lb-stage
   itself moves and .lightbox's own scrim fades as a finger drags down,
   both set directly via inline style while the drag is live so they track
   the finger 1:1 (same reasoning .car-nav/.topbar-offset have elsewhere
   for not transitioning a value that's being driven by touch input every
   frame). This class is only added for the snap-*back* -- released short
   of the close threshold -- so that one settle has something to animate
   into; closing outright (past the threshold) just calls closeLightbox()
   directly, no animated hand-off needed for a dialog that's disappearing
   anyway. */
.lb-stage.v-drag-snap { transition: transform .22s var(--ease); }
.lightbox.v-drag-snap { transition: background .22s var(--ease); }
/* flex-direction:column + the same 14px gap as .lb-stage (above) -- BUG,
   now fixed: without a caption underneath it, this centred just the image
   alone at true mid-height, while .lb-stage centres the image+caption
   pair as one shorter-than-full-height block, sitting a little above true
   centre to leave room for the caption below it. Same photo, two
   different rest positions -- reported live as the real image ("stuck"
   behind the drag overlay, still laid out by .lb-stage as always) and the
   dragged one sitting a few px apart. dragSlide() (app.js) now gives each
   slide its own <figcaption> to match -- .lb-stage figcaption's own
   styling (above) already covers it via that descendant selector, .lb-drag
   being inside .lb-stage in the markup, so nothing extra needed here for
   how it looks, just the layout to place it. */
.lb-drag-slide {
  flex: 0 0 100%; height: 100%;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 14px; padding: 60px 4px 26px;
}
.lb-drag-slide img {
  max-width: min(100%, 1500px); max-height: calc(100vh - 160px);
  width: auto; height: auto; object-fit: contain;
  border-radius: 12px; box-shadow: 0 24px 70px rgba(0, 0, 0, .7);
  background: #14121a;
}
/* 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;
  /* Hands pinch/pan on the photo entirely to app.js's own touch handling
     ("swipe on touch" IIFE) instead of the browser's native gestures --
     belt-and-suspenders alongside that handler's own preventDefault() and
     index.html's always-locked viewport meta, not load-bearing by itself. */
  touch-action: none;
}
/* #lb-img specifically (not the drag-slide previews, .lb-drag-slide img,
   which never get a transform of their own) -- transform-origin:center
   is the default, so pinch-zoom (app.js) anchors on the photo's own
   middle rather than wherever the fingers happen to be; .zoom-snap adds a
   transition only for the brief programmatic reset back to 1x
   (doResetZoom(true)), removed right after so a live pinch/pan stays
   instant, one frame behind the fingers, the whole rest of the time. */
#lb-img.zoom-snap { transition: transform .25s var(--ease); }
.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;
  /* Same "cherry" red family as :root[data-theme="dark"] -- see its own
     comment (styles.css, above). */
  --c1: #ef4f48;
  --c2: #e72935;
  --c3: #b52441;
  --accent:      #ea343f;
  --accent-ink:  #24040f;
  --accent-soft: #361618;
  --accent-pill:      #f12731;
  --accent-soft-pill: #361618;
  --pill-hot-ink: color-mix(in srgb, var(--accent-pill) 36%, var(--dim));
  --teal:        #37d6cb;
  --blue:        #4d9fff;
  --green:       #34d399;
  --grad-soft: linear-gradient(120deg, #2b1817, #271617 52%, #221416);
  --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;
  /* Belt-and-suspenders alongside the shorter placeholder text on mobile
     (app.js, paintSearchPlaceholder()) -- an ellipsis here is a graceful
     fallback for whatever that doesn't cover (an unusually narrow window,
     a typed query longer than the box), not the actual fix for the
     placeholder overflowing outright; the same input width that was too
     narrow for the full placeholder is nowhere near enough for a
     mid-length real query typed into it either, ellipsis or not. */
  overflow: hidden; text-overflow: ellipsis;
}
.search-input input::placeholder { color: var(--faint); font-weight: 500; }
.search-input kbd {
  /* border matches the badge's own text color (--faint) rather than the
     site's general --border token -- reported live as hardly visible in
     light mode, and measured out: --border there is #e6e0d9 against a
     white modal card, a ~25-38 point difference per channel, soft on
     purpose for the larger panel borders it's meant for but too close to
     read as an outline on a small badge like this one. --faint is already
     what the "Esc" text itself uses, so this reads as one cohesively-
     coloured badge -- text and outline together -- rather than a
     mismatched pairing of a bold label and a nearly-invisible box
     around it. */
  font: 700 .7rem var(--sans); border: 1px solid var(--faint);
  border-radius: 5px; padding: 2px 6px; color: var(--faint);
}
/* (hover: none) and (pointer: coarse) -- touch as the *primary* input --
   rather than a narrow-viewport breakpoint like the rest of this file
   mostly uses for "mobile": this is specifically about whether a
   keyboard (and so an actual Esc key) exists at all, which a viewport
   width can't answer -- a desktop browser window resized narrow still
   has a keyboard, and hiding the hint there would be wrong, not just
   imprecise. .search-input is display:flex with the input itself already
   flex:1 (above), so removing this from the layout entirely just lets
   the input claim the freed space rather than leaving a gap. */
@media (hover: none) and (pointer: coarse) {
  .search-input kbd { display: none; }
}
.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; }
/* min-width was 220px -- comfortably more than "Filter albums by name..."
   itself ever needed, but far more than wireResponsivePlaceholder() (app.js)
   needs once it's actually doing its job: that swaps to a shorter
   placeholder, and past that to no placeholder text at all, well before
   anything would actually clip, so this only has to stay wide enough for
   the icon and a little breathing room around it -- not wide enough for
   any particular piece of text, since there's no width this box can end up
   at where it isn't already showing whichever tier of text (down to none)
   actually fits. Left any higher than this, this is what was forcing
   search + count + the grid-size toggle to wrap apart from each other well
   before the search box actually needed to. */
.filter {
  display: flex; align-items: center; gap: 9px; flex: 1; min-width: 90px;
  padding: 10px 14px; border-radius: 999px;
  background: var(--surface); border: 1px solid var(--border);
}
/* Photos/Videos' own toolbar only (V.photos()/V.videos(), app.js) -- on
   request, capped to about half the width it used to grow to, not the
   Friends page's own .filter (.tabs-row, not .toolbar, so untouched by
   this). Still flex:1 underneath the cap, so the same responsive
   placeholder shrink (wireResponsivePlaceholder(), app.js) keeps working
   below that width -- this only stops it from growing past half the row
   at the wide end, it was never about the narrow one. Capping this
   without also pushing .toolbar-meta (below) to the far edge would just
   open a gap between the two instead of actually moving the grid-size
   toggle over, since .filter no longer eats all the remaining space on
   its own to leave it there. */
.toolbar .filter { max-width: 50%; }
.toolbar .toolbar-meta { margin-left: auto; }
.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; }
/* "30 of 30" over "albums" ("videos" on that page's own toolbar) rather than
   side by side on request -- the number stays var(--text) (still the part
   worth reading at a glance), the word underneath it drops to var(--faint)
   at a smaller size, same "bold stat over a dim label" pairing
   .hero-counts b already uses for the home page's own counts. */
.count {
  display: flex; flex-direction: column; align-items: flex-start;
  color: var(--faint); font-size: .72rem; font-weight: 600; white-space: nowrap; line-height: 1.4;
}
.count b { color: var(--text); font-size: .85rem; font-variant-numeric: tabular-nums; }
/* Count + grid-size toggle glued into one flex item (V.photos()/V.videos(),
   app.js) so .toolbar's own flex-wrap can only ever move the two of them
   together -- without this, .filter (flex: 1) is the one left to soak up
   whatever room a wrap frees, so a wrap that peeled the toggle off on its
   own (leaving count behind with the search box) also meant the search box
   suddenly ballooning wider right as it happened, on top of the toggle
   landing on a stray line by itself. */
.toolbar-meta { display: flex; align-items: center; gap: 18px; }
/* A little more air between the count and the grid-size toggle, plus a
   thin divider to actually separate them rather than just relying on
   whitespace -- on request ("more space... or make that look better").
   Scoped to when the toggle is actually sitting next to something inside
   .toolbar-meta (Photos/Videos' own toolbar) -- an album's own toolbar
   (below) has no count next to its toggle, so a divider with nothing to
   its left would read as a stray line floating in front of the buttons. */
.toolbar-meta .size-toggle { padding-left: 16px; border-left: 1px solid var(--border); }

/* Grid-size toggle (sizeToggleHtml()/wireGridSize(), app.js) -- sits at the
   end of the same toolbar as the filter/count on the Photos and Videos
   listing pages; margin-left: auto also pushes it to the far side of an
   album's own toolbar, which has nothing else in it. No shared track/pill
   behind the three of them -- each button is its own plain, background-less
   glyph at rest, same as .lb-nav's prev/next arrows and .icon-btn.ghost's
   close X on the lightbox, rather than a filled shape competing with the
   page around it. "on" reuses plain .icon-btn's own resting look (a faint
   var(--surface-2) circle) instead of introducing a new treatment just for
   this -- still enough to show which size is picked without any more
   contrast than a button already sitting elsewhere on this same page. */
.size-toggle { display: inline-flex; gap: 2px; margin-left: auto; }
.size-btn {
  display: flex; align-items: center; justify-content: center;
  width: 32px; height: 32px; border-radius: 999px; color: var(--faint);
  background: transparent; border: 1px solid transparent;
  transition: background .15s, color .15s, border-color .15s;
}
.size-btn:hover { color: var(--text); background: var(--bg-tint); }
.size-btn.on { color: var(--text); background: var(--surface-2); border-color: var(--border); }
.size-btn .i { width: 15px; height: 15px; }

.empty { padding: 60px 20px; text-align: center; color: var(--faint); }

/* ------------------------------------------------------------ responsive - */
@media (max-width: 1023px) {
  .app { grid-template-columns: 1fr; }
  .sidebar { display: none; }

  /* .safe-area-fill (index.html) -- see that element's own comment for
     the bug this exists to fix. z-index below .topbar/.tabbar (both 40)
     and .offline-banner (95, above) on purpose, not above them: whenever
     any of those three is actually the thing sitting at the top of the
     screen, it already paints over this and this is invisible, exactly
     as it should be -- this only ever needs to be seen at all once
     .topbar has hidden itself and nothing else has taken its place.
     Above .timeline (20) and plain scrolled content either way, which is
     the one comparison that actually matters here. */
  .safe-area-fill {
    position: fixed; top: 0; left: 0; right: 0; z-index: 39;
    height: env(safe-area-inset-top);
    background: var(--bg);
    pointer-events: none;
  }

  .topbar {
    /* var(--offline-h): see .offline-banner's own comment, above. */
    position: sticky; top: var(--offline-h, 0px); 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.
     Wrapped in max(env(safe-area-inset-top), ...), on top of that same
     tracking, for a reason that only ever shows up installed as an iOS
     PWA (same root cause as #lb-close's own fix, above -- see that
     comment for the full story): --topbar-offset's own hidden amount is
     .topbar's real measured height, which already has this exact same
     env(safe-area-inset-top) baked into it (it's sitting in .topbar's own
     padding-top, just above) -- so scrolled down far enough for the top
     bar to fully hide, this rule's own "+ env(safe-area-inset-top)" and
     --topbar-offset's "- (a height that itself already included that
     inset)" cancel out to exactly zero, not the inset. Confirmed directly
     doing that math: on a device with no real inset (env() is 0 there
     regardless), zero is harmless, nothing to sit under -- which is
     exactly why this never showed up outside an installed PWA, the one
     context that ever makes env(safe-area-inset-top) real. The max()
     simply refuses to let the calc push this above the one genuinely
     unsafe boundary, whatever the rest of the arithmetic works out to. */
  .timeline {
    top: max(env(safe-area-inset-top), calc(47px + max(10px, env(safe-area-inset-top)) + var(--topbar-offset, 0px)));
  }
  /* ?navbar=hidden (see .app's own comment, near the top of this file):
     .topbar is display:none then, not just visually hidden, so it takes up
     no real space above .timeline any more either -- the reservation just
     above assumes .topbar is actually there, and otherwise left that same
     ~57px sitting empty above the year strip, pinned flush against nothing
     rather than the top of the viewport. Higher specificity (adds an
     attribute selector) beats the plain rule above without needing source
     order to do it. */
  html[data-chrome="hidden"] .timeline { top: env(safe-area-inset-top); }
  .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; }
  /* Same reach, same reason, as .hero just above -- see .wrap.home's own
     comment (styles.css, desktop rule) for why it bleeds full-width at
     all now. -16px specifically, not var(--pad): #view's own side padding
     is this same fixed 16px at this width (#view, just above in this same
     block), not var(--pad) -- that only applies at >=1024px. */
  .wrap.home { margin: 0 -16px; }
  .home-inner { padding: 0 16px; }

  .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 { --tile-min: 140px; gap: 9px; }
  .grid.albums { --tile-min: 158px; gap: 12px; }
  /* Videos had no entry at all here -- BUG, now fixed: with nothing to
     override the desktop values (styles.css "grids", 275/195/360px) at
     this width, every one of the toggle's 3 sizes fell back to a single,
     already-as-narrow-as-it-gets column, either exactly filling the row
     (indistinguishable from each other) or, once a --tile-min exceeded
     the row's own real width, overflowing it instead -- reported live as
     the whole toggle doing nothing on this one grid specifically. */
  .grid.videos { --tile-min: 160px; }
  .grid.people { grid-template-columns: repeat(auto-fill, minmax(138px, 1fr)); gap: 12px; }
  /* Same grid-size toggle as the desktop rules (styles.css "grids"), re-based
     off this narrower width's own default minmax()es instead of the desktop
     ones -- a flat px offset from 215px reads very differently once the
     default itself is already down at 158px.

     "sm" itself is never offered at this width at all (.size-btn, below) --
     two sizes with real breathing room between them, confirmed directly to
     land on genuinely different column counts, held up better here than
     the same three-way squeeze desktop has room for (gridSize(), app.js,
     is what actually keeps a value picked on a wider visit from stranding
     the grid on a size with no button to get back off of here). */
  .grid.photos[data-size="lg"] { --tile-min: 190px; }
  .grid.albums[data-size="lg"] { --tile-min: 210px; }
  .grid.videos[data-size="lg"] { --tile-min: 230px; }
  .size-btn[data-size="sm"] { display: none; }
  .lb-nav { width: 40px; }
  .lb-nav .i { width: 24px; height: 24px; }
  /* .lb-drag-slide repeated alongside .lb-stage -- BUG, now fixed: this
     narrower padding was never mirrored onto the touch-drag overlay's own
     slides (styles.css, above), so on exactly the phone widths this media
     query targets, mid-swipe the sliding neighbour photo sat a few px
     lower than the real image "stuck" behind it, reported live as the
     drag looking broken. */
  .lb-stage, .lb-drag-slide { padding: 56px 2px 20px; }
  .modal-head { padding: 16px 16px 12px; }
  .modal-body { padding: 0 16px 16px; }
}

/* 330px, set on request. Six tabs' own natural content width -- checked
   directly, "Guestbook" is the one long label among them -- doesn't
   actually force .tabbar to overflow until the viewport drops below
   250px, narrower than any real phone; this is a wider, deliberately
   more cautious cutoff than that bare minimum.

   .tile.only-mobile-guestbook (above) is the complement of this same
   line: dropped from the tab bar here, it goes back to being shown on
   the More page there, so it's never in neither place. */
@media (max-width: 330px) {
  .tabbar a[href="#/guestbook"] { display: none; }
  .tile.only-mobile-guestbook { display: flex; }
  .crumb.only-mobile-guestbook { display: inline-flex; }
}

@media (max-width: 460px) {
  .grid.photos { grid-template-columns: repeat(2, 1fr); }
  /* "lg" used to have no override of its own here on the theory that it'd
     fall through to the 1023px breakpoint's own minmax() (above) and
     collapse to fewer columns on its own -- it doesn't: that rule only
     ever sets --tile-min, a plain custom property, and nothing at this
     narrower width still reads it once the line just above has already
     replaced grid-template-columns outright with a flat repeat(2, 1fr),
     so "lg" silently rendered identically to the default here. A real
     override, same as this file's other two grids get for their own
     "lg" at this width, actually forces the difference. ("sm" needs
     nothing here any more -- it isn't offered below 1023px at all, see
     .size-btn there.) */
  .grid.photos[data-size="lg"] { grid-template-columns: repeat(1, 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 -- */
/* Used to cap the whole page (crumb/eyebrow/h1/lede included) at 660px --
   every other page uses the plain, much wider .wrap, so this alone was why
   Contact read as centred and narrower than the rest of the site: .wrap's
   own margin:0 auto barely shows at 1120px (it fills the column next to the
   sidebar on most screens), but shrink that same wrap to 660px and the
   centering becomes the whole page's dominant feature. The cap belongs on
   the form itself instead -- a name/email/message form stretched to 1120px
   would look just as wrong the other way -- left where the heading above it
   already starts, not auto-centred, same reasoning as the pass-protected
   sign-in box (.gate). */
.contact-form {
  position: relative; display: block; margin-top: 26px; padding: clamp(20px, 3vw, 30px);
  max-width: 760px;  /* matches .feed -- the guestbook's and news's own width */
}
/* position:relative scopes the off-screen honeypot below to the form rather
   than the viewport */
.contact-form .hp { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }
/* Name and email, not the message -- a one-line answer stretched out to
   this card's full (now 760px) width looks stray in a way a multi-line
   message never does, which is exactly why this targets the Contact page's
   own #contact-form specifically rather than .field input generally: the
   guestbook's sign-in-style forms share .contact-form's other styling but
   not this, and were never asked to change. 500px matches what these
   actually measured before .contact-form itself grew from 560px to 760px
   (560 minus this same padding's own 30px each side) -- back to that exact
   width on request, not a fresh guess. */
#contact-form input[type="text"], #contact-form input[type="email"] { max-width: 500px; }

.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); }
/* Guestbook's "Your message" box specifically used to start collapsed to
   a single compact line and only open up to its real size (132px, what
   rows=5 -- the textarea's own HTML attribute -- renders at) once focused
   or typed into. Reported live as unwanted ("should no longer auto
   expand... should always remain expanded"): back to that plain rows=5
   block full-time instead, no JS or CSS of its own needed for it at all
   -- .field textarea's shared rule (above) already sets resize:vertical,
   and with no height override sitting on top of it here any more, the
   browser just renders the textarea at whatever its own rows="5" HTML
   attribute actually asks for, focused or not, empty or not. */
.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;
}
/* .field's own margin-bottom (18px, above) is sized for field-to-field
   gaps (Name to Message, say) -- reported live as reading too loose
   specifically where a counter is the last thing in the field, right
   before a button row (the guestbook message field, Sign/Cancel): the
   counter's own margin-top already puts real space under the textarea,
   so the full 18px on top of that read as a gap, not a rhythm. Trimmed
   to match the counter's own 6px here, so a field ending in a counter
   closes up the same amount above and below it. */
.field:has(.counter) { margin-bottom: 6px; }

.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 -- */
/* On request, formatted the same way as .featured-block (the home page's
   own "Photo + Video Spotlight" panel) instead of its previous look: a
   flat 1px border and the lighter --sh-1 shadow, not the "gradient
   showing through 2px of padding" ring .about-hero used to fake via a
   second, inner wrapper div. That div (.about-hero-in) is gone entirely
   now -- nothing left needs the extra layer, so the content sits directly
   in .about-hero itself. */
/* Flat background (no --grad-soft wash) and tighter padding/gap, on
   request/screenshot -- .featured-block itself (the panel this is
   otherwise matched to, comment above) keeps its own gradient wash, so
   this diverges from it on purpose in these two respects only. */
/* On request -- back to full .wrap width (not the 68ch the rest of the
   page uses), and the boxed-card look (border/background/shadow/padding)
   moved off this and onto the timeline below instead (.milestones, this
   file, further down) rather than staying here too. Just the masthead's
   own stacked layout left. */
.about-hero {
  display: flex; flex-direction: column;
  gap: clamp(8px, 1.2vw, 12px);
}

/* 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. */
/* repeat(4, 1fr) was a hard-coded assumption of exactly 4 entries -- a 5th
   milestone (and more are only going to keep coming as the years pass)
   either forced a wrap (grid has no way to centre an incomplete final row,
   its own now-fixed bug) or, sized for 5 up front, would have left an
   actual 4-entry moment stretched too wide instead. --ms-count (set inline
   per render, mountAbout()/V.about() in app.js, from MILESTONES.length
   itself) is the one number both this and .ms-line below key off of, so
   there's exactly one place that ever needs to know how many there
   actually are right now, and adding a 6th milestone someday needs no
   matching CSS change at all. */
/* Boxed-card look on request -- moved here from .about-hero (above),
   which gave it up rather than keeping the same treatment on both.
   --ms-pad holds the padding as its own custom property, not just a
   literal in the padding shorthand below, because .ms-line's left/right
   (and top, further down) need that exact same value added in --
   confirmed directly (reported live: the line no longer lined up with
   the dots once this box gained real padding): percentages and fixed
   offsets on an absolutely positioned child are both measured from the
   *padding edge* of a position:relative ancestor, same reference point
   regardless of how much padding that ancestor actually has, while the
   grid's own content (the dots) sits inset from that same edge by
   however much padding there now is. Before this box existed that
   padding was 0, so the two happened to already agree with no offset
   needed; now that it isn't, .ms-line has to add it back in explicitly
   on every side it's positioned from, or it stays anchored to the
   padding edge while the dots move inward and it drifts out of line. */
.milestones {
  --ms-pad: clamp(16px, 2.2vw, 26px);
  position: relative; margin-top: 48px;
  border-radius: var(--r-lg); background: var(--surface);
  border: 1px solid var(--border); box-shadow: var(--sh-1);
  padding: var(--ms-pad);
  display: grid; grid-template-columns: repeat(var(--ms-count, 4), 1fr); gap: 18px;
}
/* left/right: calc(50%/--ms-count) is the general form of the 12.5% this
   replaced (50%/4) -- half a column's share in from each edge is exactly
   where the first and last dot's own centres sit, for any column count,
   not just 4 -- plus --ms-pad (above) now that this box has its own
   padding to account for. */
/* var(--border-2) on request ("make the line lighter") -- var(--border)
   read fine against the page background this sat on before, but next to
   its own box's --surface background now it's too close in tone to
   still read clearly as a line. */
.ms-line {
  position: absolute; top: calc(var(--ms-pad) + 6px);
  left: calc(var(--ms-pad) + 50% / var(--ms-count, 4));
  right: calc(var(--ms-pad) + 50% / var(--ms-count, 4));
  height: 2px; background: var(--border-2);
}
.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%;
  /* This ring is a cutout, not a real border -- it's just var(--surface)
     (or, now, --surface-2) painted in a ring around the dot so .ms-line
     reads as passing *behind* it rather than touching it. That only
     works as long as the ring's own colour actually differs from
     whatever the dot is sitting on: back when .milestones had the page's
     own background, --surface (a card colour) stood out against it
     fine; now that .milestones is itself a --surface card, a --surface
     ring on top of a --surface box is invisible -- confirmed directly,
     reported live as the ring vanishing entirely. --surface-2, the
     shade this site already reaches for anywhere something needs to read
     as "one layer up" from a --surface panel, is what actually keeps it
     visible now. */
  background: var(--grad); box-shadow: 0 0 0 5px var(--surface-2);
}
.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.
   padding-left/.ms-line's own top/bottom/left all add var(--ms-pad) on top
   of their original literal numbers now, same fix and same reason as the
   desktop .ms-line rule above: this box didn't used to have any padding
   of its own to sit inset from, so 28px (the rail's own width, dots
   included) was, before, measured from the box's true edge and correctly
   landed the rail right at it. Once this got a real box (border/
   background) that 28px was still just the rail's own width, not
   however far from the box's edge the rail should actually start --
   reported live as sitting flush against the left border with no
   margin at all. Adding --ms-pad on top keeps the rail the same 28px
   wide, just inset by the same amount the box's own padding already
   gives everything else. */
@media (max-width: 720px) {
  .milestones { grid-template-columns: 1fr; gap: 26px; padding-left: calc(var(--ms-pad) + 28px); }
  .ms-line {
    top: calc(var(--ms-pad) + 6px); bottom: calc(var(--ms-pad) + 6px);
    left: calc(var(--ms-pad) + 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);
}
/* On request, "About Lincoln 4 Life" only -- .letter alone (above) is
   shared with "About Lincoln Heights", which stays plain body copy, so
   this is a second class layered on top rather than a change to .letter
   itself. Patrick Hand (index.html) needs a noticeably bigger size and
   looser line-height than the sans body font it replaces here -- a
   handwriting face's varied stroke widths read as smaller and more
   cramped than a grotesque at the same literal size. First attempt at
   this used Caveat, a flowing cursive script -- reported live as too
   scribbly to read comfortably at paragraph length regardless of size;
   Patrick Hand keeps the handwritten feel with plain, upright, separated
   letterforms instead, closer to neat printing than joined-up cursive. A
   slight rotate is the one added flourish, restrained on purpose: the
   font change alone already reads as "handwritten" clearly enough that
   leaning on paper textures or a torn edge too would be piling on rather
   than adding anything. */
.handwritten {
  transform: rotate(-1deg);
  background: color-mix(in srgb, var(--surface) 92%, #d9a85e 8%);
}
.handwritten p {
  font-family: "Patrick Hand", cursive; font-size: 1.25rem; line-height: 1.6;
  color: var(--text);
}
/* Caveat here specifically, not Patrick Hand -- a signature reads as a
   flourish, distinct from the note's own body text, in a way a real
   handwritten note's actual signature usually is too (a name signed in
   a looser, faster hand than the neater writing above it). Sized up
   from Patrick Hand's own 1.5rem: Caveat's thinner, more cursive strokes
   read smaller at the same literal size. */
.handwritten figcaption {
  font-family: "Caveat", cursive; font-size: 1.9rem; font-weight: 700;
  letter-spacing: normal;
  /* On request -- .letter figcaption's own border-top (above) is a plain
     divider line, fine for body-copy signoffs but one more straight rule
     sitting under a handwritten note than the wobble of the rest of it
     called for; removed here rather than off .letter itself since this
     is the only live .letter usage today, but a future plain-.letter
     caller would still get the line back by default. */
  border-top: none;
}

/* -------------------------------------------------------------- guestbook -- */
.guestbook-form { max-width: 620px; }
/* #gb-new-toggle ("Sign the guestbook", V.guestbook()/app.js) sits right
   after the page's own .lede -- p has margin:0 site-wide (reset, near the
   top of this file) and .lede adds nothing back on its bottom edge, only
   its own top (inline style, that one paragraph), so with nothing here
   the two sat flush against each other, reported live as a missing gap.
   20px, not a fresh number -- matches .news-admin's own margin-top
   (below) for the exact same "toggle button right under a page's lede"
   spot on the News page. */
#gb-new-toggle { margin-top: 20px; }

/* 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. */
/* Plain hairline between "Sign the guestbook" (and the form it reveals)
   and everything about browsing entries below it, on request -- .guestbook-
   toolbar's own top margin (below) still does most of the spacing here,
   this is just the visible line separating the two zones. */
.section-rule { border: 0; border-top: 1px solid var(--border); margin: 24px 0 0; }
.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 {
  display: grid; place-items: center; width: 26px; height: 26px;
  color: var(--faint); background: none; border: none; border-radius: 7px;
  cursor: pointer; transition: color .16s, background .16s;
}
.gb-del:hover { color: var(--accent); background: var(--bg-tint); }
.gb-del .i { width: 15px; height: 15px; stroke-width: 2.1; }

/* 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; }

/* --------------------------------------------------------------- admin -- */
/* #admin-badge (sidebar) and #admin-badge-m (mobile top bar) -- empty
   markup, not just hidden, whenever this browser isn't signed in as admin
   (paintAdminBadge(), app.js), so :empty is what actually removes it from
   each layout's own flex flow rather than leaving a phantom gap behind
   (.sidebar-foot's own `gap`, .topbar-actions' own `gap`, either one still
   counting a present-but-empty flex item otherwise). */
.admin-badge:empty { display: none; }
.admin-badge { display: flex; }
/* Base look for .admin-trigger, shared by both sizes -- reset first (a
   plain <button>, same reason .chip needs one of its own), then the
   accent-tinted "you're in a special state" look a bare .pill.hot used to
   carry on its own, now carried by this instead since that pill is gone,
   folded into one clickable control together with the chevron that shows
   it opens something. #admin-badge/#admin-badge-m (below) are what
   actually size the two differently; this rule is everything they share.
   adminDropdownInit() (app.js) is what the chevron and the dropdown it
   opens (.admin-dropdown, below) actually belong to. */
.admin-trigger {
  display: inline-flex; align-items: center; gap: 8px;
  border: none; appearance: none; -webkit-appearance: none;
  background: var(--accent-soft); color: var(--accent);
  font: 700 .84rem var(--sans); cursor: pointer;
  transition: background .16s;
}
.admin-trigger:hover { background: color-mix(in srgb, var(--accent-soft) 78%, var(--accent) 14%); }
.admin-trigger .i { width: 15px; height: 15px; stroke-width: 2.4; transition: transform .16s var(--ease); }
/* Flips the chevron to match once its own menu is actually open -- same
   idea a native <select>'s own arrow already trades on, closed reading as
   "more below" and open as "already open". aria-expanded is set/cleared
   by adminDropdownInit() (app.js), not this rule -- purely a visual
   response to state that function already has to track anyway for real
   accessibility reasons. */
.admin-trigger[aria-expanded="true"] .i { transform: rotate(180deg); }
/* "Kind of button sized" on request -- this is the one sitting in the
   sidebar directly above Search and the theme toggle, both already
   full-width .chip buttons of their own; matching their padding/radius/
   width here reads as one more row in that same list rather than a
   separate, oddly small thing above it. */
#admin-badge .admin-trigger {
  width: 100%; justify-content: center; padding: 9px 12px; border-radius: 11px;
}
/* Still a compact pill on mobile -- .topbar-actions has no spare room
   next to search and theme for anything sized like the sidebar's own
   version above. */
#admin-badge-m .admin-trigger {
  padding: 5px 10px 5px 12px; border-radius: 999px; font-size: .74rem;
}
#admin-badge-m .admin-trigger .i { width: 13px; height: 13px; }

/* The dropdown itself (#admin-dropdown, index.html; adminDropdownInit(),
   app.js) -- position:fixed and placed/sized entirely from JS (that
   function's own positionDropdown()): .sidebar is position:sticky, which
   always opens its own stacking context regardless of z-index, so a
   panel positioned relative to something inside it could never reliably
   paint over page content that comes later in the DOM. */
.admin-dropdown {
  position: fixed; z-index: 120;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--r-lg); box-shadow: var(--sh-3); padding: 6px;
  display: flex; flex-direction: column; gap: 2px;
}
.admin-dropdown-item {
  display: flex; align-items: center; gap: 10px; width: 100%;
  padding: 9px 12px; border-radius: 8px; border: none; background: none;
  color: var(--text); font: 600 .86rem var(--sans); text-align: left;
  cursor: pointer; transition: background .14s;
}
.admin-dropdown-item:hover { background: var(--bg-tint); text-decoration: none; }
.admin-dropdown-item .i { width: 16px; height: 16px; stroke-width: 2.1; color: var(--dim); flex: none; }

/* News composer -- "New post" toggle plus the form it reveals. Empty
   markup (renderNewsAdmin(), app.js) when not admin, same :empty reasoning
   as .admin-badge above. */
.news-admin:empty { display: none; }
.news-admin { margin-top: 20px; }
.news-form .hero-cta { margin-top: 4px; }
/* .field input's shared width:100% (styles.css, "contact") stretches a
   plain date picker across this card's full width -- reasonable for a
   name or email field, not for content that's only ever "MM/DD/YYYY"
   plus a small icon. 200px comfortably fits that content with a little
   room to spare, confirmed directly, without inheriting the rest of this
   card's own width. */
.news-form input[type="date"] { max-width: 200px; }

/* A small, deliberately plain rich-text box -- bold/italic/link/bulleted-
   list, nothing else (see the toolbar comment on renderNewsAdmin() in
   app.js for why). Styled as one bordered unit so it reads as a single
   field despite being two elements (the toolbar, the editable body). */
.rte {
  border-radius: var(--r-sm); border: 1px solid var(--border-2);
  background: var(--surface-2); overflow: hidden; transition: border-color .16s;
}
.rte:focus-within { border-color: var(--accent); }
.rte-toolbar { display: flex; gap: 4px; padding: 6px; border-bottom: 1px solid var(--border); }
.rte-btn {
  display: grid; place-items: center; width: 32px; height: 32px;
  border-radius: 8px; border: none; background: none; color: var(--dim);
  cursor: pointer; transition: color .16s, background .16s;
}
.rte-btn:hover { color: var(--text); background: var(--bg-tint); }
/* .on -- this command is active at the current cursor position/selection
   (document.queryCommandState(), updateToolbarState() in app.js), same
   "on" naming .tl-year/.tab already use elsewhere for the same idea. */
.rte-btn.on { color: var(--accent); background: var(--accent-soft); }
.rte-btn .i { width: 17px; height: 17px; stroke-width: 2; }
.rte-body {
  min-height: 140px; max-height: 420px; overflow-y: auto;
  padding: 12px 14px; font: 500 .96rem/1.6 var(--sans); color: var(--text);
}
.rte-body:focus { outline: none; }   /* .rte's own :focus-within border is the focus indicator */
.rte-body p { margin: 0 0 .8em; }
.rte-body p:last-child { margin-bottom: 0; }
/* Matches .post-body h3 (the published view) closely enough that a
   heading doesn't visibly change size or spacing the moment a post goes
   live -- color is already .rte-body's own var(--text) throughout, unlike
   .post-body's dimmer default, so no separate override needed for that
   part here. */
.rte-body h3 { font-size: 1.15rem; margin: 14px 0 6px; }
.rte-body h3:first-child { margin-top: 0; }
.rte-body a { color: var(--accent); }
.rte-body:empty::before {
  content: attr(data-placeholder); color: var(--faint); pointer-events: none;
}
/* Matches .post-body img.inline-img (the published view) closely enough
   that an image doesn't visibly resize the moment a post goes live --
   just without that rule's border/margin, which read as an odd inset
   frame while still sitting inside the editor's own bordered box. Same
   75% cap too, on request, so the preview while writing isn't lying
   about the size the image will actually publish at. */
.rte-body img {
  max-width: 75%; max-height: 70vh; object-fit: contain;
  display: block; border-radius: var(--r-sm); margin: 4px 0;
}
