/* ------------------------------------------------------------------
   XS Element – base styles
   Tokens live in :root. Change these first; everything follows.

   Every page is a single fixed viewport over the tree image. Nothing
   scrolls; the header sits on a hairline and the headline sits low.
------------------------------------------------------------------ */

:root {
  --bg:        #000000;
  --ink:       #f2efe6;
  --ink-soft:  rgba(242, 239, 230, 0.72);
  --ink-faint: rgba(242, 239, 230, 0.45);
  --hairline:  rgba(242, 239, 230, 0.22);
  --accent:    #e9c258;
  --code-green: rgb(121, 238, 65);

  --font-display: "Fraunces", "Iowan Old Style", Georgia, serif;
  --font-mono: "DM Mono", ui-monospace, "SF Mono", Menlo, monospace;
  --font-body: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;

  --gutter: clamp(1.25rem, 4vw, 4rem);
  --card-gap: clamp(1rem, 2vw, 2rem);
  --ease: cubic-bezier(0.22, 1, 0.36, 1);
}

*, *::before, *::after { box-sizing: border-box; }

html, body { height: 100%; }

body {
  margin: 0;
  height: 100dvh;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--font-body);
  font-weight: 300;
  font-size: clamp(1rem, 0.95rem + 0.2vw, 1.0625rem);
  line-height: 1.65;
  -webkit-font-smoothing: antialiased;
}

a { color: inherit; text-decoration: none; }

/* --- backdrop ---------------------------------------------------- */

/* Full-bleed image over black at any screen size. The gradient mask
   feathers each edge over 8% of the viewport, and being percentage
   based it never eats a small screen the way fixed pixels would. */
/* Height is pinned to the large viewport (lvh), not inset:0, so the
   background does not resize when a mobile URL bar shows or hides on
   scroll. Horizontal stays tied to the viewport via left/right. */
.backdrop {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 100vh;
  height: 100lvh;
  z-index: -1;
  background: var(--bg);
}

