/* --------------------------------------------------------------------------
   Hamed Sadeghi — personal academic website
   Design, code and content copyright (c) 2026 Hamed Sadeghi. All rights reserved.
   Original: http://sharif.edu/~hsadeghi/

   Not licensed for reuse, redistribution or derivative works without written
   permission. Reading this file is expected — it was sent to your browser so it
   could be used. Republishing it is not.

   build-id: hs-geotech-2026-6f3ad1
   -------------------------------------------------------------------------- */
/* =============================================================================
   fx.css — the 3D layer: the field, the contextual highlight, the page
            transitions, and the sticky section bar.
   -----------------------------------------------------------------------------
   Everything in this file is an ENHANCEMENT of a page that is already complete
   and already legible.  Three rules were followed throughout, and should be
   followed by anything added here later:

     1. Nothing here may change layout.  The highlight uses colour, shadow and
        transform only — never width, padding, border-width or font.  That is
        not a style preference: the Persian publication list is inside the
        highlighted region, and its line breaks are pinned (see farsi.css §).
        A one-pixel border on a list item would reflow every Persian line.

     2. Nothing here may be required.  The field is drawn by JavaScript into a
        canvas that does not exist in the markup; the transitions are scoped to
        `html.fx-nav`, a class only JavaScript adds.  With scripting off, or
        WebGL missing, every rule below simply never applies.

     3. Motion is opt-out.  `prefers-reduced-motion` is honoured here and again
        in the JavaScript — belt and braces, because these are the effects most
        likely to bother someone.

   See README §12 for the architecture and §13 for how to change or remove it.
   ========================================================================== */

/* -----------------------------------------------------------------------------
   1. The field
   A single fixed canvas behind everything.  The page background moves to <html>
   so the canvas has something to sit on top of and the content has something to
   sit on top of it — three layers, no z-index arms race.
   -------------------------------------------------------------------------- */
html { background: var(--c-page); }
body { background: transparent; }

.fx-field {
  position: fixed;
  inset: 0;
  z-index: 0;
  display: block;
  width: 100%;
  height: 100%;
  /* It must never intercept a click, a tap, a text selection or a scroll. */
  pointer-events: none;
  /* Nothing about it should be announced or focusable. */
  user-select: none;
}

/* Everything the visitor actually interacts with sits above the field.
   -----------------------------------------------------------------------------
   Note what is NOT set here: `position`.  An earlier version of this rule set
   `position: relative` on `.topbar` as well, which silently overrode the
   `position: sticky` it gets from layout.css — the header stopped sticking, and
   the mobile menu (absolutely positioned inside it) dropped behind the page,
   because `.shell` sat at the same z-index and comes later in the document.

   So: only `.shell` and `.footer` are given a stacking level, and the header
   keeps the one layout.css already gave it (50), well above them.  If anything
   is ever added here, give it a level in that same scale rather than another
   `z-index: 1`. */
.shell,
.footer { position: relative; z-index: 1; }

/* -----------------------------------------------------------------------------
   2. The contextual highlight
   `[data-fx]` marks an element as highlightable; `.is-lit` is put on it by
   assets/js/modules/fx/highlight.js, together with `--fx-glow` — the colour of
   the section it belongs to.  The field behind the page is lit the same colour
   at the same moment, which is what ties the two together.
   -------------------------------------------------------------------------- */
[data-fx] {
  /* The colour transition the whole effect is named for.  Slower than a normal
     hover (300 ms against 200 ms) so it reads as light arriving rather than as
     a state flipping. */
  transition:
    box-shadow 0.3s var(--ease-out),
    background-color 0.3s var(--ease-out),
    border-color 0.3s var(--ease-out),
    transform 0.28s var(--ease-out);
}

[data-fx].is-lit {
  /* A ring in the section's colour plus a pool of the same colour beneath.
     Both are box-shadows: they cost no layout, which is the first rule above. */
  box-shadow:
    0 0 0 1px var(--fx-glow),
    0 14px 34px -14px var(--fx-glow);
  border-radius: var(--radius);
  /* Opaque, not translucent: the field's bump is directly behind this element
     at its brightest, and body text must never be read against it.  The lit row
     lifting onto a solid surface is also what sells the 3D. */
  background-color: var(--c-surface);
  /* The 3D part: the element rotates toward the pointer and lifts off the page.
     The angles come from the pointer position as custom properties, so the
     transform itself stays here where it can be tuned or overridden. */
  transform:
    perspective(900px)
    rotateX(var(--fx-rx, 0deg))
    rotateY(var(--fx-ry, 0deg))
    translateZ(var(--fx-lift, 0px));
}

/* Text rows tilt half as far as cards.  A publication entry is a line of prose:
   rotate it as hard as a card and it becomes hard to read mid-hover. */
li[data-fx].is-lit {
  transform:
    perspective(900px)
    rotateX(calc(var(--fx-rx, 0deg) * 0.45))
    rotateY(calc(var(--fx-ry, 0deg) * 0.45))
    translateZ(calc(var(--fx-lift, 0px) * 0.4));
}

/* The card and person hover rules in components.css also set `transform`, and
   two transforms cannot both apply.  The lit state wins — it is the richer of
   the two and it includes the lift the hover rule was providing. */
.card[data-fx].is-lit,
.person[data-fx].is-lit { border-color: var(--fx-glow); }

/* Someone who asked for less motion still gets the colour — they just do not
   get the tilt.  The information is in the colour; the movement is decoration. */
@media (prefers-reduced-motion: reduce) {
  [data-fx].is-lit,
  li[data-fx].is-lit { transform: none; }
}

/* -----------------------------------------------------------------------------
   3. Page transitions — five styles
   Scoped to `html.fx-nav`, which assets/js/modules/fx/transition.js adds and
   nothing else does.  With no JavaScript there is no class, no animation, and
   navigation is what it always was.

   The style in use is `html[data-fx-transition="…"]`, set from the single
   `STYLE` constant in transition.js.  `depth` is the one the site ships with;
   the other four are kept, working, so switching is a one-line change.  Each
   one's OUTGOING duration must match `OUT_MS` in transition.js — that number is how
   long the click is held before the browser is allowed to navigate, so if the
   two disagree you get either a clipped animation or a stalled click.

   Every style animates `.shell` and `.footer` and only fades `.topbar`: the
   header is the one element in the same place on both pages, and moving it
   would break the illusion that you are travelling through a stack of pages
   rather than replacing the world.
   -------------------------------------------------------------------------- */
html.fx-nav .shell,
html.fx-nav .footer { transform-origin: 50% 50%; }

@keyframes fx-fade-out { from { opacity: 1; } to { opacity: 0.35; } }
@keyframes fx-fade-in  { from { opacity: 0.4; } to { opacity: 1; } }

