/* ============================================================
   Ryan Beck — shared styles
   ============================================================ */
:root{
  --ink:#241c14;
  --ink-soft:#4a3d30;
  --ink-faint:#7a6a58;
  --parchment:#f4ead9;
  --parchment-2:#eadcc6;
  --parchment-edge:#d9c7ab;
  --void:#0a0908;
  --ember:#c8722a;
  --ember-hot:#f2a44c;
  --gold:#c9a227;
  --rule:rgba(36,28,20,.16);
  --serif:"Iowan Old Style","Palatino Linotype",Palatino,"Book Antiqua",Georgia,"Times New Roman",serif;
  --sans:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Inter,Roboto,Helvetica,Arial,sans-serif;
  --maxw:1120px;
  /* How long the wizard takes to light up. His body and his staff ember
     share it deliberately -- they were the same .55s before and still
     drifted apart in feel, because the staff was not transitioning at all
     (see the note above .staff-glow). One value, one ramp. */
  --glow-in:.55s;
}
*,*::before,*::after{box-sizing:border-box}
html{-webkit-text-size-adjust:100%}
body{
  margin:0; font-family:var(--serif); color:var(--ink);
  background:var(--parchment);
  line-height:1.6; font-size:clamp(16px,1.05vw + 13px,18.5px);
  text-rendering:optimizeLegibility;
}
img{max-width:100%;height:auto;display:block}
a{color:inherit}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
.wrap{max-width:var(--maxw);margin:0 auto;padding:0 clamp(18px,4vw,40px)}

/* ---------- display type ---------- */
.runic{font-family:var(--serif);text-transform:uppercase;letter-spacing:.34em;font-size:.72rem;color:var(--ink-faint)}
h1,h2,h3{line-height:1.15;font-weight:600;margin:0 0 .5em}
h1{font-size:clamp(2.1rem,4.6vw,3.5rem);letter-spacing:-.015em}
h2{font-size:clamp(1.5rem,2.6vw,2.1rem)}
h3{font-size:1.15rem}
.lede{font-size:clamp(1.05rem,1.5vw,1.28rem);color:var(--ink-soft);max-width:62ch}
.rule{height:1px;background:linear-gradient(90deg,transparent,var(--rule)12%,var(--rule) 88%,transparent);border:0;margin:clamp(38px,6vw,72px) 0}

/* ============================================================
   THE HALL (landing page)
   ============================================================ */
body.hall-page{
  background:#e9dac9;              /* sampled from the artwork's own paper */
  color:#4a3d30; min-height:100svh;
  display:flex; flex-direction:column; align-items:center; justify-content:center;
  padding:clamp(8px,2vw,20px) 0;
}
.hall-mark{
  width:min(620px, 48vw, calc((100svh - 150px) * .8)); margin:0 auto clamp(2px,1vw,10px); padding:0;
  font-size:0; line-height:0;
}
.hall-mark img{width:100%;height:auto}

.hall{
  position:relative;
  /* the third term keeps the wordmark + artwork inside one screen — the
     artwork is 4:3, so height drives width once the viewport is short.
     150px is the wordmark plus paddings; the nav needs no reservation
     because it sits ON the artwork, down on the sand. */
  width:min(1560px, 98%, calc((100svh - 150px) * 1.334));
  aspect-ratio:1448/1086; margin:0 auto;
  isolation:isolate;
}
.hall-art, .hall picture{
  position:absolute; inset:0; width:100%; height:100%;
  object-fit:contain; pointer-events:none; z-index:3;
}
.hall-defs{position:absolute;width:0;height:0;overflow:hidden}

/* --- a doorway --- */
.portal{
  position:absolute; z-index:2; display:block; text-decoration:none;
  left:var(--l); top:var(--t); width:var(--w); height:var(--h);
  clip-path:var(--clip);
  overflow:hidden; background:#050403;
  -webkit-tap-highlight-color:transparent; cursor:pointer;
  /* long-press is ours (portal.js), not the browser link menu's */
  -webkit-touch-callout:none; user-select:none;
}
.portal > *{position:absolute;inset:0;display:block;pointer-events:none}