.backdrop::before {
  content: "";
  position: absolute;
  inset: 5%;
  background: url("/assets/tree.webp") center / cover no-repeat;
  filter: brightness(0.8);
  opacity: var(--img-op, 1);
  -webkit-mask-image:
    linear-gradient(to right, transparent, #000 8%, #000 92%, transparent),
    linear-gradient(to bottom, transparent, #000 8%, #000 92%, transparent);
  -webkit-mask-composite: source-in;
  mask-image:
    linear-gradient(to right, transparent, #000 8%, #000 92%, transparent),
    linear-gradient(to bottom, transparent, #000 8%, #000 92%, transparent);
  mask-composite: intersect;
}

/* A soft scrim over the lower band keeps the headline readable now
   that the image runs underneath it at full brightness */
.backdrop::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(to top, rgba(0, 0, 0, 0.55), transparent 38%);
}

/* Inner pages swap the image; geometry and feather are shared.
   All five images are 2912x1632. */
.backdrop-about::before    { background-image: url("/assets/about.webp?v=2"); }
.backdrop-projects::before { background-image: url("/assets/projects.webp"); }
.backdrop-research::before { background-image: url("/assets/research.webp?v=3"); }
.backdrop-contact::before  { background-image: url("/assets/contact.webp?v=2"); }

/* Two pages carry a second layer over the first, brought in on scroll
   by backdrop-swap.js. The layer below fades its own image out under
   it, leaving the shared scrim in place.

   The home page's field of lights is another 2912x1632 frame, so it
   only swaps the image and keeps the shared geometry and feather. */
.backdrop-swap-tree::before { background-image: url("/assets/tree-field.webp"); }

/* The You page's eye is square, and stretching it across the screen
   would lose it, so it is anchored to the right edge at its own
   proportions with only its left side feathered into the black. That
   feather is eased rather than straight: the first fifth of the image
   stays all but invisible and the ramp is spent in the middle of the
   run, so the face is gone long before the copy column and the eye
   still arrives at full strength. */
.backdrop-swap {
  background: none;
  opacity: var(--swap-op, 0);
}

.backdrop-swap-eye::before {
  inset: 0 0 0 auto;
  width: min(100%, 100lvh);
  background: url("/assets/eye.webp") center right / cover no-repeat;
  -webkit-mask-image: linear-gradient(to right, transparent 0%, rgba(0, 0, 0, 0.02) 20%, rgba(0, 0, 0, 0.1) 28%, rgba(0, 0, 0, 0.3) 34%, rgba(0, 0, 0, 0.65) 40%, #000 48%);
  mask-image: linear-gradient(to right, transparent 0%, rgba(0, 0, 0, 0.02) 20%, rgba(0, 0, 0, 0.1) 28%, rgba(0, 0, 0, 0.3) 34%, rgba(0, 0, 0, 0.65) 40%, #000 48%);
}

.backdrop-swap::after { display: none; }

/* --- header ------------------------------------------------------ */

.bar {
  display: flex;
  align-items: stretch;
  justify-content: space-between;
  border-bottom: 1px solid var(--hairline);
  background: linear-gradient(to bottom, rgba(6, 10, 8, 0.35), rgba(6, 10, 8, 0));
}

.mark {
  display: inline-flex;
  align-items: center;
  gap: 0.65rem;
  padding: clamp(1.1rem, 2.5vw, 1.5rem) var(--gutter);
}

.mark-glyph {
  width: 11px; height: 11px;
  border: 1px solid currentColor;
  transform: rotate(45deg);
  transition: transform 0.6s var(--ease);
}
.mark:hover .mark-glyph { transform: rotate(225deg); }

.mark-text {
  font-size: 0.95rem;
  letter-spacing: 0.02em;
  font-weight: 400;
}

/* MP2-style menu: uppercase cells divided by hairlines. Each cell
   has an accent bar pinned to its bottom edge: 0 tall at rest, 4px
   on the current page, and on hover it rises to flood the whole
   cell while the text fades to dark. */
.nav { display: flex; align-items: stretch; }

.nav a {
  position: relative;
  isolation: isolate;
  display: flex;
  align-items: center;
  padding: 0 clamp(0.9rem, 2.2vw, 2rem);
  border-left: 1px solid var(--hairline);
  font-size: 0.72rem;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  font-weight: 400;
  color: var(--ink-soft);
  transition: color 0.3s;
}
.nav a:last-child { border-right: 1px solid var(--hairline); }

.nav a::after {
  content: "";
  position: absolute;
  left: 0; right: 0; bottom: 0;
  height: 0;
  z-index: -1;
  background: var(--accent);
  transition: height 0.3s;
}

.nav a[aria-current="page"] { color: var(--ink); }
.nav a[aria-current="page"]::after { height: 4px; }

.nav a:hover { color: #101210; }
.nav a:hover::after { height: 100%; }

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

.hero {
  position: relative;
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  gap: 1rem;
  padding: 0 var(--gutter) clamp(2.5rem, 7vh, 5rem);
}

/* The visitor's own address, centred in the opening screen: the claim
   the panels below spend the rest of the page explaining. */
.hero-ip {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: min(100% - 2 * var(--gutter), 46rem);
  padding: 2.5rem 2rem;
  text-align: center;
  pointer-events: none;
  /* No ground behind it: the text sits straight on the image, so the
     shadow does all the work. Layered rather than single: a tight pair
     to cut the glyph out of whatever is immediately behind it, and two
     wide soft ones to darken the light strands further out. */
  text-shadow:
    0 0 3px rgba(0, 0, 0, 0.95),
    0 1px 4px rgba(0, 0, 0, 0.95),
    0 2px 14px rgba(0, 0, 0, 0.9),
    0 4px 30px rgba(0, 0, 0, 0.85);
}

.hero-ip-addr {
  margin: 0;
  font-family: var(--font-mono);
  font-weight: 300;
  font-size: clamp(1.8rem, 5.5vw, 4rem);
  line-height: 1.1;
  color: var(--code-green);
  /* IPv6 runs long enough to overflow a phone at this size */
  overflow-wrap: anywhere;
}

.hero-ip-org {
  margin: 0.7rem 0 0;
  font-size: 0.74rem;
  letter-spacing: 0.26em;
  text-transform: uppercase;
  color: var(--ink);
}

/* Place and VPN verdict, quieter than the carrier above them */
.hero-ip-line {
  margin: 0.4rem 0 0;
  font-family: var(--font-mono);
  font-size: 0.82rem;
  letter-spacing: 0.06em;
  color: var(--ink);
}
.hero-ip-line:empty { display: none; }

.eyebrow {
  margin: 0;
  font-size: 0.7rem;
  letter-spacing: 0.3em;
  text-transform: uppercase;
  color: var(--ink-faint);
}

.display {
  margin: 0;
  max-width: 14ch;
  font-family: var(--font-mono);
  font-weight: 300;
  font-size: clamp(2rem, 6vw, 4.75rem);
  line-height: 0.98;
  letter-spacing: -0.02em;
  text-shadow: 0 2px 24px rgba(6, 10, 8, 0.45);
}

.display em {
  font-style: normal;
  color: var(--accent);
}

.title-gold { color: var(--accent); }
.title-green { color: var(--code-green); }

.subtitle {
  max-width: 44ch;
  margin: 0;
  font-family: var(--font-display);
  font-weight: 300;
  font-size: clamp(1.1rem, 2vw, 1.6rem);
  color: var(--ink-soft);
  text-shadow: 0 1px 12px rgba(6, 10, 8, 0.5);
}

.subtitle em {
  font-style: italic;
  color: var(--accent);
}

.subtitle a {
  border-bottom: 1px solid var(--hairline);
  transition: color 0.3s, border-color 0.3s;
}
.subtitle a:hover {
  color: var(--accent);
  border-color: var(--accent);
}

/* Home headline scramble: the word resolves in the code green
   sampled from the backdrop images; the title's mono face keeps
   letters in position while they roll */
#scramble { color: var(--code-green); }

.pending { display: inline-block; }

/* --- scroll pages ------------------------------------------------ */

/* A page that scrolls: the first paint matches the fixed pages
   exactly (bar on top, title low), then content continues below.
   The bar is fixed and earns a glass surface once scrolled. */
body.page-scroll {
  display: block;
  height: auto;
  min-height: 100dvh;
  overflow: hidden auto;
}

.page-scroll .bar {
  position: fixed;
  top: 0; left: 0; right: 0;
  z-index: 10;
}

.page-scroll .hero {
  min-height: 100vh;
  min-height: 100lvh; /* stable while a mobile URL bar hides */
}

/* Glass blocks: hairline outlines like the bar, translucent liquid
   glass over the backdrop */
.blocks {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr));
  gap: var(--card-gap);
  padding: 0 var(--gutter);
  margin: var(--card-gap) 0;
}

.card-glass {
  border: 1px solid var(--hairline);
  background: rgba(242, 239, 230, 0.06);
  -webkit-backdrop-filter: blur(16px) saturate(1.15);
  backdrop-filter: blur(16px) saturate(1.15);
  padding: clamp(1.25rem, 2.5vw, 2rem);
}

.card-wide { grid-column: 1 / -1; }

.card-index {
  margin: 0 0 1.2rem;
  font-size: 0.7rem;
  letter-spacing: 0.26em;
  text-transform: uppercase;
  color: var(--ink-faint);
}

.card-figure {
  margin: 0;
  font-family: var(--font-mono);
  font-weight: 300;
  font-size: clamp(2.5rem, 5vw, 4rem);
  line-height: 1;
  color: var(--accent);
}

.card-figure.green { color: var(--code-green); }

.card-note {
  margin: 0.9rem 0 0;
  color: var(--ink-soft);
  font-size: 0.95rem;
}

/* Small print under a card: the caveat someone needs before they click,
   not so loud that it reads as a warning about the thing itself. */
.card-fine {
  margin: 0.6rem 0 0;
  max-width: 60ch;
  color: var(--ink-faint);
  font-size: 0.82rem;
  line-height: 1.5;
}

.card-prose {
  margin: 0;
  max-width: 60ch;
  color: var(--ink-soft);
}

.card-name {
  margin: 0;
  font-family: var(--font-mono);
  font-weight: 300;
  font-size: clamp(1.4rem, 2.4vw, 2.2rem);
}

.card-link {
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
  transition: border-color 0.3s;
}

.card-link:hover { border-color: var(--accent); }

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

/* A viewport-height frame: the project list is a right-aligned
   stack taking 25% of the width; hovering a project reveals its
   detail panel filling the rest */
.proj-frame {
  display: flex;
  align-items: stretch;
  --list-gap: clamp(0.5rem, 1.2vw, 1rem);
  gap: var(--card-gap);
  /* Sized by script to the gap between the fixed header and the footer,
     so the whole frame fits one screen with nothing overlapping. */
  height: var(--frame-h, 70vh);
  min-height: 0;
  padding: 0 var(--gutter);
  margin: 0;
}

/* The frame already accounts for the footer, so no gap above it. */
.proj-frame + .foot { margin-top: 0; }

/* Reserves the exact spot the hero subtitle flies to. Hidden, because
   the real subtitle lands on top of it and becomes the heading. */
.proj-slot {
  visibility: hidden;
  flex: none;
  margin: 0;
}

.proj-detail {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: var(--list-gap);
}

.proj-detail-stack {
  flex: 1;
  min-height: 0;
  position: relative;
}

.proj-detail .card-glass {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  /* Top aligned so the index and the project name land in the same
     spot whichever project is showing. */
  justify-content: flex-start;
  gap: 1rem;
  opacity: 0;
  transition: opacity 0.35s ease;
  pointer-events: none;
}

.proj-detail .card-glass.show { opacity: 1; pointer-events: auto; }

.proj-detail .card-note { margin-top: auto; }

/* The prose scrolls inside the card rather than growing it. A card is
   inset: 0 on a fixed stack, so a paragraph longer than the room left
   used to run down through the download line and the note under it.
   The rail only appears when there is something to scroll, which is
   what keeps it out of the way on the short cards. */
.proj-detail .card-prose {
  min-height: 0;
  overflow-y: auto;
  /* Chaining is left on deliberately: reaching the end of the prose
     should hand the wheel back to the page, which is what drives the
     title and the ring. */
  scrollbar-width: thin;
  scrollbar-color: var(--hairline) transparent;
  padding-right: 0.6rem;
}
/* On a card with no screenshot the prose is a direct child of the
   column and needs to be told to take the space. */
.proj-detail .card-glass > .card-prose { flex: 1 1 auto; }

.proj-detail .card-prose::-webkit-scrollbar { width: 3px; }
.proj-detail .card-prose::-webkit-scrollbar-track { background: transparent; }
.proj-detail .card-prose::-webkit-scrollbar-thumb {
  background: var(--hairline);
  border-radius: 2px;
}

/* A project carrying a screenshot splits its row: prose on the left,
   the shot on the right scaled to whatever height the card has left,
   so the frame still ends where the footer starts. The source PNG has
   transparent window corners flattened onto black, so the shot needs
   no border to sit on the backdrop. */
.proj-split {
  flex: 1;
  min-height: 0;
  display: flex;
  gap: var(--card-gap);
  align-items: stretch;
}
.proj-split .card-prose { flex: 1 1 auto; }

/* Placed like the cover flow, for the same reason: in the prose row a
   tall screenshot was only as tall as the paragraph beside it. */
.proj-shot {
  position: absolute;
  top: 5.75rem;
  right: clamp(1.25rem, 2.5vw, 2rem);
  bottom: clamp(1.25rem, 2.5vw, 2rem);
  width: 54%;
  margin: 0;
}
.proj-shot img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: contain;
  object-position: center;
}

/* A landscape screenshot fills the width of that box rather than its
   height, so at the shared 54% it reached back across into the prose.
   This one is narrower and centred on the height it needs, and it
   carries a frame: unlike the captures taken against black, comet's
   window sits on its own near-black and its edge would otherwise
   dissolve into the card instead of reading as a window. */
.proj-shot-wide {
  width: 44%;
  top: 50%;
  bottom: auto;
  transform: translateY(-50%);
  padding: 0.5rem;
  border: 1px solid var(--hairline);
  border-radius: 12px;
  background: rgba(242, 239, 230, 0.04);
  box-shadow: 0 30px 65px rgba(0, 0, 0, 0.65);
}
/* Height comes from the image here, so the shared height: 100% would
   measure against a box that has none. */
.proj-shot-wide img {
  height: auto;
  border-radius: 6px;
}

/* A cover flow. The shot in front is square on to you and the rest fall
   away to either side, turned inward and set back, so a card can carry
   any number of screens in one slot. Which one is in front is the only
   state; proj-flow.js writes the transform for each shot from its
   distance to it, and the transition here does the travelling.

   The figure carries a height of its own because its images are
   absolutely positioned and would otherwise leave it with none, which
   collapses the row it sits in. */
/* Taken out of the prose row and given the height of the whole card,
   from under the project name to the card's lower edge. Left in the
   row it was only as tall as the paragraph beside it, which on a card
   with three lines of prose left the screens small in a large empty
   space. */
.proj-flow {
  position: absolute;
  top: 5.75rem;
  right: clamp(1.25rem, 2.5vw, 2rem);
  bottom: clamp(1.25rem, 2.5vw, 2rem);
  width: 54%;
  margin: 0;
  perspective: 1600px;
  perspective-origin: 50% 50%;
  outline: none;
  /* A deep row still reaches the edges eventually, so it is clipped to
     the card and faded rather than sliced. */
  overflow: hidden;
  -webkit-mask-image: linear-gradient(to right, transparent, #000 5%, #000 95%, transparent);
  mask-image: linear-gradient(to right, transparent, #000 5%, #000 95%, transparent);
}

/* The prose keeps to its own half, since the shots no longer hold a
   column open in the row. */
.proj-split:has(.proj-flow) .card-prose,
.proj-split:has(.proj-shot) .card-prose { max-width: 44%; }

/* The captures are cropped to the window itself, off the black they were
   taken against, so the corner rounding and the drop shadow are the
   page's to draw. Left on, that black margin read as a rectangle sitting
   on the card rather than as a window floating over it. */
.proj-flow img {
  position: absolute;
  top: 50%;
  left: 50%;
  height: 100%;
  width: auto;
  max-width: none;
  border-radius: 10px;
  box-shadow: 0 30px 65px rgba(0, 0, 0, 0.65);
  cursor: pointer;
  will-change: transform;
  transition: transform 0.55s var(--ease), opacity 0.55s var(--ease);
}

/* Only the shot in front is worth clicking; the rest are targets for
   bringing forward, which is what the cursor above says. */
.proj-flow img.front { cursor: default; }

/* Keyboard focus lands on the figure, not on each shot in turn. */
.proj-flow:focus-visible { outline: 1px solid var(--accent); outline-offset: 6px; }

/* Repo links for a project that ships from more than one source tree */
.card-links {
  display: flex;
  align-items: baseline;
  gap: 1.1rem;
  margin: 1rem 0 0;
  font-family: var(--font-mono);
  font-size: 0.78rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-faint);
}
.card-links a {
  color: var(--code-green);
  border-bottom: 1px solid transparent;
  transition: border-color 0.3s;
}
.card-links a:hover { border-color: currentColor; }

.proj-name a { border-bottom: 1px solid transparent; transition: border-color 0.3s; }
.proj-name a:hover { border-color: currentColor; }

.proj-name {
  margin: 0;
  font-family: var(--font-mono);
  font-weight: 300;
  font-size: clamp(2rem, 4vw, 3.2rem);
  line-height: 1.05;
  color: var(--code-green);
}

.proj-list {
  width: 25%;
  min-width: 15rem;
  margin-left: auto;
  display: flex;
  flex-direction: column;
  gap: var(--list-gap);
}

/* Six of these share the frame's height, so the padding has to give
   before the type does: a box is as tall as a sixth of the column and
   the text still has to sit in it on a short laptop screen. */
.proj-item {
  flex: 1;
  min-height: 0;
  /* Vertical padding in vh, so a short window tightens the boxes
     instead of pushing their text past the border. */
  padding: clamp(0.35rem, 1.1vh, 0.95rem) clamp(1rem, 2vw, 1.6rem);
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 0.2rem;
  cursor: pointer;
  transition: border-color 0.3s;
}
.proj-item .card-index { margin: 0; }
.proj-item .proj-item-name,
.proj-item .card-index { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.proj-item:hover,
.proj-item.active { border-color: var(--code-green); }

.proj-panel { display: none; }

.proj-item-name {
  position: relative;
  margin: 0;
  font-family: var(--font-mono);
  font-weight: 300;
  font-size: clamp(1rem, 1.4vw, 1.35rem);
  line-height: 1.1;
  color: var(--ink);
}

/* Below this there is no room for six names and six numbers. The
   number is decoration, and the detail card still carries it. */
@media (max-height: 720px) and (min-width: 761px) {
  .proj-item .card-index { display: none; }
}

/* The stack fades down from the top when it enters the viewport */
body.js .proj-item { opacity: 0; }
body.js .proj-list.in .proj-item {
  opacity: 1;
  animation: drop-in 0.6s var(--ease) backwards;
}
/* Counted by type, so the heading spacer above them does not shift the
   order and leave the last box arriving first. */
body.js .proj-list.in .proj-item:nth-of-type(2) { animation-delay: 0.08s; }
body.js .proj-list.in .proj-item:nth-of-type(3) { animation-delay: 0.16s; }
body.js .proj-list.in .proj-item:nth-of-type(4) { animation-delay: 0.24s; }
body.js .proj-list.in .proj-item:nth-of-type(5) { animation-delay: 0.32s; }
body.js .proj-list.in .proj-item:nth-of-type(6) { animation-delay: 0.40s; }

@keyframes drop-in {
  from {
    opacity: 0;
    transform: translateY(-2.5rem);
  }
}

/* Extra runway so the title finishes its exit before this section
   reaches the viewport */
.blocks-after-exit { margin-top: 60vh; }

/* The home studio box waits for the title to clear the screen */
body.js #studio {
  opacity: 0;
  transition: opacity 0.5s ease;
}

body.js #studio.show { opacity: 1; }

/* --- fingerprint (the You page) ---------------------------------- */

.blocks-2 {
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 24rem), 1fr));
}

.fp-head {
  display: flex;
  align-items: center;
  gap: 1rem;
}

.fp-logo { width: 44px; height: 44px; flex: none; }
.fp-logo svg { width: 100%; height: 100%; }

.fp-name {
  margin: 0;
  font-family: var(--font-mono);
  font-weight: 300;
  font-size: clamp(1.3rem, 2vw, 1.8rem);
  line-height: 1.15;
}

.fp-sub {
  margin: 0.1rem 0 0;
  font-size: 0.8rem;
  color: var(--ink-faint);
}

.fp-kv {
  margin: 1.2rem 0 0;
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 0.35rem 1.2rem;
  font-size: 0.85rem;
}

.fp-kv dt {
  color: var(--ink-faint);
  text-transform: uppercase;
  letter-spacing: 0.12em;
  font-size: 0.62rem;
  padding-top: 0.2rem;
  white-space: nowrap;
}

.fp-kv dd {
  margin: 0;
  color: var(--ink-soft);
  font-family: var(--font-mono);
  overflow-wrap: anywhere;
}

/* The user agent string is the one value long enough to need its own
   size. It was a style attribute on the element until the CSP went in,
   and inline styles are exactly what that policy exists to refuse. */
.fp-kv dd#br-ua { font-size: 0.66rem; }

.fp-big {
  margin: 0.4rem 0 0;
  font-family: var(--font-mono);
  font-weight: 300;
  font-size: clamp(1.5rem, 2.6vw, 2.4rem);
  line-height: 1.1;
  color: var(--accent);
  overflow-wrap: anywhere;
}

.fp-big.green { color: var(--code-green); }

/* Same treatment as the cookies note: a bordered box of small mono text,
   so an explanation reads as an aside rather than as the main event. */
.fp-box {
  margin-top: 1rem;
  border: 1px solid var(--hairline);
  padding: 0.75rem 1rem;
  font-family: var(--font-mono);
  font-size: 0.78rem;
  line-height: 1.7;
  color: var(--ink-soft);
}
.fp-box p { margin: 0; }
.fp-box p + p { margin-top: 0.8rem; }
.fp-box b { color: var(--accent); font-weight: 400; }

.card-prose + .card-prose { margin-top: 0.9rem; }
.card-prose b { color: var(--accent); font-weight: 400; }

.fp-scroll {
  margin-top: 1rem;
  max-height: 11rem;
  overflow: auto;
  border: 1px solid var(--hairline);
  padding: 0.75rem 1rem;
  font-family: var(--font-mono);
  font-size: 0.78rem;
  line-height: 1.7;
  color: var(--ink-soft);
  scrollbar-width: thin;
  scrollbar-color: var(--hairline) transparent;
}

/* This card is stretched by the taller one beside it, so the height it
   gains has to be passed down: card to split, split to map slot, slot to
   the map frame. Without that chain the map keeps its own content height
   and the surplus collects as dead space underneath. */
.loc-card { display: flex; flex-direction: column; }
.loc-card .loc-split { flex: 1; }

/* The map takes the larger share and stretches to the height of the
   readings beside it, rather than sitting at a fixed height and leaving
   the rest of the card empty. */
.loc-split {
  display: flex;
  gap: var(--card-gap);
  align-items: stretch;
  flex-wrap: wrap;
}
.loc-split .fp-kv { flex: 1 1 11rem; margin-top: 1rem; align-content: start; }
.loc-split #loc-map-slot {
  flex: 1.7 1 15rem;
  margin-top: 0.55rem;
  display: flex;
  flex-direction: column;
}

