/* Personal Sanctuary — The Index.
   Near-white paper, one deep green, one type family, hairline rules.
   The numbers are the only ornament. */

:root {
  --paper: #fafaf8;
  /* Same colour as --paper, as channels, so a gradient can ramp its alpha
     without the `transparent` keyword dragging it through transparent black. */
  --paper-rgb: 250 250 248;
  --ink: #16161a;
  --muted: #6b6b75;
  --rule: #e4e3dd;
  --accent: #2e5e4e;
  --accent-rgb: 46 94 78;
  /* The glow only. Same hue as the accent at higher chroma: a deep green
     cannot read as emission on paper, it reads as a shadow. oklch(62% .12 169.6)
     in light, and the accent itself in dark, where it already emits. */
  --glow-rgb: 18 157 122;
  --accent-wash: #eef3f0;

  /* Type ramp: two fluid display roles (the headline and the stat value) plus
     four fixed steps. Eight near-identical sizes below 1rem is how a ramp rots;
     a fifth step here is drift, not a decision. */
  --t-micro: 0.72rem;
  --t-small: 0.82rem;
  --t-body: 0.95rem;
  --t-lead: 1.1rem;

  /* The banner's height, shared by the fixed banner and the sheet that has to
     start below it. 16:5, capped so it never eats a short viewport. */
  --banner-h: min(46vh, calc(100vw * 5 / 16));

  --measure: 42rem;
  --gutter: clamp(1.25rem, 5vw, 2.5rem);

  color-scheme: light dark;
}

/* The page is read on a phone at any hour, so it follows the reader's setting
   rather than picking a side. */
@media (prefers-color-scheme: dark) {
  :root {
    --paper: #121214;
    --paper-rgb: 18 18 20;
    --ink: #ecebe9;
    --muted: #9a9aa4;
    --rule: #2a2a2e;
    --accent: #7fc4a8;
    --accent-rgb: 127 196 168;
    --glow-rgb: 127 196 168;
    --accent-wash: #1a2320;
  }
}

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

html {
  -webkit-text-size-adjust: 100%;
  scrollbar-color: var(--muted) var(--paper);
}

body {
  margin: 0;
  background: var(--paper);
  color: var(--ink);
  font-family: 'Libre Franklin', ui-sans-serif, system-ui, sans-serif;
  font-size: 100%;
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
}

::selection {
  background: var(--accent);
  color: var(--paper);
}

a {
  color: var(--accent);
  text-underline-offset: 0.2em;
  text-decoration-thickness: 1px;
}

:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 4px;
  border-radius: 2px;
}

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.skip-link {
  position: absolute;
  left: var(--gutter);
  top: -4rem;
  z-index: 2;
  padding: 0.5rem 0.9rem;
  background: var(--accent);
  color: var(--paper);
  font-size: var(--t-small);
  text-decoration: none;
  transition: top 140ms ease-out;
}

.skip-link:focus-visible {
  top: 1rem;
}

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

/* A 16:5 banner that dissolves into the page rather than ending on an edge.
   The mask fades the image itself to transparent, so the blend is correct in
   both themes without a per-theme scrim colour on top of it. */
.backdrop {
  /* Pinned to the viewport: the sheet below scrolls up over it rather than the
     banner scrolling away. */
  position: fixed;
  top: 0;
  left: 0;
  z-index: 0;
  width: 100%;
  height: var(--banner-h);
  overflow: hidden;
  background: var(--paper);
}