/* the world beyond the door.

   The zoom rides the `scale` property and the drift rides `transform`, and
   they must stay on separate properties. Both used to be `transform`, and
   the drift was declared inside the awake rule -- so leaving the door
   canceled an animation and changed the property in the same style change,
   and a browser starts NO transition for that. The scene snapped from
   whatever the drift had reached straight back to 1 while the light was
   still fading: the zoom jump Ryan reported. See "Animation cancels eat
   transitions" in CLAUDE.md; the staff glow had the identical bug.

   The drift is therefore always applied and merely paused at rest, never
   added and removed. Paused inside its 1.35s delay it contributes nothing,
   so an untouched door sits at exactly scale 1; paused mid-drift it holds
   its last value, which is invisible behind a veil at brightness .07 and
   means the next hover resumes the dolly instead of restarting it. */
.portal .scene{
  inset:var(--scene-bleed,-11%);
  background-size:cover; background-position:center 46%;
  scale:1;
  animation:portal-drift 13s ease-in-out 1.35s infinite alternate;
  animation-play-state:paused;
  filter:brightness(var(--scene-rest,.42)) saturate(.45) contrast(.92) blur(1.4px);
  transition:scale 1.35s cubic-bezier(.19,.75,.2,1), filter .9s ease;
  will-change:scale,transform,filter;
}
/* The shadow the scene sits in. Not a blackout -- a resting door is a
   passage you can just make out, and the depth comes from three stacked
   gradients rather than from one flat black.

   Read them bottom-up, which is how they stack:

   1. THE MOUTH. Open in the middle, closing to solid black at the walls.
      This is the whole depth cue: the eye reads a converging dark surround
      with something faintly lit at the far end as a corridor, and reads the
      same image at a flat alpha as a dirty window. The center stop is what
      you tune -- lower shows more, higher shrouds it.
   2. THE FLOOR AND LINTEL. A near-black band top and bottom. The mouth
      gradient is elliptical and leaves the top and bottom corners lighter
      than the sides, which reads as a hole cut in a wall; weighting the
      floor puts the viewer on the near side of it.
   3. THE HAZE. A cold, thin wash hanging where the far end is. Distance
      desaturates and lifts blacks -- aerial perspective -- and against the
      warm `.ember` glow below it separates near from far by color, not
      just by value.

   All three ride the same opacity, which is what fades to .02 when the door
   wakes, so the lit state is untouched and the crossfade is one property. */