html.fx-nav.is-leaving .topbar  { animation: fx-fade-out 240ms var(--ease) both; }
html.fx-nav.is-entering .topbar { animation: fx-fade-in 300ms var(--ease-out) both; }

/* ---- depth: recede and dim, then rise from behind ------------------------ */
@keyframes fx-depth-out {
  from { opacity: 1; transform: perspective(1400px) translateZ(0) scale(1); }
  to   { opacity: 0; transform: perspective(1400px) translateZ(-90px) scale(0.972); }
}
@keyframes fx-depth-in {
  from { opacity: 0; transform: perspective(1400px) translateZ(-70px) scale(0.984); }
  to   { opacity: 1; transform: perspective(1400px) translateZ(0) scale(1); }
}
html.fx-nav[data-fx-transition="depth"].is-leaving .shell,
html.fx-nav[data-fx-transition="depth"].is-leaving .footer {
  animation: fx-depth-out 260ms var(--ease) both;
}
html.fx-nav[data-fx-transition="depth"].is-entering .shell {
  animation: fx-depth-in 420ms var(--ease-out) both;
}

/* ---- flip: tips away on Y, the next tips in from the other side ---------- */
@keyframes fx-flip-out {
  from { opacity: 1; transform: perspective(1200px) rotateY(0) translateZ(0); }
  to   { opacity: 0; transform: perspective(1200px) rotateY(-14deg) translateZ(-260px); }
}
@keyframes fx-flip-in {
  from { opacity: 0; transform: perspective(1200px) rotateY(14deg) translateZ(-260px); }
  to   { opacity: 1; transform: perspective(1200px) rotateY(0) translateZ(0); }
}
html.fx-nav[data-fx-transition="flip"].is-leaving .shell,
html.fx-nav[data-fx-transition="flip"].is-leaving .footer {
  animation: fx-flip-out 380ms var(--ease) both;
}
html.fx-nav[data-fx-transition="flip"].is-entering .shell {
  animation: fx-flip-in 480ms var(--ease-out) both;
}

/* ---- cube: hinged at the edge, like turning a cube face ------------------ */
@keyframes fx-cube-out {
  from { opacity: 1; transform: perspective(1100px) rotateY(0); }
  to   { opacity: 0; transform: perspective(1100px) rotateY(-42deg); }
}
@keyframes fx-cube-in {
  from { opacity: 0; transform: perspective(1100px) rotateY(42deg); }
  to   { opacity: 1; transform: perspective(1100px) rotateY(0); }
}
/* The hinge is the leaving edge on the way out and the arriving edge on the way
   in, which is what makes the two halves read as one continuous rotation. */
html.fx-nav[data-fx-transition="cube"].is-leaving .shell,
html.fx-nav[data-fx-transition="cube"].is-leaving .footer {
  transform-origin: 100% 50%;
  animation: fx-cube-out 420ms var(--ease) both;
}
html.fx-nav[data-fx-transition="cube"].is-entering .shell {
  transform-origin: 0% 50%;
  animation: fx-cube-in 520ms var(--ease-out) both;
}

/* ---- dissolve: the quiet one -------------------------------------------- */
@keyframes fx-dissolve-out {
  from { opacity: 1; transform: scale(1); }
  to   { opacity: 0; transform: scale(1.012); }
}
@keyframes fx-dissolve-in {
  from { opacity: 0; transform: scale(0.994); }
  to   { opacity: 1; transform: scale(1); }
}
html.fx-nav[data-fx-transition="dissolve"].is-leaving .shell,
html.fx-nav[data-fx-transition="dissolve"].is-leaving .footer {
  animation: fx-dissolve-out 200ms var(--ease) both;
}
html.fx-nav[data-fx-transition="dissolve"].is-entering .shell {
  animation: fx-dissolve-in 320ms var(--ease-out) both;
}

/* ---- slice: panels sweeping across at staggered depths ------------------- */
/* The page itself only dims; the movement is in seven panels the JavaScript
   creates for this style alone and removes when it is finished. */
@keyframes fx-slice-dim-out {
  from { opacity: 1; } to { opacity: 0.15; }
}
@keyframes fx-slice-dim-in {
  from { opacity: 0.15; } to { opacity: 1; }
}
html.fx-nav[data-fx-transition="slice"].is-leaving .shell,
html.fx-nav[data-fx-transition="slice"].is-leaving .footer {
  animation: fx-slice-dim-out 300ms var(--ease) both;
}
html.fx-nav[data-fx-transition="slice"].is-entering .shell {
  animation: fx-slice-dim-in 420ms var(--ease-out) both;
}

.fx-slices {
  position: fixed;
  inset: 0;
  z-index: 80;            /* over the page, under the skip link (100) */
  display: flex;
  pointer-events: none;
  perspective: 1000px;
}
.fx-slices i {
  flex: 1 1 0;
  background: var(--c-accent);
  transform-origin: 50% 0%;
  /* `--i` is the panel's index, set inline by transition.js. Multiplying it
     here is what produces the stagger without seven separate rules. */
  animation-delay: calc(var(--i) * 34ms);
}
@keyframes fx-slice-cover {
  from { transform: translateY(-100%) rotateX(35deg); }
  to   { transform: translateY(0) rotateX(0); }
}
@keyframes fx-slice-clear {
  from { transform: translateY(0) rotateX(0); }
  to   { transform: translateY(100%) rotateX(-35deg); }
}
.fx-slices--out i { animation: fx-slice-cover 300ms var(--ease) both; }
.fx-slices--in  i { animation: fx-slice-clear 360ms var(--ease-out) both; }

/* -----------------------------------------------------------------------------
   4. The section bar
   Built by assets/js/modules/sectionnav.js on any page with two or more
   sections.  Shown below 64rem only — above that the sidebar is beside the
   article the whole way down and already does this job.
   -------------------------------------------------------------------------- */
.secnav {
  position: sticky;
  top: var(--bar-h);
  z-index: 5;
  margin: 0 calc(var(--pad) * -1) var(--space-4);
  padding: var(--space-2) 0;
  background: var(--c-page);
  border-bottom: 1px solid var(--c-rule);
}

@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
  .secnav {
    background: var(--c-bar);
    -webkit-backdrop-filter: saturate(180%) blur(14px);
    backdrop-filter: saturate(180%) blur(14px);
  }
}

.secnav__scroll {
  display: flex;
  gap: var(--space-2);
  overflow-x: auto;
  padding: 0 var(--pad);
  scroll-snap-type: x proximity;
  /* A horizontal swipe scrolls this row; a vertical one still scrolls the page.
     Without this the bar becomes a place where the page will not move. */
  touch-action: pan-x pan-y;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
}
.secnav__scroll::-webkit-scrollbar { display: none; }