/* The iframe is taller than this frame so its bottom attribution bar
   is clipped; we credit OpenStreetMap in .fp-map-credit below instead */
.fp-map-wrap {
  position: relative;
  flex: 1;
  min-height: 240px;
  overflow: hidden;
  border: 1px solid var(--hairline);
}

/* The embed is oversized and offset so its own furniture falls outside
   the frame: the attribution bar off the bottom, and the zoom buttons off
   the right. Both edges are clipped by the same amount so the marker
   stays centred. They are cropped rather than hidden because the embed is
   cross-origin, and pointless here anyway since the map does not take
   input. */
.fp-map {
  display: block;
  /* A picture, not a control: without this the wheel zooms the map and
     the page stops scrolling wherever the pointer happens to be. */
  pointer-events: none;
  position: absolute;
  top: 0;
  left: -56px;
  width: calc(100% + 112px);
  height: calc(100% + 52px);
  border: 0;
  filter: invert(0.92) hue-rotate(180deg) saturate(0.45) contrast(1.2) brightness(0.85);
}

/* Sits on the map rather than under it: the attribution stays visible
   but stops charging the map two lines of height. */
.fp-map-credit {
  position: absolute;
  left: 0.55rem;
  bottom: 0.4rem;
  z-index: 1;
  margin: 0;
  font-size: 0.6rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: rgba(242, 239, 230, 0.62);
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.95), 0 0 8px rgba(0, 0, 0, 0.9);
}
.fp-map-credit a { border-bottom: 1px solid var(--hairline); }