.backdrop__media {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* A looping banner is decoration, so it holds still for anyone who asked for
   that. The poster frame carries the same image, and the banner scrolls with
   the page instead of being pinned: a fixed layer under moving content is the
   parallax effect itself, which is motion whether or not anything animates. */
@media (prefers-reduced-motion: reduce) {
  .backdrop__media {
    display: none;
  }

  .backdrop {
    position: absolute;
    background: var(--paper) var(--backdrop-poster, url('media/art-backdrop-poster.webp')) center / cover no-repeat;
  }
}

/* The sheet is what covers the banner. It carries the page ground full-bleed,
   so text riding over the banner is always on opaque paper. */
.sheet {
  position: relative;
  z-index: 1;
  margin-top: var(--banner-h);
  background: var(--paper);
}

/* The dissolve rides on the sheet's leading edge, not on the banner. A mask on
   a fixed banner cannot move, so it would cut a hard line across the artwork
   the moment the page scrolled. This travels with the content instead, so the
   banner is always met by a fade wherever the sheet happens to be. */
.sheet::before {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: 100%;
  height: clamp(4rem, 13vh, 8.5rem);
  background: linear-gradient(
    to bottom,
    rgb(var(--paper-rgb) / 0.0000) 0.00%,
    rgb(var(--paper-rgb) / 0.0112) 6.25%,
    rgb(var(--paper-rgb) / 0.0430) 12.50%,
    rgb(var(--paper-rgb) / 0.0923) 18.75%,
    rgb(var(--paper-rgb) / 0.1562) 25.00%,
    rgb(var(--paper-rgb) / 0.2319) 31.25%,
    rgb(var(--paper-rgb) / 0.3164) 37.50%,
    rgb(var(--paper-rgb) / 0.4067) 43.75%,
    rgb(var(--paper-rgb) / 0.5000) 50.00%,
    rgb(var(--paper-rgb) / 0.5933) 56.25%,
    rgb(var(--paper-rgb) / 0.6836) 62.50%,
    rgb(var(--paper-rgb) / 0.7681) 68.75%,
    rgb(var(--paper-rgb) / 0.8438) 75.00%,
    rgb(var(--paper-rgb) / 0.9077) 81.25%,
    rgb(var(--paper-rgb) / 0.9570) 87.50%,
    rgb(var(--paper-rgb) / 0.9888) 93.75%,
    rgb(var(--paper-rgb) / 1.0000) 100.00%
  );

  pointer-events: none;
}

.page {
  max-width: calc(var(--measure) + var(--gutter) * 2);
  margin: 0 auto;
  /* The sheet supplies the offset; this is only the breathing room above the
     first line. */
  padding: clamp(1.75rem, 4vh, 3rem) var(--gutter) 4rem;
  position: relative;
}

/* ---- masthead --------------------------------------------------------- */

/* Portrait beside the name, not above it. */
.masthead {
  display: flex;
  align-items: center;
  gap: clamp(0.9rem, 2.6vw, 1.6rem);
}

.masthead__text {
  min-width: 0;
}

/* 1:1 portrait, circle-framed. The hairline ring is the same weight as the
   rules between entries, so it belongs to the same system. */
.masthead__photo {
  display: block;
  flex: none;
  width: clamp(3.75rem, 9vw, 5.5rem);
  height: auto;
  aspect-ratio: 1 / 1;
  margin: 0;
  border-radius: 50%;
  object-fit: cover;
  background: color-mix(in srgb, var(--rule) 45%, var(--paper));
  box-shadow: 0 0 0 1px var(--rule);
}

.masthead__title {
  /* Light and large: the only display gesture on the page. There is no eyebrow
     above it; the byline lives in the colophon. */
  margin: 0;
  max-width: 18ch;
  font-size: clamp(2rem, 6.2vw, 3.1rem);
  font-weight: 300;
  line-height: 1.12;
  letter-spacing: -0.021em;
  text-wrap: balance;
}

.masthead__note {
  margin: 0.55rem 0 0;
  max-width: 54ch;
  font-size: var(--t-body);
  color: var(--muted);
}

/* ---- the list --------------------------------------------------------- */

.index {
  margin: clamp(2.75rem, 8vh, 4.5rem) 0 0;
  padding: 0;
  list-style: none;
}

.entry {
  border-top: 1px solid var(--rule);
}

.entry:last-child {
  border-bottom: 1px solid var(--rule);
}

.entry__link {
  display: grid;
  grid-template-columns: minmax(0, 7fr) minmax(0, 12fr);
  gap: 1.4rem 2rem;
  align-items: start;
  padding: 1.7rem 0.75rem 1.8rem;
  margin-inline: -0.75rem;
  color: inherit;
  text-decoration: none;
  transition: background-color 180ms ease-out;
}

.entry__link:hover,
.entry__link:focus-visible {
  background: var(--accent-wash);
}

.entry__hobby {
  margin: 0;
  font-size: clamp(1.35rem, 3.4vw, 1.7rem);
  font-weight: 400;
  line-height: 1.15;
  letter-spacing: -0.015em;
}

/* The one authored moment: a rule draws itself under the name on the way in. */
.entry__hobby::after {
  content: '';
  display: block;
  height: 1px;
  margin-top: 0.2rem;
  background: var(--accent);
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 250ms cubic-bezier(0.16, 1, 0.3, 1);
}

.entry__link:hover .entry__hobby::after,
.entry__link:focus-visible .entry__hobby::after {
  transform: scaleX(1);
}

@media (prefers-reduced-motion: reduce) {
  .entry__hobby::after {
    transition: none;
  }
}

.entry__meta {
  margin: 0.45rem 0 0;
  font-size: var(--t-small);
  color: var(--muted);
}

/* Three different usernames across five sites is a fact about the person, so
   each line carries its own rather than unifying them. */
.entry__user::before {
  content: ' / ';
}

.entry__user {
  font-style: italic;
}

.entry__when {
  margin: 0.15rem 0 0;
  font-size: var(--t-small);
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

.entry__stats {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 1.2rem 1.1rem;
  margin: 0;
}

.stat {
  min-width: 0;
}

.stat__value {
  margin: 0;
  font-size: clamp(1.3rem, 3.1vw, 1.65rem);
  font-weight: 300;
  line-height: 1.1;
  letter-spacing: -0.025em;
  font-variant-numeric: tabular-nums lining-nums;
}

.stat__label {
  margin-top: 0.2rem;
  font-size: var(--t-micro);
  font-weight: 500;
  letter-spacing: 0.045em;
  text-transform: uppercase;
  color: var(--muted);
}

/* Exactly three columns whether an entry fills them or not, so values line up
   down the whole list. */
.entry__none {
  margin: 0;
  font-size: var(--t-body);
  font-style: italic;
  color: var(--muted);
}

/* ---- posters ----------------------------------------------------------- */

/* The strip spans the full row so the covers get room. It keeps five columns
   whatever it holds, so a poster is the same size in every entry; a column with
   no cover is simply empty. An entry with no covers renders no strip. */
.posters {
  grid-column: 1 / -1;
  display: grid;
  grid-template-columns: repeat(5, minmax(0, 1fr));
  gap: 0.6rem;
  margin: 0;
  padding: 0;
  list-style: none;
}

.poster {
  position: relative;
  aspect-ratio: 2 / 3;
  /* Holds the slot's space while the cover loads, without reading as the
     placeholder plate the page deliberately does not draw. */
  background: color-mix(in srgb, var(--rule) 45%, var(--paper));
  border-radius: 2px;
}

.poster__img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* The slot used to clip this; it cannot any more, so the image rounds itself. */
  border-radius: inherit;
}

/* Sits half on the artwork and half above it, centred on the slot's top edge. */
.poster__badge {
  position: absolute;
  top: 0;
  left: 50%;
  z-index: 2;
  width: clamp(1.05rem, 1.5vw, 1.35rem);
  transform: translate(-50%, -50%);
  line-height: 0;
  pointer-events: none;
}

.poster__badge svg {
  display: block;
  width: 100%;
  height: auto;
}

.poster__badge-face {
  fill: rgb(var(--glow-rgb));
  stroke: var(--paper);
  stroke-width: 1.6;
}

.poster__badge-mark {
  fill: var(--paper);
}

.poster__badge-wave {
  fill: none;
  stroke: var(--paper);
  stroke-width: 1.7;
  stroke-linecap: round;
}

/* Only a strip that claims the present gets the ring. Without it the strip is
   favourites, and favourites assert nothing about right now. The ring sits
   outside the slot rather than inset, because the covers are full-bleed artwork
   and an inset stroke would crop them. The breathing glow on top of it lives in
   the motion section. */
.posters--current .poster:first-child {
  box-shadow:
    0 0 0 2px rgb(var(--glow-rgb)),
    0 0 12px 2px rgb(var(--glow-rgb) / 0.62),
    0 0 26px 7px rgb(var(--glow-rgb) / 0.3);
}

/* ---- motion ------------------------------------------------------------ */

/* MOTION 3. Everything here sits behind `no-preference`, so a reader who asked
   for stillness gets the finished page with nothing hidden and nothing moving.
   Only `transform`, `scale` and `opacity` animate. Entrances write only a
   `from` keyframe with a `backwards` fill, so each one hands back to the
   element's own styles when it ends and the hover transitions stay free. */
@media (prefers-reduced-motion: no-preference) {
  @keyframes arrive {
    from {
      opacity: 0;
      transform: translateY(18px);
    }
  }

  @keyframes arrive-cover {
    from {
      opacity: 0;
      transform: translateY(28px) scale(0.96);
    }
  }

  @keyframes settle {
    from {
      opacity: 0;
      scale: 1.08;
    }
  }

  /* The banner settles in, then recedes toward the paper as the sheet climbs
     over it. Scroll-driven where the browser has it; elsewhere it holds still. */
  .backdrop__media {
    animation: settle 250ms cubic-bezier(0.16, 1, 0.3, 1) backwards;
  }

  @supports (animation-timeline: scroll()) {
    @keyframes recede {
      to {
        opacity: 0.45;
        scale: 1.12;
      }
    }

    .backdrop {
      animation: recede linear both;
      animation-timeline: scroll(root);
      animation-range: 0 var(--banner-h);
    }
  }

  @keyframes word-rise {
    from {
      transform: translateY(110%);
    }
  }

  @keyframes draw {
    from {
      transform: scaleX(0);
    }
  }

  @keyframes pop {
    from {
      scale: 0;
    }
  }

  .masthead__photo {
    animation: arrive-cover 250ms cubic-bezier(0.16, 1, 0.3, 1) 60ms backwards;
  }

  /* The title rises word by word out of its own line box. index.html splits
     it into .word > span and sets --w; the clip margin keeps descenders. */
  .word {
    display: inline-block;
    overflow: clip;
    overflow-clip-margin: 0.14em;
    vertical-align: top;
  }

  .word > span {
    display: inline-block;
    animation: word-rise 250ms cubic-bezier(0.16, 1, 0.3, 1) backwards;
    animation-delay: calc(80ms + var(--w, 0) * 40ms);
  }

  .masthead__note {
    animation: arrive 220ms cubic-bezier(0.16, 1, 0.3, 1) 160ms backwards;
  }

  /* Entries wait off-stage until they scroll into view. index.html adds
     `.reveal` and then `.is-in`, setting --d so a batch arriving together
     staggers 40ms apart. Without the observer nothing gets `.reveal`, so
     nothing is ever left hidden. */
  .reveal:not(.is-in) {
    opacity: 0;
  }

  .colophon.is-in {
    animation: arrive 220ms cubic-bezier(0.16, 1, 0.3, 1) backwards;
  }

  /* The hairline above each entry draws itself in from the left as the row
     arrives, instead of being there already. */
  .entry.reveal {
    position: relative;
    border-top-color: transparent;
  }

  .entry.reveal::before {
    content: '';
    position: absolute;
    top: -1px;
    left: 0;
    right: 0;
    height: 1px;
    background: var(--rule);
    transform-origin: left;
  }

  .entry.is-in::before {
    animation: draw 250ms cubic-bezier(0.16, 1, 0.3, 1) backwards;
    animation-delay: calc(80ms + var(--d, 0ms));
  }

  .entry.is-in .poster__badge {
    animation: pop 180ms cubic-bezier(0.16, 1, 0.3, 1) backwards;
    animation-delay: calc(300ms + var(--d, 0ms));
  }

  .entry.is-in {
    animation: arrive 220ms cubic-bezier(0.16, 1, 0.3, 1) backwards;
    animation-delay: calc(100ms + var(--d, 0ms));
  }

  .entry.is-in .stat {
    animation: arrive 200ms cubic-bezier(0.16, 1, 0.3, 1) backwards;
    animation-delay: calc(140ms + var(--d, 0ms) + var(--n) * 40ms);
  }

  .entry.is-in .poster {
    animation: arrive-cover 250ms cubic-bezier(0.16, 1, 0.3, 1) backwards;
    animation-delay: calc(180ms + var(--d, 0ms) + var(--n) * 40ms);
  }

  .stat:nth-child(1), .poster:nth-child(1) { --n: 0; }
  .stat:nth-child(2), .poster:nth-child(2) { --n: 1; }
  .stat:nth-child(3), .poster:nth-child(3) { --n: 2; }
  .poster:nth-child(4) { --n: 3; }
  .poster:nth-child(5) { --n: 4; }

  /* Covers rise a little with the row, and the one under the pointer rises
     further. The ripple across the strip comes from the same --n. */
  .poster {
    transition: transform 200ms cubic-bezier(0.16, 1, 0.3, 1);
    transition-delay: calc(var(--n) * 15ms);
  }

  .entry__link:hover .poster,
  .entry__link:focus-visible .poster {
    transform: translateY(-4px);
  }

  /* The cover under the pointer lifts, and index.html tilts it toward the
     pointer on the separate `rotate` property, eased per frame in JS. The tilt
     must not ride the `transform` transition: restarting a transition on every
     pointermove never reaches the compositor while the drift runs there, and
     the cover freezes until the pointer stops. The strip's perspective gives
     the tilt its depth; a sheen follows --mx/--my. */
  .posters {
    perspective: 900px;
  }

  .entry__link .poster:hover {
    transform: translateY(-10px);
    transition-duration: 120ms;
    transition-delay: 0ms;
  }

  .poster::before {
    content: '';
    position: absolute;
    inset: 0;
    z-index: 1;
    border-radius: inherit;
    background: radial-gradient(circle at var(--mx, 50%) var(--my, 0%), rgb(255 255 255 / 0.32), rgb(255 255 255 / 0) 60%);
    opacity: 0;
    transition: opacity 150ms ease-out;
    pointer-events: none;
  }

  .entry__link .poster:hover::before {
    opacity: 1;
  }

  /* A press reads as a press. */
  .entry__link {
    transition: background-color 180ms ease-out, transform 160ms ease-out;
  }

  .entry__link:active {
    transform: scale(0.992);
  }

  /* Covers drift against each other as the strip passes through the viewport,
     on `translate` so it never fights the transform the hover and the entrance
     use. */
  @supports (animation-timeline: view()) {
    @keyframes drift {
      from {
        translate: 0 calc((var(--n) - 2) * 6px);
      }
      to {
        translate: 0 calc((var(--n) - 2) * -6px);
      }
    }

    .entry.is-in .poster {
      animation:
        arrive-cover 250ms cubic-bezier(0.16, 1, 0.3, 1) backwards,
        drift linear both;
      animation-timeline: auto, view();
      animation-delay: calc(180ms + var(--d, 0ms) + var(--n) * 40ms), 0s;
    }
  }

  /* Covers fade in as they land, so a slow image arrives rather than blinks. */
  .poster__img {
    opacity: 0;
    transition: opacity 200ms ease-out;
  }

  .poster__img.is-loaded {
    opacity: 1;
  }

  /* The ring breathes. Its glow is painted on a pseudo-element and only its
     opacity moves, so the pulse never repaints a shadow per frame. */
  @keyframes breathe {
    from { opacity: 0.15; }
    to { opacity: 0.9; }
  }

  .posters--current .poster:first-child::after {
    content: '';
    position: absolute;
    inset: 0;
    border-radius: inherit;
    box-shadow: 0 0 22px 8px rgb(var(--glow-rgb) / 0.55);
    pointer-events: none;
    animation: breathe 2.6s ease-in-out infinite alternate;
  }

  @keyframes broadcast {
    0%, 100% { opacity: 1; }
    50% { opacity: 0.25; }
  }

  .poster__badge-wave {
    animation: broadcast 1.8s ease-in-out infinite;
  }
}

/* ---- states ----------------------------------------------------------- */

.state {
  padding: 2.5rem 0;
  border-top: 1px solid var(--rule);
}

.state__line {
  margin: 0;
  font-size: var(--t-lead);
  font-weight: 400;
}

.state__why {
  margin: 0.4rem 0 0;
  font-size: var(--t-small);
  color: var(--muted);
}

.state__retry {
  margin-top: 1.1rem;
  padding: 0.5rem 1.1rem;
  border: 1px solid var(--accent);
  border-radius: 2px;
  background: transparent;
  color: var(--accent);
  font: inherit;
  font-size: var(--t-small);
  cursor: pointer;
  caret-color: var(--accent);
  transition: background-color 160ms ease-out, color 160ms ease-out;
}

.state__retry:hover {
  background: var(--accent);
  color: var(--paper);
}

/* ---- colophon --------------------------------------------------------- */

.colophon {
  margin-top: clamp(2.5rem, 7vh, 4rem);
  max-width: 60ch;
  font-size: var(--t-small);
  color: var(--muted);
}

.colophon p {
  margin: 0 0 0.8rem;
}

.colophon__note {
  padding-left: 0.9rem;
  border-left: 1px solid var(--accent);
  color: var(--ink);
}

/* The artist's name, set as plainly as the rest of the colophon. */
.colophon__credit {
  color: var(--muted);
}

@media (max-width: 34rem) {
  /* The portrait stacks above the name on a phone. Side by side, the title had
     to share a 350px line with a 60px circle, which cost it more than the
     pairing was worth. */
  .masthead {
    flex-direction: column;
    align-items: flex-start;
    gap: 0.95rem;
  }

  .entry__link {
    grid-template-columns: minmax(0, 1fr);
    gap: 1.15rem;
    padding: 1.5rem 0.75rem 1.6rem;
  }

  /* Three across on a phone too. The stats are a row, not a stack — breaking
     them onto new lines loses the comparison the grid exists to make. */
  .entry__stats {
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: 1rem 0.75rem;
  }

  /* The ramp stays four steps: --t-micro takes a smaller value here rather
     than a fifth step being invented for the phone. Its only consumer is the
     stat label, which has to hold three across at 320px. */
  :root {
    --t-micro: 0.66rem;
  }

  /* The value's clamp already floors at 1.3rem on a phone, so only the fit
     needs adjusting, not the size. */
  .stat__value {
    letter-spacing: -0.03em;
  }

  .stat__label {
    letter-spacing: 0.03em;
  }
}