.secnav__chip {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  flex: 0 0 auto;
  /* 44 px tall: the smallest a target should be for a thumb. */
  min-height: 2.75rem;
  padding: 0 var(--space-4);
  border: 1px solid var(--c-border);
  border-radius: var(--radius-full);
  background: var(--c-surface);
  color: var(--c-ink-soft);
  font-size: var(--t-sm);
  font-weight: 500;
  white-space: nowrap;
  scroll-snap-align: center;
  transition: background-color var(--dur) var(--ease), border-color var(--dur) var(--ease),
              color var(--dur) var(--ease);
}
.secnav__chip .icon { color: var(--c-accent); }
.secnav__chip:hover { border-color: var(--c-accent); color: var(--c-ink); }

/* Same treatment as .btn--primary, including the dark-scheme text colour: on
   the dark palette --c-accent is a bright cyan and white text on it fails AA,
   which is why the buttons already override it. */
.secnav__chip.is-current {
  background: var(--c-accent);
  border-color: var(--c-accent);
  color: #fff;
}
:root[data-theme="dark"] .secnav__chip.is-current { color: #04212a; }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme]) .secnav__chip.is-current { color: #04212a; }
}
.secnav__chip.is-current .icon { color: currentColor; }

/* Anchor targets have to clear the header *and* this bar.  `--secnav-h` is
   measured and published by sectionnav.js, because the bar's height follows the
   root font size, which changes with the viewport. */
html { scroll-padding-top: calc(var(--bar-h) + var(--secnav-h, 0px) + 1rem); }

/* The bar is shown at EVERY width, not just on phones.
   -----------------------------------------------------------------------------
   It started out mobile-only, on the reasoning that the desktop sidebar already
   lists the sections. In use that turned out to be wrong: Publications is a
   hundred entries and Research Team nearly forty, and on a desktop you are just
   as likely to want to jump straight to the Persian list. The sidebar is a
   table of contents you read; this is a control you use, and it stays put at the
   top of the window where the sidebar scrolls away.

   On wide screens it is quieter — the row centres itself under the page title
   rather than filling the width. */
@media (min-width: 64rem) {
  .secnav {
    margin-left: 0;
    margin-right: 0;
  }
  .secnav__scroll { padding-left: 0; padding-right: 0; }
}

/* -----------------------------------------------------------------------------
   5. The publications explorer
   The two 3D views above the publication list — assets/js/modules/explorer/.
   Everything here is presentation only; the panel is built by JavaScript and
   never exists without it, and the written list inside it is a real control
   that works whether or not the canvas can be drawn.  See README §15.
   -------------------------------------------------------------------------- */
/* `hidden` has to keep working: several parts of this panel are shown and
   hidden by the attribute, and a `display: flex` rule below would otherwise
   quietly beat the browser's own `[hidden] { display: none }`. */
.explorer [hidden] { display: none !important; }

.explorer {
  margin: 0 0 var(--space-6);
  border: 1px solid var(--c-border);
  border-radius: var(--radius-lg);
  background: var(--c-surface);
  box-shadow: var(--sh-card);
  overflow: hidden;
}

.explorer__head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--space-3) var(--space-4);
  padding: var(--space-4) var(--space-4) var(--space-3);
  border-bottom: 1px solid var(--c-rule);
}

.explorer__tabs { display: flex; gap: var(--space-2); flex: none; }

.explorer__tab {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  min-height: 2.5rem;
  padding: 0 var(--space-4);
  border: 1px solid var(--c-border);
  border-radius: var(--radius-full);
  background: var(--c-surface-2);
  color: var(--c-ink-soft);
  font-size: var(--t-sm);
  font-weight: 600;
  cursor: pointer;
  transition: background-color var(--dur) var(--ease), color var(--dur) var(--ease),
              border-color var(--dur) var(--ease);
}
.explorer__tab:hover { border-color: var(--c-accent); color: var(--c-ink); }
.explorer__tab[aria-selected="true"] {
  background: var(--c-accent);
  border-color: var(--c-accent);
  color: #fff;
}
:root[data-theme="dark"] .explorer__tab[aria-selected="true"] { color: #04212a; }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme]) .explorer__tab[aria-selected="true"] { color: #04212a; }
}

/* The roster panel on the Research Team page reuses every class in this
   section: it is the same idea in a second place and it should look like one
   thing, not like two panels that nearly match.  Only these two rules are new —
   a heading, and the gap under the panel. */
.explorer__title {
  flex: none;
  margin: 0;
  font-size: var(--t-md);
  color: var(--c-ink-strong);
}

.roster-host:not(:empty) { margin-bottom: var(--space-6); }

/* The row the roster just took you to. Its own class rather than `.is-lit`,
   which belongs to highlight.js — see the note in roster/index.js. */
.person.is-jumped {
  box-shadow:
    0 0 0 2px var(--roster-jump, var(--c-accent)),
    0 14px 34px -14px var(--roster-jump, var(--c-accent));
  border-radius: var(--radius);
  animation: roster-jumped 2.6s var(--ease) both;
}

@keyframes roster-jumped {
  0%   { box-shadow: 0 0 0 4px var(--roster-jump, var(--c-accent)), 0 18px 40px -14px var(--roster-jump, var(--c-accent)); }
  18%  { box-shadow: 0 0 0 2px var(--roster-jump, var(--c-accent)), 0 14px 34px -14px var(--roster-jump, var(--c-accent)); }
  100% { box-shadow: 0 0 0 0 transparent, 0 0 0 transparent; }
}

@media (prefers-reduced-motion: reduce) {
  .person.is-jumped { animation: none; }
}

/* The dot on a category chip, in that category's own colour. */
.explorer__year { display: inline-flex; align-items: center; gap: var(--space-2); }
.explorer__dot {
  width: 0.55rem;
  height: 0.55rem;
  border-radius: 50%;
  flex: none;
}

/* Two filter rows stack, and only the first of them keeps the rule above it —
   otherwise the pair reads as two separate controls rather than one filter. */
.explorer[data-view="roster"] .explorer__years + .explorer__years {
  border-top: 0;
  padding-top: 0;
}

/* ---- the controls strip -----------------------------------------------------
   A line saying how to work the thing, and a way back.  Under the stage rather
   than over it: a 3D panel with instructions floating on top of the picture is
   a panel that is permanently half covered by its own manual, and these are
   things you read once.
   -------------------------------------------------------------------------- */
.explorer__controls {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-2) var(--space-4);
  padding: var(--space-3) var(--space-4);
  border-top: 1px solid var(--c-rule);
}