/* --- contact form ------------------------------------------------ */

/* A fixed panel that fades into place as the backdrop goes black rather
   than scrolling up the page: the contact form, and the link tree on
   the research page. The spacer supplies the scroll distance that drives
   the fade, since neither panel is tall enough to scroll on its own. */
.fade-stage {
  position: fixed;
  top: var(--bar-h, 74px);
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 4;
  display: grid;
  place-items: center;
  padding: 1rem var(--gutter) 4.5rem;
  opacity: 0;
  pointer-events: none;
}
.fade-stage.live { pointer-events: auto; }

/* The link tree hangs from the top of the panel instead of sitting in
   the middle of it: the columns are all different lengths, so centring
   the block leaves the tops ragged. The filter takes the height it
   needs and the tree takes the rest, which is what gives the tree
   something to scroll inside. The columns run down behind the preview
   panel, which paints over them and which the fade dissolves them
   into. */
.fade-stage-top {
  --stage-top: clamp(1.5rem, 5vh, 3.5rem);
  grid-template-rows: minmax(0, 1fr);
  align-items: stretch;
  padding-top: var(--stage-top);
  padding-bottom: 5.5rem;
  transition: padding-top 0.45s var(--ease);
}

/* The open field takes the line the headings were on, and the columns
   move down out of its way. */