.portal .veil{
  background:
    radial-gradient(72% 56% at 50% 42%, rgba(46,54,70,.20) 0%, rgba(20,24,32,.07) 55%, transparent 74%),
    linear-gradient(to bottom, rgba(0,0,0,.36) 0%, rgba(0,0,0,.04) 24%, rgba(0,0,0,.09) 56%, rgba(0,0,0,.52) 100%),
    radial-gradient(118% 94% at 50% 44%, rgba(6,5,4,.26) 0%, rgba(4,3,2,.70) 44%, rgba(1,1,1,.93) 76%, #000 100%);
  transition:opacity .85s ease;
}
/* an ember that never quite goes out */
.portal .ember{
  background:radial-gradient(58% 42% at 50% 74%, rgba(210,118,40,.5), transparent 72%);
  mix-blend-mode:screen; opacity:.18;
  animation:ember-breathe 7.5s ease-in-out infinite;
  transition:opacity .6s ease;
}
/* the ring of fire, licking the inside of the doorframe */
.portal .swirl{
  transform:translateY(-50%) scaleY(1.14);
  inset:auto; left:-35%; top:50%; width:170%; height:auto; aspect-ratio:1;
  background:image-set(url("../scenes/fire-a.webp") type("image/webp"), url("../scenes/fire-a.png") type("image/png")) center/100% 100% no-repeat;
  mix-blend-mode:screen; opacity:0;
  filter:saturate(1.15) contrast(1.05);
  animation:fire-spin 24s linear infinite;
  transition:opacity .75s ease;
}
.portal .swirl.b{
  left:-48%; width:196%;
  background-image:image-set(url("../scenes/fire-b.webp") type("image/webp"), url("../scenes/fire-b.png") type("image/png"));
  filter:blur(6px) saturate(1.2);
  animation:fire-spin-r 15s linear infinite;
}

/* inner frame that opens up as the scene pushes out — the vertigo pull */
.portal .rim{
  box-shadow:inset 0 0 20px 12px rgba(0,0,0,.95), inset 0 0 56px 26px rgba(0,0,0,.7);
  transform:scale(1.16);
  transition:transform 1.35s cubic-bezier(.19,.75,.2,1), box-shadow .9s ease;
}

/* ---- awake ---- */
.portal:is(:hover,:focus-visible,.is-awake) .scene{
  scale:var(--scene-zoom,1.46);
  filter:brightness(1.22) saturate(1.06) contrast(1.02) blur(0);
  animation-play-state:running;
}
.portal:is(:hover,:focus-visible,.is-awake) .veil{opacity:.02}
.portal:is(:hover,:focus-visible,.is-awake) .ember{opacity:0}
.portal:is(:hover,:focus-visible,.is-awake) .swirl{opacity:.92;animation:fire-spin 24s linear infinite, flare 6s ease-in-out infinite}
.portal:is(:hover,:focus-visible,.is-awake) .swirl.b{opacity:.55;animation:fire-spin-r 15s linear infinite, flare-b 4.4s ease-in-out infinite}
.portal:is(:hover,:focus-visible,.is-awake) .rim{
  transform:scale(1);
  box-shadow:inset 0 0 10px 2px rgba(0,0,0,.35), inset 0 0 44px 10px rgba(120,44,0,.28);
}
.portal:focus-visible{outline:2px solid rgba(255,200,120,.9);outline-offset:3px}

/* How close a door stands to its scene. A door shows `1 / (bleed x zoom)` of
   the art's height and `0.741x` that in width, so the two numbers together
   decide how much of the picture you actually get -- at the defaults, 56% of
   the height at hover.

   The blog door overrides both because its art is a close-up photograph
   rather than a room: at the default framing the blue piece filled the whole
   opening, which is not a portal into anything. Pulled back it shows the
   piece plus a ring of the pieces around it, which is the width of the
   photograph's own field. This is the floor -- the scene cannot show more
   puzzle than the photograph contains, and it is already at nearly its full
   width, so a wider view needs a wider photograph. */
/* `--scene-rest` is the resting exposure, and it is per-door because the three
   pictures are nowhere near each other in level: the projects art is a night
   skyline with a median of 37, the fiction corridor sits at 124, the puzzle
   at 165. One exposure across all three left the corridor legible and the
   skyline solid black. These land all three at a median around 25 and a p90
   around 37 behind the veil -- present, and still firmly in shadow. Re-derive
   them if the art changes; `tools/prep-scene.py` prints the percentiles. */
/* The anvil sits on a large gold disc, so unlike the other two it has real
   mass at mid-brightness -- only 6% of it is near-black, against 86% for the
   voxel version this replaced. That version needed 1.15, because it was one
   thin bright subject on black and dimming it at all erased it. This one is
   the opposite problem: left at 1.15 the disc glows through the veil as a
   bright slab and the door stops reading as shadowed. Tuned so the disc lands
   near the p95 the other two doors sit at. */
.portal[data-portal="projects"]{ --scene-rest:.75 }
.portal[data-portal="fiction"] { --scene-rest:.36 }
.portal[data-portal="blog"]    { --scene-bleed:-3%; --scene-zoom:1.06; --scene-rest:.46 }

@keyframes fire-spin{from{transform:translateY(-50%) scaleY(1.14) rotate(0deg)}to{transform:translateY(-50%) scaleY(1.14) rotate(360deg)}}
@keyframes fire-spin-r{from{transform:translateY(-50%) scaleY(1.14) rotate(360deg)}to{transform:translateY(-50%) scaleY(1.14) rotate(0deg)}}
@keyframes flare{0%,100%{opacity:.82}50%{opacity:1}}
@keyframes flare-b{0%,100%{opacity:.4}50%{opacity:.72}}
/* A multiplier on top of the zoom, not an absolute size: 1.46 x 1.1096 =
   1.62, the same reach as before, and the translate lands identically
   because it is scaled by both factors either way. */
@keyframes portal-drift{
  from{transform:scale(1) translate3d(0,0,0)}
  to{transform:scale(1.1096) translate3d(0,-2.4%,0)}
}
@keyframes ember-breathe{0%,100%{opacity:.08}50%{opacity:.22}}

/* --- the wizard --- */
.wizard{
  position:absolute; z-index:4; display:block; cursor:pointer;
  left:28.5%; top:46%; width:16%; height:25%;
  clip-path:url(#clip-wizard);
  background-image:image-set(url("../portal-hall.webp") type("image/webp"), url("../portal-hall.png") type("image/png"));
  background-size:625% 400%;
  background-position:33.93% 61.33%;
  background-repeat:no-repeat;
  filter:none; transition:filter var(--glow-in) ease, transform var(--glow-in) ease;
  -webkit-tap-highlight-color:transparent;
  /* long-press is ours (portal.js), not the browser link menu's */
  -webkit-touch-callout:none; user-select:none;
}
.wizard::after{
  content:""; position:absolute; inset:0;
  background:radial-gradient(60% 50% at 48% 40%, rgba(255,196,102,.55), transparent 72%);
  opacity:0; transition:opacity var(--glow-in) ease; mix-blend-mode:screen;
}
/* The full-body glow belongs to a deliberate visit — pointer, keyboard
   focus, or a long press on touch (.is-held, from portal.js). His turn in
   the wake cycle lights only the staff ember below — a wizard flaring up
   on a timer looked possessed rather than inviting. */
.wizard:is(:hover,:focus-visible,.is-held){
  filter:drop-shadow(0 0 14px rgba(255,190,110,.85)) drop-shadow(0 0 34px rgba(255,150,60,.45)) brightness(1.06);
  transform:translateY(-.4%);
}
.wizard:is(:hover,:focus-visible,.is-held)::after{opacity:1}
.wizard:focus-visible{outline:2px solid rgba(255,200,120,.9);outline-offset:4px}

/* The carved knot at the top of his staff carries wizard-light: a white
   core with a cold blue halo and two faint crossed rays — Glamdring-cave
   light, not candle light. Dim and breathing at rest; hovering him (or his
   turn in the wake cycle, or a long press) flares it. It lives OUTSIDE
   .wizard because his clip-path would crop any halo to his silhouette —
   which is also why it must stay pointer-events:none, sitting over his hit
   area. Position: the knot is at ~(0.79, 0.11) of the wizard's box; box
   left 28.5% + 0.79x16% = 41.1%, top 46% + 0.11x25% = 48.8%. */
.staff-glow{
  position:absolute; z-index:5; pointer-events:none;
  left:41.1%; top:48.8%; width:5.6%; aspect-ratio:1;
  transform:translate(-50%,-50%);
  background:
    radial-gradient(closest-side,
      rgba(255,255,255,.95),
      rgba(224,240,255,.5) 22%,
      rgba(150,198,255,.22) 46%,
      rgba(110,170,255,.08) 62%,
      transparent 76%);
  mix-blend-mode:screen; filter:blur(.4px);
  opacity:.34; transition:opacity var(--glow-in) ease, transform var(--glow-in) ease;
  animation:staff-breathe 5.5s ease-in-out infinite;
}
/* The resting breath rides `filter`, NOT `opacity`, because `opacity` is
   what the flare transitions. Breathing on opacity meant hovering him had
   to cancel the animation (`animation:none`) in the same style change that
   raised opacity -- which starts no transition at all, so the ember hit
   full brightness instantly while his body took .55s. That is the "staff
   is faster than the body" Ryan reported, and passing the cursor across
   the staff showed it worst because the snap fired on every crossing.
   On two properties nothing has to be canceled, both ends of the flare
   ease over --glow-in, and he keeps breathing while lit. */

/* the rays: two crossed streaks, slightly off-axis like a lens flare.
   They inherit the element's opacity, so they dim and flare with it. */
.staff-glow::before, .staff-glow::after{
  content:""; position:absolute; left:50%; top:50%;
  width:240%; height:22%;
  background:radial-gradient(50% 50% at 50% 50%, rgba(228,242,255,.5), rgba(160,205,255,.14) 58%, transparent 78%);
}
.staff-glow::before{ transform:translate(-50%,-50%) rotate(18deg) }
.staff-glow::after{ transform:translate(-50%,-50%) rotate(105deg) }
.wizard:is(:hover,:focus-visible,.is-awake,.is-held) + .staff-glow{
  opacity:1; transform:translate(-50%,-50%) scale(1.5);
}
@keyframes staff-breathe{
  0%,100%{filter:blur(.4px) brightness(.80)}
  50%{filter:blur(.4px) brightness(1.08)}
}

/* wizard hint label */
.wizard-hint{
  position:absolute; z-index:4; left:28.5%; top:72.5%; width:16%;
  text-align:center; font-family:var(--sans); font-size:clamp(9px,.8vw,12px);
  letter-spacing:.22em; text-transform:uppercase; color:#8a7862;
  opacity:.0; transition:opacity .5s ease; pointer-events:none;
}
.hall:hover .wizard-hint{opacity:.55}

/* The nav sits ON the artwork at every size — down on the sand, where
   there's nothing to cover — so it can never be pushed off the bottom of
   the screen. The drawing's rocks run out at about 78% of the frame, so the
   nav starts just below that and clears the wizard entirely. On desktop it
   doubles as the plain-text path in beside the doors. */
.hall-nav{
  display:flex; gap:clamp(12px,3.4vw,26px); flex-wrap:nowrap; justify-content:center;
  font-family:var(--sans); font-size:clamp(.62rem,2.6vw,.78rem); letter-spacing:.18em;
  text-transform:uppercase;
  position:absolute; left:50%; transform:translateX(-50%);
  width:100vw; bottom:13%; z-index:5; padding:0 10px;
}
.hall-nav a{color:#5c4c3a;text-decoration:none;padding:9px 2px;white-space:nowrap;border-bottom:1px solid rgba(92,76,58,.35);text-shadow:0 1px 0 rgba(255,250,240,.55)}
.hall-nav a:hover{color:#241c14;border-color:#241c14}
.hall-nav a:active{color:#241c14}
@media (max-width:760px){ .wizard-hint{display:none} }

/* On a portrait phone the artwork is landscape, so it leaves a lot of empty
   page. Scale it past the viewport and let the faded edges crop — the page is
   the same color as the paper, so there's nothing to see out there anyway. */
@media (max-width:760px) and (orientation:portrait){
  body.hall-page{overflow-x:hidden}
  .hall{width:152%;margin-left:-26%}
  .hall-mark{width:84vw}
}

/* ============================================================
   INTERIOR PAGES
   ============================================================ */
.topbar{
  position:sticky; top:0; z-index:50;
  background:rgba(244,234,217,.86); backdrop-filter:blur(10px);
  border-bottom:1px solid var(--rule);
}
.topbar .wrap{display:flex;align-items:center;justify-content:space-between;gap:20px;min-height:62px}
.brand{display:flex;align-items:center;text-decoration:none;flex:none}
.brand img{width:clamp(124px,17vw,168px);height:auto;opacity:.92;transition:opacity .2s}
.brand:hover img{opacity:1}
.nav{display:flex;gap:clamp(12px,2.4vw,26px);font-family:var(--sans);font-size:.8rem;letter-spacing:.14em;text-transform:uppercase}
.nav a{text-decoration:none;color:var(--ink-soft);padding:6px 0;border-bottom:1px solid transparent;transition:.2s}
.nav a:hover{color:var(--ink);border-color:var(--ink)}
.nav a[aria-current="page"]{color:var(--ink);border-color:var(--ember)}
@media (max-width:640px){
  .topbar .wrap{flex-wrap:wrap;gap:6px;padding-top:10px;padding-bottom:8px;min-height:0}
  .brand img{width:116px}
  .nav{width:100%;justify-content:space-between;gap:8px;font-size:.68rem;letter-spacing:.1em}
}

.page-head{padding:clamp(46px,8vw,96px) 0 clamp(26px,4vw,44px)}
.page-head .runic{display:block;margin-bottom:14px}
main{padding-bottom:clamp(60px,10vw,120px)}

.site-foot{
  border-top:1px solid var(--rule); padding:34px 0 60px;
  font-size:.86rem; color:var(--ink-faint);
}
.site-foot .wrap{display:flex;flex-wrap:wrap;gap:16px;justify-content:space-between;align-items:center}
.site-foot a{color:var(--ink-soft)}

/* ---------- cards ---------- */
.card-grid{
  display:grid; gap:clamp(18px,2.4vw,30px);
  grid-template-columns:repeat(auto-fill,minmax(min(100%,290px),1fr));
}
.card{
  position:relative; display:flex; flex-direction:column; overflow:hidden;
  background:var(--parchment-2); border:1px solid var(--parchment-edge);
  border-radius:3px; text-decoration:none; color:inherit;
  transition:transform .35s cubic-bezier(.2,.7,.2,1), box-shadow .35s ease, border-color .35s;
  box-shadow:0 1px 2px rgba(36,28,20,.06);
}
.card:hover{transform:translateY(-4px);box-shadow:0 16px 36px -18px rgba(36,28,20,.55);border-color:#c3ab88}
.card .thumb{aspect-ratio:16/10;overflow:hidden;background:#171310}
.card .thumb img{width:100%;height:100%!important;object-fit:cover;transition:transform .7s cubic-bezier(.2,.7,.2,1);filter:saturate(.92)}
.card:hover .thumb img{transform:scale(1.07)}
.card .body{padding:18px 20px 22px;display:flex;flex-direction:column;gap:8px;flex:1}
.card .tags{display:flex;flex-wrap:wrap;gap:6px;margin-top:auto;padding-top:12px}
.tag{
  font-family:var(--sans);font-size:.66rem;letter-spacing:.12em;text-transform:uppercase;
  padding:3px 8px;border:1px solid var(--rule);border-radius:99px;color:var(--ink-faint);
}
.card h3{margin:0}
.card p{margin:0;color:var(--ink-soft);font-size:.95rem}

/* ---------- buttons ---------- */
.btn{
  display:inline-flex;align-items:center;gap:9px;
  font-family:var(--sans);font-size:.8rem;letter-spacing:.14em;text-transform:uppercase;
  padding:12px 22px;border-radius:2px;text-decoration:none;
  background:var(--ink);color:var(--parchment);border:1px solid var(--ink);
  transition:.22s;
}
.btn:hover{background:transparent;color:var(--ink)}
.btn.ghost{background:transparent;color:var(--ink)}
.btn.ghost:hover{background:var(--ink);color:var(--parchment)}
.btn-row{display:flex;flex-wrap:wrap;gap:12px;margin-top:26px}

/* ---------- book / writing ---------- */
.book{
  display:grid; gap:clamp(24px,4vw,56px);
  grid-template-columns:minmax(0,320px) minmax(0,1fr);
  align-items:start;
}
@media (max-width:720px){.book{grid-template-columns:1fr}}
.book .cover{
  border-radius:2px; overflow:hidden;
  box-shadow:0 24px 50px -22px rgba(36,28,20,.7), 0 2px 0 rgba(255,255,255,.35) inset;
  transform:perspective(1200px) rotateY(-6deg); transition:transform .6s cubic-bezier(.2,.7,.2,1);
}
.book .cover:hover{transform:perspective(1200px) rotateY(0deg) translateY(-4px)}
.book .meta{font-family:var(--sans);font-size:.74rem;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-faint)}
blockquote.pull{
  margin:clamp(30px,5vw,56px) 0; padding:0 0 0 clamp(18px,3vw,30px);
  border-left:2px solid var(--ember); font-size:clamp(1.15rem,2vw,1.5rem);
  font-style:italic; color:var(--ink-soft); max-width:34ch;
}
.signup{
  background:linear-gradient(180deg,#efe3cd,#e7d8bd);
  border:1px solid var(--parchment-edge); border-radius:3px;
  padding:clamp(24px,4vw,42px);
}
.signup form{display:flex;gap:10px;flex-wrap:wrap;margin-top:18px}
.signup input[type=email]{
  flex:1 1 240px;min-width:0;padding:12px 14px;font:inherit;font-size:.95rem;
  border:1px solid var(--parchment-edge);background:#fbf5ea;border-radius:2px;color:var(--ink);
}
.signup input[type=email]:focus{outline:2px solid var(--ember);outline-offset:1px}

/* ---------- post list ---------- */
.posts{list-style:none;margin:0;padding:0}
.posts li{border-bottom:1px solid var(--rule)}
.posts a{
  display:grid;grid-template-columns:1fr auto;gap:6px 24px;align-items:baseline;
  padding:20px 0;text-decoration:none;transition:.25s;
}
.posts a:hover{padding-left:10px}
.posts .t{font-size:1.12rem;font-weight:600}
.posts .d{font-family:var(--sans);font-size:.74rem;letter-spacing:.12em;text-transform:uppercase;color:var(--ink-faint);white-space:nowrap}
.posts .x{grid-column:1/-1;color:var(--ink-soft);font-size:.95rem;margin:0}
.source-pill{
  display:inline-block;font-family:var(--sans);font-size:.62rem;letter-spacing:.14em;
  text-transform:uppercase;padding:2px 7px;border-radius:99px;margin-right:9px;vertical-align:2px;
  border:1px solid var(--rule);color:var(--ink-faint);
}
.source-pill.substack{border-color:#d98b4a;color:#a5561c}

/* ---------- about ---------- */
.about-grid{display:grid;gap:clamp(28px,5vw,60px);grid-template-columns:minmax(0,260px) minmax(0,1fr);align-items:start}
@media (max-width:720px){.about-grid{grid-template-columns:1fr}}
/* portrait keeps its own ratio */
.portrait{border-radius:3px;overflow:hidden;border:1px solid var(--parchment-edge);box-shadow:0 18px 40px -22px rgba(36,28,20,.6)}
.facts{list-style:none;margin:22px 0 0;padding:0;font-size:.92rem}
.facts li{display:flex;gap:10px;padding:7px 0;border-bottom:1px dotted var(--rule)}
.facts b{font-family:var(--sans);font-size:.68rem;letter-spacing:.13em;text-transform:uppercase;color:var(--ink-faint);min-width:78px;padding-top:3px}
.prose p{max-width:68ch}
.timeline{list-style:none;margin:0;padding:0;border-left:1px solid var(--rule)}
.timeline li{position:relative;padding:0 0 26px 26px}
.timeline li::before{content:"";position:absolute;left:-4.5px;top:9px;width:8px;height:8px;border-radius:50%;background:var(--ember)}
.timeline .yr{font-family:var(--sans);font-size:.7rem;letter-spacing:.16em;text-transform:uppercase;color:var(--ink-faint)}

/* ---------- motion & contrast prefs ---------- */
@media (prefers-reduced-motion:reduce){
  *,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.12s!important}
  .portal .scene{transform:none!important;scale:1.02!important}
  .portal:is(:hover,:focus-visible,.is-awake) .scene{scale:1.08!important}
  .portal[data-portal="blog"]{--scene-zoom:1.08}
}

/* ---------- blanked copy ----------
   Emptying a field in the copy editor should leave NOTHING on the page, not
   an empty box. `apply` writes the text inside an element, so a blanked field
   yields `<span class="tag"></span>` -- which still has its border, padding
   and pill radius, so it renders as a small empty capsule. Headings and
   paragraphs leave dead vertical space the same way.

   `:empty` only matches an element with no child nodes at all, which is
   exactly what apply produces, so this cannot hide anything that has content.
   Do not add whitespace inside these elements in the templates. */
h1:empty, h2:empty, h3:empty,
p:empty, .lede:empty, .meta:empty, .kicker:empty, .runic:empty,
.tag:empty, .status:empty, figcaption:empty, li:empty{
  display:none;
}

/* A row whose every tag is blank still reserved its 12px of padding.

   Deliberately NOT extended to whole sections. A `.wrap` whose heading and
   lede are both blank may still hold buttons, a chart or an image that has
   to stay, and `:has(h2)` matches an EMPTY h2 anyway -- so a rule meant to
   tidy up a blanked section would either do nothing or hide a section with
   real content in it. If an entire section should go, delete it from the
   HTML rather than blanking its words. */
.tags:not(:has(.tag:not(:empty))){display:none}

/* ---------- quiet links ----------
   A link that should not announce itself as one. Used where the text is a
   heading or ordinary prose that happens to be clickable -- styling it like a
   normal link would make a section heading read as navigation. It takes the
   surrounding color and weight and only marks itself on hover and focus. */
a.quiet{
  color:inherit;
  text-decoration:none;
  border-bottom:1px solid transparent;
  transition:border-color .14s ease,color .14s ease;
}
a.quiet:hover{border-bottom-color:currentColor}
a.quiet:focus-visible{
  outline:2px solid var(--ember);
  outline-offset:2px;
  border-bottom-color:transparent;
}

/* The way out of the book, under it rather than on its last page. It used to
   sit in the colophon, which meant finding it required turning to the end of
   the thing you had already decided not to read. Quieter than .tome-hint,
   because it is a fallback and not an instruction. */
.tome-alt{
  margin:6px 0 0;
  text-align:center;
  font:400 .74rem/1.5 var(--sans);
  letter-spacing:.08em;
}
.tome-alt a{
  color:var(--ink-faint);
  text-decoration:none;
  border-bottom:1px solid transparent;
  transition:color .14s ease,border-color .14s ease;
}
.tome-alt a:hover{color:var(--ink-soft);border-bottom-color:currentColor}

/* The portrait on the generated plain about page. `.portrait` was written for
   the old two-column layout, where the grid set its width; standing alone in
   a .wrap it would run the full column and print Ryan's face a foot tall. */
.wrap > .portrait{width:min(240px,100%);margin:0}
.wrap > .portrait img{display:block;width:100%;height:auto}
.wrap > .portrait figcaption{
  padding:8px 10px;
  font:400 .74rem/1.4 var(--sans);
  letter-spacing:.06em;
  color:var(--ink-faint);
  background:var(--parchment);
}

/* Figures on the generated plain about page -- the thesis pictures that sit
   on their own page in the book, joined into the Education section here. */
.wrap > .fig{ margin:22px 0 0; max-width:min(520px,100%) }
.wrap > .fig img{ display:block; width:100%; height:auto; border:1px solid var(--parchment-edge) }
.wrap > .fig .fig-cap{ margin:6px 0 0; font:400 .76rem/1.4 var(--sans); color:var(--ink-faint) }
.wrap > .fig .fig-zoom{ display:block; cursor:zoom-in }

/* The picture modal (assets/js/lightbox.js) -- a native <dialog>, so it sits
   in the top layer above the book and its dust, and Escape closes it. Any
   click inside dismisses it, hence zoom-out everywhere. */
.lightbox{
  width:100vw; height:100vh; max-width:none; max-height:none;
  margin:0; padding:0; border:0; background:transparent; cursor:zoom-out;
}
.lightbox[open]{ display:flex; align-items:center; justify-content:center }
.lightbox::backdrop{ background:rgba(20,14,8,.86) }
.lightbox figure{ margin:0; display:flex; flex-direction:column; align-items:center; gap:10px }
.lightbox img{
  display:block; width:auto; height:auto;
  max-width:calc(100vw - 32px); max-height:calc(100vh - 96px);
  padding:6px; background:#fffdf6; box-shadow:0 24px 60px -20px rgba(0,0,0,.8);
}
.lightbox figcaption{ font:400 .86rem/1.4 var(--sans); letter-spacing:.04em; color:#eadcc6; text-align:center }
.lightbox figcaption:empty{ display:none }
.lightbox-close{
  position:fixed; top:14px; right:16px; width:44px; height:44px;
  border:0; border-radius:50%; background:rgba(255,248,236,.14); color:#fff8ec;
  font:300 30px/1 var(--sans); cursor:pointer;
}
.lightbox-close:hover{ background:rgba(255,248,236,.26) }

/* not-human.html -- a short story on its own page, set for long reading. */
.story .page-head{ text-align:center }
.story-body{ max-width:calc(68ch + 2 * clamp(18px,4vw,40px)) }
.story-body p{ font-size:clamp(1.02rem,1.3vw,1.14rem); line-height:1.75; margin:0 0 1em }
.story-body .scene-break{ text-align:center; color:var(--ink-faint); margin:2.2em 0 }
.story-back{ max-width:calc(68ch + 2 * clamp(18px,4vw,40px)); margin-top:clamp(36px,6vw,64px); font-family:var(--sans); font-size:.9rem }

/* Migrated blog articles retain their original prose and media. */
.blog-article{max-width:900px}
.blog-article .page-head{padding-bottom:28px}
.blog-article h1{overflow-wrap:anywhere}
.blog-article time,.article-original{font:400 .9rem/1.5 var(--sans);color:var(--ink-soft)}
.article-body{font-size:1.08rem;line-height:1.75;overflow-wrap:anywhere}
.article-body p{margin:0 0 1.2em}
.article-body h1{font-size:1.7rem;margin:1.5em 0 .6em}
.article-body h2{font-size:1.4rem;margin:1.5em 0 .6em}
.article-body img,.article-body video{max-width:100%;height:auto}
.article-body figure{margin:1.5em 0}
.article-body figcaption{font:400 .85rem/1.5 var(--sans);color:var(--ink-soft);margin-top:10px}
.article-body blockquote{margin:1.4em 0;padding:0 0 0 22px;border-left:3px solid var(--parchment-edge)}
.article-body pre{overflow:auto;padding:16px;background:var(--parchment-2)}
.article-body iframe{max-width:100%;border:0}
.article-body .embediframe{width:100%;aspect-ratio:16/9;height:auto}
.article-neighbors{display:flex;flex-wrap:wrap;gap:20px;justify-content:space-between;border-top:1px solid var(--rule);padding:24px 0;margin-top:40px}
.article-neighbors a{flex:1 1 240px}
.article-original{margin-bottom:48px}
.story-back a{ color:var(--ink-soft) }