/* The guide stays up on BOTH views.

   It was hidden on the collaboration view for a while, on the reasoning that a
   ball of nodes you spin and click needs less explaining than a corridor. That
   was wrong in use: the collaboration view has exactly the same four controls —
   drag turns it, Ctrl-drag or right-drag moves it, the wheel zooms, a click
   filters — and a control that is only documented on the other tab is a control
   nobody finds. The wording covers both ("Click to filter or open"), so one
   line serves both and neither view is the one where you have to guess. */
.explorer__hint {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-1) var(--space-3);
  margin: 0;
  min-width: 0;
  font-size: var(--t-xs);
  color: var(--c-ink-mute);
}

/* A middot between the instructions, drawn rather than typed, so the list
   wraps on a phone without leaving a separator stranded at the end of a line. */
.explorer__hint span + span::before {
  content: "·";
  margin-inline-end: var(--space-3);
  color: var(--c-ink-faint);
}

.explorer__reset {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  flex: none;
  min-height: 2.25rem;
  padding: 0 var(--space-3);
  border: 1px solid var(--c-border);
  border-radius: var(--radius-full);
  background: var(--c-surface);
  color: var(--c-ink-soft);
  font-size: var(--t-xs);
  font-weight: 600;
  cursor: pointer;
  transition: border-color var(--dur) var(--ease), color var(--dur) var(--ease);
}
.explorer__reset:hover,
.explorer__reset:focus-visible { border-color: var(--c-accent); color: var(--c-accent); }
.explorer__reset .icon { width: 0.95rem; height: 0.95rem; }

/* Panning gets its own cursor, so the gesture is confirmed the moment it
   starts rather than after the scene has already moved. */
.explorer__stage.is-panning { cursor: move; }

.explorer__caption {
  flex: 1 1 16rem;
  min-width: 0;
  font-size: var(--t-xs);
  color: var(--c-ink-mute);
}

/* ---- the canvas ---------------------------------------------------------- */
.explorer__stage {
  position: relative;
  /* Tall enough for the graph to have room, short enough that it never pushes
     the publications themselves off the first screen. */
  height: clamp(17rem, 44vh, 28rem);
  background:
    radial-gradient(75% 65% at 50% 45%, var(--c-hero-a) 0%, transparent 70%),
    var(--c-surface-2);
  cursor: grab;
  touch-action: pan-y;   /* a vertical swipe still scrolls the page */
  user-select: none;
}
.explorer__stage.is-pointing { cursor: pointer; }
.explorer__stage.is-dragging { cursor: grabbing; }

.explorer__canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }

.explorer__labels { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }

.explorer__label {
  position: absolute;
  top: 0;
  left: 0;
  padding: 0.1rem 0.4rem;
  border-radius: var(--radius-sm);
  background: var(--c-bar-solid);
  border: 1px solid var(--c-border);
  color: var(--c-ink-soft);
  font-size: var(--t-xs);
  line-height: 1.35;
  white-space: nowrap;
  will-change: transform;
}
.explorer__label.is-lit {
  color: var(--c-ink-strong);
  border-color: var(--c-accent);
  font-weight: 600;
  z-index: 2;
}
/* Persian names keep the site's Persian face here as everywhere else.  This is
   a label, not the publication list, so the lock in farsi.css does not reach it
   — the family is named explicitly instead. */
.explorer__label[lang="fa"] { font-family: var(--f-farsi); }

/* Year markers on the timeline: bigger, quieter, and out of the way of the
   cards they label. */
.explorer__label--year {
  background: transparent;
  border-color: transparent;
  color: var(--c-ink-mute);
  font-size: var(--t-sm);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.02em;
}

.explorer__readout {
  position: absolute;
  left: var(--space-4);
  right: var(--space-4);
  bottom: var(--space-3);
  margin: 0;
  font-size: var(--t-xs);
  color: var(--c-ink-mute);
  text-align: center;
  pointer-events: none;
  text-wrap: balance;
}

/* ---- year chips ---------------------------------------------------------- */
.explorer__years {
  display: flex;
  gap: var(--space-2);
  overflow-x: auto;
  padding: var(--space-3) var(--space-4);
  border-top: 1px solid var(--c-rule);
  touch-action: pan-x pan-y;
  scrollbar-width: none;
}
.explorer__years::-webkit-scrollbar { display: none; }

.explorer__year {
  flex: 0 0 auto;
  min-height: 2.25rem;
  padding: 0 var(--space-3);
  border: 1px solid var(--c-border);
  border-radius: var(--radius-full);
  background: var(--c-surface);
  color: var(--c-ink-soft);
  font-size: var(--t-xs);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  cursor: pointer;
  transition: border-color var(--dur) var(--ease), color var(--dur) var(--ease);
}
.explorer__year:hover { border-color: var(--c-accent); color: var(--c-ink); }
.explorer__year.is-current { border-color: var(--c-accent); color: var(--c-accent); }

/* ---- filter controls ----------------------------------------------------- */
.explorer__clear {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  width: 100%;
  min-height: 2.75rem;
  padding: 0 var(--space-4);
  border: 0;
  border-top: 1px solid var(--c-rule);
  background: var(--c-surface-2);
  color: var(--c-accent);
  font-size: var(--t-sm);
  font-weight: 600;
  cursor: pointer;
}
.explorer__clear:hover { background: var(--c-hover); }

/* ---- the written view ---------------------------------------------------- */
.explorer__written {
  padding: var(--space-3) var(--space-4) var(--space-4);
  border-top: 1px solid var(--c-rule);
  font-size: var(--t-sm);
}
.explorer__written > summary {
  cursor: pointer;
  color: var(--c-ink-soft);
  font-weight: 600;
}
.explorer__list {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  margin-top: var(--space-3);
  list-style: none;
  padding: 0;
}
.explorer__pick {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  padding: 0.3rem 0.7rem;
  border: 1px solid var(--c-border);
  border-radius: var(--radius-full);
  background: var(--c-surface);
  color: var(--c-ink-soft);
  font-size: var(--t-xs);
  cursor: pointer;
}
.explorer__pick:hover { border-color: var(--c-accent); color: var(--c-ink); }
.explorer__pick.is-on { border-color: var(--c-accent); color: var(--c-accent); font-weight: 600; }
.explorer__pick-name[dir="rtl"] { font-family: var(--f-farsi); }
.explorer__pick-count {
  padding: 0 0.35rem;
  border-radius: var(--radius-full);
  background: var(--c-surface-2);
  font-variant-numeric: tabular-nums;
}
.explorer__note { margin-top: var(--space-3); font-size: var(--t-xs); color: var(--c-ink-mute); }