.fade-stage-top.filter-open { padding-top: calc(var(--stage-top) + 3.4rem); }

.fade-space { height: 105vh; height: 105lvh; }

.contact-form {
  display: grid;
  gap: 0.7rem;
  width: min(100%, 38rem);
  padding: clamp(1rem, 2vw, 1.5rem);
}

.contact-form .card-index { margin-bottom: 0.2rem; }

.contact-row {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr));
  gap: 0.7rem;
}

.field { display: grid; gap: 0.25rem; }

.field label {
  font-size: 0.58rem;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--ink-faint);
}

.field input,
.field textarea {
  width: 100%;
  padding: 0.5rem 0.7rem;
  font-family: var(--font-mono);
  font-size: 0.88rem;
  color: var(--ink);
  background: rgba(0, 0, 0, 0.3);
  border: 1px solid var(--hairline);
  transition: border-color 0.3s;
}

.field input:focus,
.field textarea:focus { outline: none; border-color: var(--accent); }
.field textarea { resize: vertical; min-height: 4.5rem; }
.field.bad input, .field.bad textarea { border-color: #ff8a7a; }

/* Off-screen decoy: a person never sees it, so anything typed into it
   came from a bot filling every field it found. */
.hp { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }

.cf-foot {
  display: flex;
  align-items: center;
  gap: 1rem;
  flex-wrap: wrap;
  margin-top: 0.2rem;
}

.btn {
  padding: 0.6rem 1.4rem;
  font-family: var(--font-mono);
  font-size: 0.68rem;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--ink);
  background: transparent;
  border: 1px solid var(--hairline);
  cursor: pointer;
  transition: background-color 0.3s, color 0.3s, border-color 0.3s;
}
.btn:hover { background: var(--accent); border-color: var(--accent); color: #101210; }
.btn[disabled] { opacity: 0.5; cursor: default; }

.cf-status { margin: 0; font-size: 0.82rem; color: var(--ink-soft); }
.cf-status.ok { color: var(--code-green); }
.cf-status.err { color: #ff8a7a; }

/* --- research link tree ------------------------------------------- */

/* Parallel trees, one per category. Collapsed, a column shows a single
   character per link: the first letter of its domain, so a screen of
   them reads as the columns of falling characters in the artwork.

   The columns are equal fractions of a fixed-width row, so they are
   spread evenly across the page and the whole width of a fraction is
   part of its column – there is no dead strip between them that would
   close everything as the pointer crosses. Opening a column takes its
   width out of the row, which squeezes the others towards the nearer
   edge with no per-column arithmetic.

   All of it is CSS over plain markup, so the panel works with
   JavaScript off, minus the scroll fade that brings it in. */
.tree {
  display: flex;
  align-items: flex-start;
  width: min(100%, 68rem);
  min-height: 0;
  /* Closed until the panel has finished arriving: a scrollable tree
     under the pointer would take the wheel before the page had faded
     it in, and the fade would never happen. */
  overflow: hidden;
  font-family: var(--font-mono);
  font-size: 0.95rem;
  line-height: 2.3;
}

.fade-stage.live .tree {
  overflow-y: auto;
  scrollbar-width: thin;
  scrollbar-color: var(--hairline) transparent;
}

/* Equal fractions of the row, the last one included, so every column
   has room for its heading and its subgroup names while closed. */
.tree-col {
  flex: 1 1 0;
  min-width: 0;
  transition: flex-grow 0.55s var(--ease), flex-basis 0.55s var(--ease);
}

/* Open: under the pointer, holding keyboard focus, or opened from the
   margin beside it. Filtering opens its own columns instead, and takes
   the hover width off so several can stand open at once. */
.tree:not(.filtering) .tree-col:hover,
.tree:not(.filtering) .tree-col:focus-within,
.tree:not(.filtering) .tree-col.open { flex: 0 0 min(22rem, 60vw); }

/* Pinned to the head of the scroller, with the background painted in
   so the links pass behind rather than through them. */
.tree-cat {
  position: sticky;
  top: 0;
  z-index: 2;
  margin: 0 0 0.7rem;
  padding-bottom: 0.4rem;
  background: var(--bg);
  font-family: var(--font-mono);
  font-size: 0.72rem;
  font-weight: 400;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  white-space: nowrap;
  color: var(--accent);
}

.tree-sub { overflow: hidden; }
.tree-sub + .tree-sub { margin-top: 1.1rem; }

/* Always there, whether the column is open or closed, but folded to
   its initial while closed so a shut column stays a strip of single
   characters. The name itself stays in the markup for the filter and
   for anything reading the page aloud; it is the type size that goes,
   and the initial comes back in front of it. */
.tree-sub-name {
  margin: 0;
  font-size: 0;
  letter-spacing: 0;
  font-weight: 400;
  text-transform: uppercase;
  white-space: nowrap;
  color: var(--ink-faint);
}

.tree-sub-name::before {
  content: attr(data-initial);
  display: inline-block;
  font-size: 0.66rem;
  letter-spacing: 0.22em;
  line-height: 2.3;
}

.tree-col:hover .tree-sub-name,
.tree-col:focus-within .tree-sub-name,
.tree-col.open .tree-sub-name,
.filtering .tree-sub-name {
  font-size: 0.66rem;
  letter-spacing: 0.22em;
}

.tree-col:hover .tree-sub-name::before,
.tree-col:focus-within .tree-sub-name::before,
.tree-col.open .tree-sub-name::before,
.filtering .tree-sub-name::before { content: none; }

.tree-links { margin: 0; padding: 0; list-style: none; }

.tree-links a {
  display: flex;
  align-items: baseline;
  gap: 0.75rem;
  white-space: nowrap;
  color: var(--ink-soft);
}

/* Fixed, so the letters stay put as the rest of the row arrives beside
   them and the columns keep their vertical line.

   A closed column's letters turn slowly on their vertical axis, each on
   its own clock: mostly still, then one revolution, which research-tree
   .js scatters by giving every letter a random duration and start. The
   turning stops on the column being read. */
.tree-glyph {
  flex: 0 0 auto;
  width: 1.8rem;
  color: var(--code-green);
  opacity: 0.55;
  transition: opacity 0.35s linear, color 0.35s linear;
  animation: glyph-turn 24s linear infinite;
  /* Half a character in, so the axis runs down the middle of the letter
     rather than the middle of its 1.8rem cell. Turned about the cell,
     the letter swings out sideways and leaves its column. */
  transform-origin: 0.29rem 50%;
}

@keyframes glyph-turn {
  0%, 62% { transform: perspective(600px) rotateY(0deg); }
  100% { transform: perspective(600px) rotateY(360deg); }
}

.tree-col:hover .tree-glyph,
.tree-col:focus-within .tree-glyph,
.tree-col.open .tree-glyph,
.filtering .tree-glyph { animation: none; }


@media (prefers-reduced-motion: reduce) {
  .tree-glyph { animation: none; }
}

.tree-url {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  opacity: 0;
  transition: opacity 0.35s linear;
}

.tree-col:hover .tree-glyph,
.tree-col:focus-within .tree-glyph,
.tree-col.open .tree-glyph,
.filtering .tree-glyph { opacity: 1; }

.tree-col:hover .tree-url,
.tree-col:focus-within .tree-url,
.tree-col.open .tree-url,
.filtering .tree-url { opacity: 1; }

.tree-links a:hover .tree-glyph,
.tree-links a:focus-visible .tree-glyph { color: var(--accent); }

.tree-links a:hover .tree-url,
.tree-links a:focus-visible .tree-url { color: var(--ink); }

/* Out in the left margin, directly under the wordmark in the header and
   on the same left edge as it, so it is one more thing in that column
   rather than a bar across the top of the links. Absolute, so opening
   it costs the columns no room. */
.tree-filter {
  position: absolute;
  /* On the line the column headings sit on, out in the margin beside
     them. It stays put when the columns move down for the field. */
  top: calc(var(--stage-top) - 0.2rem);
  left: calc(var(--gutter) - 0.3rem);
  right: var(--gutter);
  z-index: 3;
  display: flex;
  align-items: flex-start;
  gap: 0.55rem;
}

.tree-filter-toggle {
  flex: 0 0 auto;
  display: grid;
  place-items: center;
  width: 1.6rem;
  height: 1.6rem;
  padding: 0;
  color: var(--ink-faint);
  background: none;
  border: 0;
  cursor: pointer;
  transition: color 0.3s;
}

.tree-filter-toggle svg {
  width: 17px;
  height: 17px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.3;
  stroke-linecap: round;
}

.tree-filter-toggle:hover,
.tree-filter-toggle:focus-visible,
.tree-filter.open .tree-filter-toggle { color: var(--accent); }

/* Closed to nothing rather than hidden, so it opens sideways out of the
   icon rather than appearing whole, and runs the rest of the way across
   the page. */
.tree-filter-body {
  display: flex;
  align-items: center;
  gap: 0.9rem;
  flex: 0 1 0;
  min-width: 0;
  overflow: hidden;
  opacity: 0;
  pointer-events: none;
  transition: flex-basis 0.45s var(--ease), opacity 0.3s linear;
}

.tree-filter.open .tree-filter-body {
  flex-basis: 100%;
  opacity: 1;
  pointer-events: auto;
}

.tree-filter input {
  flex: 0 1 24rem;
  min-width: 0;
  padding: 0.45rem 0.7rem;
  font-family: var(--font-mono);
  font-size: 0.88rem;
  color: var(--ink);
  background: rgba(0, 0, 0, 0.3);
  border: 1px solid var(--hairline);
  transition: border-color 0.3s;
  appearance: none;
  width: 100%;
}

.tree-filter input:focus { outline: none; border-color: var(--accent); }
/* The platform's own clear button, which is a blue cross here. Escape
   and the icon both clear the field. */
.tree-filter input::-webkit-search-cancel-button { appearance: none; }

/* The terms that come up most often across the link descriptions, so
   the field has something to offer before anything is typed. They are
   worked out from the descriptions themselves in research-tree.js, and
   run off the end of the line rather than wrapping it. */
.tree-filter-pills {
  flex: 1 1 auto;
  display: flex;
  gap: 0.4rem;
  min-width: 0;
  overflow: hidden;
}

.tree-filter-pills button {
  flex: 0 0 auto;
  padding: 0.2rem 0.6rem;
  font-family: var(--font-mono);
  font-size: 0.68rem;
  letter-spacing: 0.1em;
  color: var(--ink-faint);
  background: none;
  border: 1px solid var(--hairline);
  border-radius: 999px;
  cursor: pointer;
  transition: color 0.25s, border-color 0.25s;
}

.tree-filter-pills button:hover,
.tree-filter-pills button:focus-visible { color: var(--ink); border-color: var(--ink-faint); }

.tree-filter-pills button.on { color: var(--accent); border-color: var(--accent); }

.tree-filter-count {
  flex: 0 0 auto;
  margin: 0;
  font-family: var(--font-mono);
  font-size: 0.7rem;
  letter-spacing: 0.14em;
  color: var(--ink-faint);
}

/* Filtered: the columns that kept a link share the row evenly and stay
   open, and whatever missed the query is gone rather than dimmed. */
.tree.filtering .tree-col,
.tree.filtering .tree-col:last-child { flex: 1 1 0; }

.tree-col.hide,
.tree-sub.hide,
.tree-links li.hide { display: none; }

/* Exactly the margin either side of the tree, so hovering out there
   opens the column nearest to it. */
.tree-edge {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  width: max(0px, (100% - 68rem) / 2);
}

.tree-edge-right { left: auto; right: 0; }

/* Whichever link is under the pointer names itself across the foot of
   the screen. The columns above are masked away as they approach it, so
   a long column dissolves into the panel rather than colliding with it;
   the mask itself is measured in link-preview.js. */
.tree-preview {
  position: absolute;
  left: var(--gutter);
  right: var(--gutter);
  /* Clear of the footer, which comes into view at the foot of the
     scroll while the panel is still up. */
  bottom: 5rem;
  /* The same width as the row of columns, so the panel starts on the
     left edge of the first column rather than out at the gutter. */
  width: min(100% - 2 * var(--gutter), 68rem);
  margin-inline: auto;
  display: flex;
  align-items: flex-start;
  gap: 1.25rem;
  min-height: 6rem;
  padding-top: 1rem;
  border-top: 1px solid var(--hairline);
  /* Opaque, since the columns now run down behind it. */
  background: var(--bg);
  opacity: 0;
  transform: translateY(0.6rem);
  pointer-events: none;
  transition: opacity 0.3s linear, transform 0.4s var(--ease);
}

.tree-preview.live { opacity: 1; transform: none; }

/* Only present for links that carry a data-shot; nothing is fetched for
   the rest. */
/* Kept to the right hand end of the panel and out of the text's way:
   the site and its description are in the same place whether a picture
   turns up or not, and one that does slides in from the right. A fixed
   box, whatever the picture's own proportions are. */
.tree-preview-shot {
  flex: 0 0 auto;
  order: 2;
  margin-left: auto;
  width: 10rem;
  height: 6rem;
  object-fit: cover;
  object-position: center top;
  border: 1px solid var(--hairline);
  background: rgba(242, 239, 230, 0.06);
  opacity: 0;
  transform: translateX(2.5rem);
  transition: opacity 0.35s linear, transform 0.5s var(--ease);
}

.tree-preview-shot.on { opacity: 1; transform: none; }

.tree-preview-text { min-width: 0; }

.tree-preview-site {
  margin: 0 0 0.3rem;
  font-family: var(--font-mono);
  font-size: 0.8rem;
  letter-spacing: 0.14em;
  color: var(--accent);
}

.tree-preview-note {
  margin: 0;
  max-width: 46rem;
  font-size: 0.95rem;
  color: var(--ink-soft);
}

/* No hover to open a column with, so every column is open and the
   trees stack into one scrolling list. */
@media (max-width: 760px), (hover: none) and (pointer: coarse) {
  .tree {
    flex-direction: column;
    align-items: stretch;
    gap: 1.4rem;
    width: min(100%, 26rem);
    overflow-y: auto;
    overscroll-behavior: contain;
  }
  .tree-col, .tree-col:last-child { flex: 0 0 auto; }
  .tree-cat { margin-bottom: 0.4rem; }
  .tree-glyph { opacity: 1; }
  .tree-url { opacity: 1; }

  .tree-edge { display: none; }
  .tree-sub-name { font-size: 0.66rem; letter-spacing: 0.22em; }
  .tree-sub-name::before { content: none; }
  /* No preview panel down there to leave room for. */
  .fade-stage-top { padding-bottom: 3rem; }
  .tree-cat { position: static; }

  /* Nothing to hover with, so each link carries its own description
     rather than sending it to a panel at the foot of the screen. */
  .tree-preview { display: none; }
  .tree-links a { flex-wrap: wrap; padding-bottom: 0.5rem; }
  .tree-links a::after {
    content: attr(data-note);
    flex: 0 0 100%;
    padding-left: calc(1.8rem + 0.75rem);
    font-family: var(--font-body);
    font-size: 0.8rem;
    line-height: 1.5;
    white-space: normal;
    color: var(--ink-faint);
  }
}

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

.foot {
  display: flex;
  /* Held to the right edge, out of the way of the artwork that runs
     down the left of these pages. */
  justify-content: flex-end;
  gap: 0.6rem;
  margin-top: clamp(2rem, 6vh, 4rem);
  padding: 1.25rem var(--gutter) 1.5rem;
  border-top: 1px solid var(--hairline);
  font-size: 0.7rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--ink-faint);
}

/* --- small screens ----------------------------------------------- */

@media (max-width: 760px) {
  .proj-frame { flex-direction: column; height: auto; min-height: 0; margin: var(--card-gap) 0; }
  .proj-slot { display: none; }
  .proj-detail { display: none; }
  .proj-list { width: 100%; min-width: 0; }
  .proj-list { height: auto; }

  /* Sized by content here, not by dividing a fixed column, or an opened
     panel would have nowhere to grow into. */
  /* Block, not flex: a flex row would shrink the opened panel back to
     nothing. As a block the panel simply adds its height to the row. */
  .proj-item {
    display: block;
    flex: none;
    padding: 1rem 1.25rem;
    cursor: pointer;
  }
  .proj-item .card-index { margin-bottom: 0.3rem; }

  /* Each project opens in place. The open height is set in script from
     the content's own measurement, so the slide is exact. */
  .proj-panel {
    display: block;
    overflow: hidden;
    max-height: 0;
    transition: max-height 0.35s var(--ease);
  }
  .proj-panel .card-prose { margin: 0.9rem 0 0; font-size: 0.9rem; }
  .proj-panel .card-links { margin: 0.7rem 0 0; font-size: 0.72rem; }
  .proj-panel .card-note { margin: 0.7rem 0 0; }

  /* A plus that turns into a minus, so the row reads as openable. */
  /* The desktop boxes clip a long name to keep six rows the same
     height. Nothing is dividing a fixed column here, so let it wrap. */
  .proj-item-name { padding-right: 1.6rem; white-space: normal; overflow: visible; }
  .proj-item-name::after {
    content: "+";
    position: absolute;
    right: 0;
    top: 0;
    color: var(--ink-faint);
    transition: color 0.3s;
  }
  .proj-item.open .proj-item-name::after { content: "\2212"; color: var(--code-green); }

  .bar { flex-direction: column; align-items: stretch; }
  .mark { padding-bottom: 0.7rem; }
  .nav { border-top: 1px solid var(--hairline); overflow-x: auto; }
  .nav a { flex: 1; justify-content: center; padding: 0.8rem 0.5rem; font-size: 0.62rem; letter-spacing: 0.12em; }
}

/* Touch and small screens: backdrop-filter blur on a dozen glass
   cards re-samples the scrolling background every frame and makes
   mobile scrolling choppy. Swap the blur for a solid translucent
   panel, which reads almost identically over the dark backdrop but
   costs nothing to scroll. Also promote the fixed background to its
   own compositor layer and lighten its filter. */
@media (max-width: 760px), (hover: none) and (pointer: coarse) {
  .card-glass {
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
    background: rgba(9, 14, 11, 0.66);
  }
  .backdrop::before {
    filter: none;
    transform: translateZ(0);
  }
  .backdrop::after {
    background: linear-gradient(to top, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0.3) 45%, rgba(0, 0, 0, 0.45));
  }
  .fp-map { filter: brightness(0.85) contrast(1.05); }
  .backdrop::before { inset: 0; }
  .backdrop-swap-eye::before { inset: 0 0 0 auto; }
}