/* With no WebGL the panel is only the written view, so it should not pretend to
   be a stage. */
.explorer--flat .explorer__written { border-top: 0; }

/* ---- the filter banner over the list ------------------------------------- */
.pub-filter {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  margin: 0 0 var(--space-4);
  padding: var(--space-3) var(--space-4);
  border: 1px solid var(--c-accent);
  border-radius: var(--radius);
  background: var(--c-accent-soft);
  color: var(--c-ink);
  font-size: var(--t-sm);
  font-weight: 600;
}
.pub-filter .icon { color: var(--c-accent); }
:root[data-theme="dark"] .pub-filter { background: var(--c-surface-2); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme]) .pub-filter { background: var(--c-surface-2); }
}

@media (max-width: 44rem) {
  .explorer__stage { height: clamp(15rem, 38vh, 22rem); }
  .explorer__head { padding: var(--space-3); }
}

/* -----------------------------------------------------------------------------
   6. The university mark in the header
   Built by assets/js/modules/logo3d/.  Three layers stacked in one round box:
   the meteor ring (CSS, always), the 3D canvas (when Three.js is installed),
   and the flat image underneath (the content, and the fallback).
   -------------------------------------------------------------------------- */
.brandmark {
  /* Sized off the bar, not the other way round: --bar-h is 4rem and this sits
     inside it with room to breathe.  Nothing here may change the bar's height. */
  --mark: 2.5rem;
  width: var(--mark);
  height: var(--mark);
  flex: none;
  display: flex;
  align-items: center;
  justify-content: center;
  perspective: 320px;   /* for the CSS fallback's turn */
}

.brandmark__link {
  position: relative;
  display: block;
  width: 100%;
  height: 100%;
  border-radius: 50%;
  transform-style: preserve-3d;
}

.brandmark__poster,
.brandmark__canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  border-radius: 50%;
}

/* The canvas only appears once a renderer is actually drawing into it. */
.brandmark__canvas { opacity: 0; transition: opacity var(--dur-slow) var(--ease-out); }
.brandmark[data-brandmark="three"] .brandmark__canvas { opacity: 1; }
.brandmark[data-brandmark="three"] .brandmark__poster { opacity: 0; }

/* ---- the meteor ------------------------------------------------------------
   A cyan arc travelling clockwise around the rim: bright at the head, trailing
   away behind it.  A conic gradient rotated by an animation, masked down to a
   ring so only the border is lit.  Two pixels wide and entirely CSS — it keeps
   running whatever happens to WebGL.
   -------------------------------------------------------------------------- */
.brandmark__ring {
  position: absolute;
  /* Just outside the artwork's own edge, so the arc traces the border rather
     than crossing it. */
  inset: -0.1875rem;
  border-radius: 50%;
  pointer-events: none;
  background: conic-gradient(
    from 0turn,
    transparent 0 55%,
    color-mix(in srgb, var(--c-brand) 25%, transparent) 74%,
    var(--c-brand) 92%,
    #ffffff 97%,
    transparent 99% 100%
  );
  -webkit-mask: radial-gradient(closest-side, transparent calc(100% - 2px), #000 calc(100% - 2px));
  mask: radial-gradient(closest-side, transparent calc(100% - 2px), #000 calc(100% - 2px));
  animation: brandmark-orbit 3.4s linear infinite;
}

/* `color-mix` is recent; without it the tail is simply the same cyan, which is
   a slightly blunter comet and nothing worse. */
@supports not (color: color-mix(in srgb, red 50%, blue)) {
  .brandmark__ring {
    background: conic-gradient(
      from 0turn, transparent 0 60%, var(--c-brand) 92%, #ffffff 97%, transparent 99% 100%);
  }
}

@keyframes brandmark-orbit {
  from { transform: rotate(0turn); }
  to   { transform: rotate(1turn); }
}

/* ---- the fallback turn -----------------------------------------------------
   With no Three.js the flat mark turns about the same vertical axis.  It is not
   the extruded version, but it is the same gesture.
   -------------------------------------------------------------------------- */
@keyframes brandmark-spin {
  from { transform: rotateY(0deg); }
  to   { transform: rotateY(360deg); }
}
.brandmark[data-brandmark="css"] .brandmark__poster {
  animation: brandmark-spin 14s linear infinite;
  backface-visibility: visible;
}

/* Reduced motion stops both the orbit and the turn.  The mark stays, lit and
   legible; it simply holds still. */
@media (prefers-reduced-motion: reduce) {
  .brandmark__ring,
  .brandmark[data-brandmark="css"] .brandmark__poster { animation: none; }
  .brandmark__ring { background: conic-gradient(from 0turn, transparent 0 70%, var(--c-brand) 95%, transparent 100%); }
}

/* On a phone the header is tight; the mark shrinks rather than the bar. */
@media (max-width: 30rem) {
  .brandmark { --mark: 2.125rem; }
}

/* -----------------------------------------------------------------------------
   7. Cards as surfaces in space
   The section cards lean toward the pointer and stand their contents at
   different depths.  The hero does NOT: it holds the portrait and the name, it
   is the first thing on the site, and it should sit still and be read. What it
   gets instead is the light — a highlight that follows the pointer across it,
   as though a lamp were being moved over a surface.

   Two rules govern the cards, and both are load-bearing:

     1. NOTHING IS LIFTED UNTIL POINTED AT.  `translateZ` under a perspective
        makes a thing bigger, because it is nearer.  Applying the depths at rest
        would leave every icon a few per cent larger than it was designed to be,
        and the layout would drift.

     2. TRANSFORMS ONLY.  No padding, no borders, no widths — transforms do not
        affect layout, so nothing here can reflow text or move a line break.
   -------------------------------------------------------------------------- */

/* The perspective lives on the container, not on the cards, so every child
   shares one vanishing point and the depths agree with each other. */
.cards { perspective: 1600px; }

/* ---- the hero: still, and lit ---------------------------------------------- */
.hero { position: relative; }

.hero::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  background: radial-gradient(
    30rem 24rem at var(--mx, 50%) var(--my, 50%),
    rgb(255 255 255 / 0.34),
    transparent 62%
  );
  opacity: 0;
  transition: opacity 0.35s var(--ease-out);
  /* Above the panel's own background, below everything written on it. */
  z-index: 0;
}
.hero.is-lit-surface::after { opacity: 1; }
.hero > * { position: relative; z-index: 1; }

:root[data-theme="dark"] .hero::after {
  background: radial-gradient(
    30rem 24rem at var(--mx, 50%) var(--my, 50%),
    rgb(150 225 255 / 0.16),
    transparent 62%
  );
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme]) .hero::after {
    background: radial-gradient(
      30rem 24rem at var(--mx, 50%) var(--my, 50%),
      rgb(150 225 255 / 0.16),
      transparent 62%
    );
  }
}

/* ---- the section cards ------------------------------------------------------
   These already lean toward the pointer: the highlight layer (§2) sets
   `--fx-rx` and `--fx-ry` on whatever is hovered, and `.card` is one of its
   targets.  All that is added here is the depth those angles were missing —
   without children at different distances a tilt is just a skewed rectangle.
   -------------------------------------------------------------------------- */
.card {
  transform-style: preserve-3d;
  position: relative;
}
.card__icon,
.card__title,
.card__blurb,
.card__meta {
  transition: transform 0.35s var(--ease-out);
}
.card[data-fx].is-lit .card__icon  { transform: translateZ(34px); }
.card[data-fx].is-lit .card__title { transform: translateZ(22px); }
.card[data-fx].is-lit .card__blurb { transform: translateZ(12px); }
.card[data-fx].is-lit .card__meta  { transform: translateZ(18px); }

/* A lit top edge, so a card has a thickness the light catches. Inset shadows
   cost nothing and do not affect layout. */
.card,
.panel,
.person {
  box-shadow:
    inset 0 1px 0 rgb(255 255 255 / 0.6),
    var(--sh-card);
}
:root[data-theme="dark"] .card,
:root[data-theme="dark"] .panel,
:root[data-theme="dark"] .person {
  box-shadow:
    inset 0 1px 0 rgb(255 255 255 / 0.06),
    var(--sh-card);
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme]) .card,
  :root:not([data-theme]) .panel,
  :root:not([data-theme]) .person {
    box-shadow:
      inset 0 1px 0 rgb(255 255 255 / 0.06),
      var(--sh-card);
  }
}

/* Someone who asked for less motion keeps the depth and loses the movement. */
@media (prefers-reduced-motion: reduce) {
  .card[data-fx].is-lit .card__icon,
  .card[data-fx].is-lit .card__title,
  .card[data-fx].is-lit .card__blurb,
  .card[data-fx].is-lit .card__meta { transform: none; }
  .hero::after { display: none; }
}

/* -----------------------------------------------------------------------------
   8. Type with depth — the Summary page only
   The headings on the home page are solids.  Each letter is a shape that has
   been pressed proud of the card, the way a name is raised on a plate: the
   sheet stands up in a fine ridge around the glyph, the glyph's own walls run
   back from that ridge, and the whole word drops a shadow on what is left.

   What makes it read as a raised object rather than a letter with a shadow
   under it is that the wall TAPERS.  Reading down the stack, the offset grows
   by a step at a time while the blur grows with it, so each slice of wall
   covers less ground than the slice above it: the solid is at full thickness
   where it leaves the letter's face and narrows to nothing at its base,
   instead of ending square like a stack of hard copies.  The offsets also
   drift right as they go back, which puts the light above and to the left —
   the same place the card lighting and the brand mark's aura put it.

   The lip is the piece that sells it.  It is a hairline of light laid ABOVE
   and slightly LEFT of the glyph, outside the letterform: the ridge of sheet
   thrown up around a shape that has been pushed through from behind.  The
   letter's own walls are turned away from the light and are in shade; this
   ridge is the one surface facing it.  Take the lip away and the same stack
   reads as a drop shadow.

   Readability first, which is why this is still not a bevel cut INTO the
   glyph and not an outline:

     - The letterform itself is untouched, and so is its colour.  Every shadow
       is outside the glyph, so nothing eats into the shape and nothing lowers
       the contrast between the ink and the page.  `tools/check-contrast.mjs`
       measures colour against background and is unaffected — but so is the
       eye, which is the point.
     - It is on display type only.  Body copy, chips, captions and the sidebar
       are left flat: a paragraph of raised text is a headache.
     - Every colour is a token per scheme.  On the dark card the walls are
       slate and the lip is a step lighter again, because a raised side on a
       dark page catches light; only the shadow it casts is black.
   -------------------------------------------------------------------------- */
body[data-page="home"] .hero__name {
  text-shadow:
    /* the lip — the sheet standing up around the letter, facing the light */
    -0.5px -1px 0 var(--c-lip),
    /* the wall, thinning as it goes back */
    0.5px 1px 0    var(--c-extrude),
    1px   2px 0    var(--c-extrude),
    1.5px 3px 1px  var(--c-extrude-deep),
    2px   4px 2px  var(--c-extrude-deep),
    2.5px 5px 4px  var(--c-extrude-deep),
    /* where the solid meets the card, then the pool it drops further out */
    3px   7px 9px  var(--c-extrude-cast),
    4px  16px 26px var(--c-extrude-cast-soft);
}

/* Section and card headings are a third the size of the name, so the same
   geometry at the same scale would swamp them: three slices of wall, a
   half-strength lip, one shadow. */
body[data-page="home"] .section-title,
body[data-page="home"] .card__title {
  text-shadow:
    -0.5px -0.5px 0 var(--c-lip),
    0.5px 1px 0    var(--c-extrude),
    1px   2px 1px  var(--c-extrude-deep),
    1.5px 3px 3px  var(--c-extrude-deep),
    2px   6px 10px var(--c-extrude-cast-soft);
}

/* The drop cap is already the largest thing in the article; it takes the full
   treatment, a little deeper again, so it does not end up the only flat letter
   on the page. */
body[data-page="home"] .bio__lead::first-letter {
  text-shadow:
    -0.5px -1px 0 var(--c-lip),
    1px   2px 0    var(--c-extrude),
    1.5px 3px 1px  var(--c-extrude),
    2px   4px 2px  var(--c-extrude-deep),
    2.5px 6px 5px  var(--c-extrude-deep),
    3px   9px 12px var(--c-extrude-cast),
    4px  18px 28px var(--c-extrude-cast-soft);
}

/* Reduced motion is not the concern here — nothing moves — but a reader who
   has asked for more contrast has asked for fewer soft edges around type. */
@media (prefers-contrast: more) {
  body[data-page="home"] .hero__name,
  body[data-page="home"] .section-title,
  body[data-page="home"] .card__title,
  body[data-page="home"] .bio__lead::first-letter { text-shadow: none; }
}