/* Narrow windows only, not merely touch: a touch laptop in landscape
   frames the image the same way a desktop does. */
@media (max-width: 760px) {
  /* A square image cropped to a phone's tall window leaves the eye off
     the side of the screen, so on a narrow window it is framed rather
     than cropped: scaled up a quarter again and offset until the eye
     sits across the upper half at close to the full width, with enough
     of the face around it to read as a face. The feather
     closes sooner too, since 40% of a phone's width would reach into
     the eye itself. */
  .backdrop-swap-eye::before {
    inset: 0 0 0 auto;
    background-size: auto 125%;
    background-position: 38% -9%;
    -webkit-mask-image: linear-gradient(to right, transparent 0%, rgba(0, 0, 0, 0.12) 12%, #000 32%);
    mask-image: linear-gradient(to right, transparent 0%, rgba(0, 0, 0, 0.12) 12%, #000 32%);
  }
}

@media (prefers-reduced-motion: reduce) {
  * { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}

/* --- privacy policy ---------------------------------------------- */

/* A single centred column of glass cards. The prose runs a little
   wider than .card-prose allows elsewhere, since this page is read
   rather than scanned. */
.policy-stack {
  display: flex;
  flex-direction: column;
  gap: var(--card-gap);
  width: min(100%, 64rem);
  margin: 0 auto;
  padding: 0 var(--gutter);
}
.policy-stack .card-prose { max-width: 68ch; }
.policy-h {
  margin: 0 0 1rem;
  font-family: var(--font-display);
  font-weight: 400;
  font-size: clamp(1.25rem, 2.2vw, 1.75rem);
  line-height: 1.2;
  color: var(--ink);
}
/* The whole policy lives in one card, so a heading opens a section
   inside it rather than a new card. Only the first sits flush. */
.policy-stack .card-prose + .policy-h,
.policy-stack .policy-list + .policy-h,
.policy-stack .policy-h + .policy-h { margin-top: 2.4rem; }
.policy-list {
  margin: 0.9rem 0 0;
  padding-left: 1.1rem;
  max-width: 68ch;
  color: var(--ink-soft);
}
.policy-list li { margin: 0.5rem 0; }
.policy-list b,
.policy-stack .card-prose b { color: var(--accent); font-weight: 400; }
.policy-stack a {
  border-bottom: 1px solid var(--hairline);
  transition: color 0.3s, border-color 0.3s;
}
.policy-stack a:hover { color: var(--accent); border-color: var(--accent); }
.policy-updated {
  width: min(100%, 64rem);
  margin: 0 auto;
  padding: 1.5rem var(--gutter) 0;
  font-family: var(--font-mono);
  font-size: 0.75rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--ink-soft);
  /* This line sits outside the glass, straight over the backdrop, and
     the grove image is bright enough under it to swallow faint ink. */
  text-shadow: 0 1px 12px rgba(6, 10, 8, 0.9);
}

.foot a {
  border-bottom: 1px solid transparent;
  transition: color 0.3s, border-color 0.3s;
}
.foot a:hover { color: var(--accent); border-color: var(--accent); }

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

/* A gold ring that slides in from the left edge as the projects page
   moves, and then holds there behind the cards. The code rain behind
   it is fading out over the same distance, so by the time the ring is
   fully in it is alone on black. The artwork is gold on black and is
   screened rather than laid on top, which keeps its square edge from
   showing against the backdrop while that is still on its way out.

   The script sets two numbers. --ring-p is the fade, run over the same
   distance as the backdrop swaps elsewhere on the site. --ring-t is
   the travel, run over the whole page: the ring is still creeping
   right until the footer is completely in view. */
.ring-mark {
  position: fixed;
  left: 0;
  top: 50%;
  width: min(80vh, 920px);
  aspect-ratio: 1;
  z-index: -1;
  /* Larger than its own box, so the empty margin around the artwork is
     cropped away and the rings themselves fill it. Held to the top of
     the box, so what the zoom cuts is the bottom of the circle, which
     is running off the screen anyway, and never the top of it. */
  background: url("/assets/rings.webp") center top / 132% no-repeat;
  mix-blend-mode: screen;
  opacity: var(--ring-p, 0);
  /* Comes to rest hanging off the left edge and sitting low enough for
     the screen to take a bite out of the bottom left of the circle:
     -38% across and -34% down are where it settles, at the foot of the
     page. It starts 47% of its own width further out. */
  transform: translate3d(calc(var(--ring-t, 0) * 47% - 85%), -34%, 0);
  pointer-events: none;
}

@media (max-width: 760px) {
  .ring-mark { width: min(110vw, 55vh); }
}