/* -----------------------------------------------------------------------------
   9. The calligraphic brand mark
   The bismillah in the top left.  The mark itself is WebGL — see
   `modules/bismillah/index.js` — and everything here is either the light around
   it or the arrangement that lets the light and the mark share a box.

     the aura     light in every direction, brighter in some than others, and
                  changing.  Two conic gradients turning at different speeds in
                  opposite directions: where their bright arcs coincide the glow
                  swells, where they oppose it thins, and because the periods do
                  not divide into each other the pattern never repeats.

     the meteor   a bright arc running counter-clockwise around the mark's own
                  border.  A stroked path with a moving dash, not a rotated
                  gradient: this circumference is a piece of calligraphy, and a
                  conic gradient sweeps at a constant ANGLE rather than along a
                  path, which on a shape like this means racing across the
                  lobes and crawling in the notches.  The path itself is
                  rewritten every frame by the module, from the mesh's own
                  rotation, so the light is on the border rather than near it.

     the shafts   columns of light standing off the border.  Positions, lengths
                  and lifetimes are rolled in the module; all that is needed
                  here is that they are allowed to leave the box.

     the hover    a step up in size and a beat, both done in the module so that
                  the mark and the light around it grow by the same amount at
                  the same moment.  A CSS transform on the wrapper would have
                  scaled the canvas's pixels instead of the mark.
   -------------------------------------------------------------------------- */
.bismillah {
  position: relative;
  /* Nearly the full bar: --bar-h is 4rem, and the mark is meant to be read,
     not merely noticed. */
  width: 3.5rem;
  height: 3.5rem;
  flex: none;
  display: block;
}

/* The poster and the canvas occupy the same box; only one of them is ever
   visible.  The poster is in the markup from the start, so there is no moment
   with a gap in the header. */
.bismillah__poster,
.bismillah__canvas {
  position: absolute;
  inset: 0;
  z-index: 1;
  width: 100%;
  height: 100%;
  display: block;
}

.bismillah__canvas { display: none; }
.bismillah[data-bismillah="three"] .bismillah__poster { display: none; }
.bismillah[data-bismillah="three"] .bismillah__canvas { display: block; }

/* ---- the aura --------------------------------------------------------------
   Sits behind the mark and spills well past it.  The mask fades it out toward
   the edge, so what is emitted is light rather than a coloured disc.
   -------------------------------------------------------------------------- */
.bismillah__aura,
.bismillah__aura::after {
  position: absolute;
  inset: -34%;
  border-radius: 50%;
  content: "";
  pointer-events: none;
  -webkit-mask: radial-gradient(closest-side, #000 6%, rgb(0 0 0 / 0.5) 40%, transparent 72%);
  mask: radial-gradient(closest-side, #000 6%, rgb(0 0 0 / 0.5) 40%, transparent 72%);
}

.bismillah__aura {
  z-index: 0;
  background: conic-gradient(
    from 0turn,
    rgb(0 204 255 / 0.38), rgb(0 204 255 / 0.05) 17%,
    rgb(0 204 255 / 0.42) 37%, rgb(0 204 255 / 0.04) 58%,
    rgb(0 204 255 / 0.32) 78%, rgb(0 204 255 / 0.06) 92%,
    rgb(0 204 255 / 0.38)
  );
  filter: blur(7px);
  animation: brand-aura-turn 11s linear infinite;
}

/* The second layer, turning the other way at a different rate. Their sum is
   what makes the glow feel alive rather than merely rotating. */
.bismillah__aura::after {
  inset: -6%;
  background: conic-gradient(
    from 0.35turn,
    rgb(150 240 255 / 0.30), rgb(150 240 255 / 0.03) 26%,
    rgb(150 240 255 / 0.34) 52%, rgb(150 240 255 / 0.03) 74%,
    rgb(150 240 255 / 0.30)
  );
  filter: blur(3px);
  animation: brand-aura-turn 7s linear infinite reverse;
}

@keyframes brand-aura-turn {
  from { transform: rotate(0turn); }
  to   { transform: rotate(1turn); }
}

/* ---- the light overlay ------------------------------------------------------
   Exactly over the canvas, so the module's 0–100 box and the rendered mark are
   the same box.  `overflow: visible` is not cosmetic: the shafts are supposed
   to leave the mark and spill into the bar, and without it they would be cut
   off at the edge of a 3.5rem square.
   -------------------------------------------------------------------------- */
.bismillah__lights {
  position: absolute;
  inset: 0;
  z-index: 2;
  display: block;
  pointer-events: none;
}

.bismillah__light {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
  fill: none;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* The shafts are stroked with a gradient that dies at their tip, so the only
   thing needed here is a little bloom around the bright end. */
.bismillah__shafts {
  filter: drop-shadow(0 0 1.5px rgb(0 204 255 / 0.6));
}

/* ---- the meteor -------------------------------------------------------------
   Two copies of the same path — a long faint one for the tail and a short
   bright one for the head — both with `pathLength="100"`, which makes the dash
   lengths percentages of the perimeter so the light travels at a constant speed
   the whole way round rather than racing on the straights.
   -------------------------------------------------------------------------- */
.bismillah__meteor-tail,
.bismillah__meteor {
  vector-effect: non-scaling-stroke;
  animation: bismillah-orbit-run 4.6s linear infinite;
}

.bismillah__meteor-tail {
  stroke: var(--c-brand);
  stroke-width: 1.1;
  stroke-dasharray: 15 85;
  opacity: 0.45;
}

.bismillah__meteor {
  stroke: #ffffff;
  stroke-width: 1.6;
  stroke-dasharray: 3.5 96.5;
  filter: drop-shadow(0 0 3px var(--c-brand)) drop-shadow(0 0 7px var(--c-brand));
}

/* Negative offset runs the dash FORWARD along the path, and the module hands us
   a path already wound counter-clockwise on screen (it checks the shoelace area
   and reverses the points when it has to).  Forward along a counter-clockwise
   path is counter-clockwise, which is what was asked for — and this sign is the
   one line in the file where getting it wrong is the whole feature lost. */
@keyframes bismillah-orbit-run {
  from { stroke-dashoffset: 0; }
  to   { stroke-dashoffset: -100; }
}

/* Reduced motion: the light stays, the movement goes.  The module stops its own
   loop under the same query, which leaves the mark lit and still. */
@media (prefers-reduced-motion: reduce) {
  .bismillah__aura,
  .bismillah__aura::after,
  .bismillah__meteor-tail,
  .bismillah__meteor { animation: none; }
  .bismillah__meteor { stroke-dasharray: 7 93; stroke-dashoffset: -22; }
  /* The module already declines to arrive under reduced motion; this is the
     belt to that pair of braces. */
  .bismillah.is-arriving .bismillah__aura { animation: none; }
}

/* ---- the arrival -----------------------------------------------------------
   The Summary page carries one bismillah and every other page carries another,
   so crossing between them changes the mark in the corner of the header. That
   change is announced: the new mark turns into place and grows into its size,
   and the aura flares while it does.

   The turn and the growth are NOT here — they are in the module, because the
   light around the mark is rewritten every frame from the mesh's own rotation
   and scale, and anything that moved the mark from outside would leave the
   meteor tracing a border the mark had left. What is here is the one part with
   no geometry in it: the aura, brightening and settling.
   -------------------------------------------------------------------------- */
.bismillah.is-arriving .bismillah__aura {
  animation: brand-aura-turn 11s linear infinite,
             bismillah-arrive 1.05s var(--ease-out);
}

@keyframes bismillah-arrive {
  0%   { opacity: 0.2; filter: blur(16px); }
  35%  { opacity: 1;   filter: blur(4px); }
  100% { opacity: 1;   filter: blur(7px); }
}

@media (max-width: 30rem) {
  .bismillah { width: 3rem; height: 3rem; }
}

/* -----------------------------------------------------------------------------
   10. The ascent — what pressing "back to top" looks like
   Scrolling a hundred publications back to the top instantly is disorienting:
   the page you were reading is simply replaced by a page you were not.  So the
   return is given a shape.  Every card, entry and panel on the page slides to
   the SAME point near the top, stacking into one another as it goes, tips away
   from the viewer and vanishes; the star field lifts and burns with them; then
   the page is at the top and the stack opens back out.

   HOW THE STACKING WORKS.  `backtotop.js` measures each element and writes
   `--dy`, the distance from where that element is to where the stack forms.
   The animation translates every element by exactly its own `--dy`, so a card
   near the top barely moves and one near the bottom travels the height of the
   page — and they all arrive together.  That convergence is the effect;
   translating everything by a fixed amount would just be the page sliding.

   `--i` staggers the start a little so the pile assembles rather than snapping
   shut, and the `perspective` on the ancestor is what makes the tip a rotation
   in space rather than a squash.

   WHAT COUNTS AS A BLOCK.  `[data-fx]` — the same attribute that opts an
   element into the contextual highlight.  Every card, person, publication
   entry, timeline row and project already carries it, which means this list
   maintains itself: anything added to the site that behaves like a block gets
   the attribute for the highlight and joins the stack for free.  `.panel` and
   the hero (which is `data-fx-field`) are named because they are blocks that
   opted out of the highlight for their own reasons.

   All of it is scoped to `html.fx-rise`, which nothing but `backtotop.js` adds.
   -------------------------------------------------------------------------- */
html.fx-rise .shell,
html.fx-land .shell { perspective: 1200px; perspective-origin: 50% 0%; }

html.fx-rise [data-fx],
html.fx-rise [data-fx-field],
html.fx-rise .panel {
  animation: fx-rise-stack 420ms var(--ease) both;
  animation-delay: calc(var(--i, 0) * 14ms);
  transform-style: preserve-3d;
}

@keyframes fx-rise-stack {
  from {
    opacity: 1;
    transform: translate3d(0, 0, 0) rotateX(0) scale(1);
  }
  55% { opacity: 1; }
  to {
    opacity: 0;
    /* `--dy` is negative: every element travels to the same y. The lift on z
       and the tip on x are what make the pile a pile rather than a stack of
       flat rectangles sliding under one another. */
    transform: translate3d(0, var(--dy, -40px), 90px) rotateX(24deg) scale(0.94);
  }
}

/* Coming back: the stack opens out again from just behind the page. Shorter
   than the departure, because arriving should feel like landing rather than
   like a second journey. */
html.fx-land [data-fx],
html.fx-land [data-fx-field],
html.fx-land .panel {
  animation: fx-rise-land 340ms var(--ease-out) both;
  animation-delay: calc(var(--i, 0) * 9ms);
}

@keyframes fx-rise-land {
  from { opacity: 0; transform: translate3d(0, -18px, 60px) rotateX(10deg) scale(0.97); }
  to   { opacity: 1; transform: none; }
}

/* The header and the section bar stay put through all of it — they are the one
   part of the page that is in the same place at the top as at the bottom, and
   moving them would break the illusion that the content is travelling past
   them. Same reasoning as the page transitions in §3.

   The lit element's own transform is suppressed for the duration: `.is-lit`
   sets a `transform` of its own, and two transforms on one element means the
   later rule wins outright — the card you happened to be pointing at would sit
   still while every other card left. */
html.fx-rise [data-fx].is-lit,
html.fx-land [data-fx].is-lit { transform: none; }

/* Someone who asked for less motion gets none of this: the button simply puts
   them at the top. `backtotop.js` checks the same query and never adds the
   classes, so this block is belt and braces. */
@media (prefers-reduced-motion: reduce) {
  html.fx-rise [data-fx], html.fx-rise [data-fx-field], html.fx-rise .panel,
  html.fx-land [data-fx], html.fx-land [data-fx-field], html.fx-land .panel {
    animation: none;
  }
}


/* -----------------------------------------------------------------------------
   20. The name in the left gutter
   Every page but Summary carries the name down the left of the screen as twelve
   extruded letters — see `modules/namecolumn/index.js` for what they do and
   why.

   WHERE IT IS, AND WHEN IT IS NOT.  The content shell is 75rem wide and
   centred, so a gutter only exists on a wide screen.  Below 88rem there is no
   room beside the content and the column is not rendered at all — `main.js`
   checks the same query before it imports anything, so a narrow window does not
   pay for a WebGL context it will never show.  This is the one piece of the
   site that is genuinely desktop-only, and it is that way because it is
   furniture for a space that only exists on a desktop.

   `top` IS SET BY THE MODULE, not here: the top of the "H" is lined up with the
   page's kicker, and only the module knows where inside its own canvas the top
   of the "H" landed.  The value below is what it starts from before the first
   measurement.

   It cannot be clicked through to and it is not in the accessibility tree: the
   name is already in the header, in text.  This is the same name said again in
   light.
   -------------------------------------------------------------------------- */
.namecol { display: none; }

@media (min-width: 88rem) {
  .namecol {
    display: block;
    position: fixed;
    left: 1.25rem;
    top: 8rem;
    width: 5rem;
    height: min(34rem, 78vh);
    /* Under the header, over the field.  A letter is allowed to pass behind the
       masthead and must never pass in front of it. */
    z-index: 20;
    pointer-events: none;
    /* The glow.  A drop-shadow rather than a bloom pass, for the same reason as
       the footer marks: a post-processing chain for twelve letters is an absurd
       amount of machinery for something the compositor does for free. */
    filter: drop-shadow(0 0 6px rgb(34 211 238 / 0.3));
  }

  .namecol__canvas {
    display: block;
    width: 100%;
    height: 100%;
  }
}

/* Reduced motion: the module never starts its loop — it draws one frame and
   stops — so the column is there, lit and still. Nothing to switch off here. */
