/* ==========================================================================
   YONI — room.css
   The whole stylesheet for the whole site.

   Direction: warm dark, never blue dark. A room with people in it.
   Depth comes from light, space and type weight. No cards, no borders,
   no rounded rectangles, no drop shadows.
   Motion is slow and never elastic. Nothing strobes.

   Owned by: art direction. Class vocabulary is documented in DESIGN.md.
   ========================================================================== */

/* --------------------------------------------------------------------------
   1. TOKENS
   -------------------------------------------------------------------------- */

:root {
  /* Ground. Warm near-blacks. Every one of these is redder than it is bluer. */
  --ink-0: #0A0908;   /* the floor. html/body. */
  --ink-1: #14110F;   /* a raised plane, used for light not for boxes */
  --ink-2: #1E1917;   /* the highest plane we ever go */

  /* ------------------------------------------------------------------ THE
     NIGHT RAMP

     The site is one night. Each room sits at an hour along one continuous
     journey, and the light moves along that line as you walk between them.

       NIGHT                                                        DAWN
       ├──────────┬────────────┬──────────┬───────────┬─────────────┤
       #07080C    #0A0908      #140F0A    #1A1310     #1E1A18
       blue-black warm-black   ember      low amber   grey gold
       the door   arriving     THE ROOM   THE WALL    HIM

     The door is the only place on the site with any blue in it. It is outside,
     before you go in, and it is cold. That one cold moment is what makes the
     room feel warm when it arrives.

     Held per room on body[data-room] and body[data-state] in section 3, with a
     long transition, so moving rooms reads as the light changing rather than a
     theme being switched.

     THE CONTRAST RULE THAT GOVERNS THIS WHOLE MECHANISM, and it is not taste:
     the veil is a dark wash laid OVER the legibility scrims. Every colour it
     can ever take is darker than #1E1A18 (relative luminance 0.0108), and the
     dimmest text on the site, --text-3, needs its background to stay under
     0.0245 to hold 4.5:1. So the veil can only ever pull the brightest pixel
     behind text DOWN, never up, whatever room it is tuned to. The scrims
     themselves never go below the values that were measured and verified.
     A colour journey may not cost anybody the ability to read what his friends
     wrote about him. */

  --veil: rgba(19, 14, 10, 0.34);  /* the hour, as a wash. never lighter than #1E1A18 */
  --scrim-open: 1;                 /* the room shape: open in the middle */
  --scrim-close: 0;                /* the reading shape: closed over */
  --door-ink: 7, 8, 12;            /* the only blue on the site */

  /* Text. Bone white, warm. */
  --text:   #EDE7DE;  /* 16.2:1 on ink-0 */
  --text-2: #C6BCB0;  /* 10.6:1 on ink-0 — long form copy */
  --text-3: #9A9086;  /*  6.4:1 on ink-0 — eyebrows, captions, attributions */

  /* The single accent. Amber, the colour of a warm light in a dark room. */
  --amber:      #E0A860; /* 9.4:1 on ink-0 — safe as text */
  --amber-deep: #C98A4B; /* 6.8:1 on ink-0 — safe as text */
  --amber-lit:  #F0BE7C; /* only ever a highlight, never a fill behind text */
  --warn:       #E4906A; /* form errors. warm, not a hospital red. */

  /* Light, expressed as alpha on bone. Used instead of borders and shadows. */
  --line-1: rgba(237, 231, 222, 0.09);
  --line-2: rgba(237, 231, 222, 0.16);
  --line-3: rgba(237, 231, 222, 0.28);
  --glow:   rgba(224, 168, 96, 0.14);

  /* Scrim. Legibility without a box. */
  --scrim-0: rgba(10, 9, 8, 0);
  --scrim-1: rgba(10, 9, 8, 0.45);
  --scrim-2: rgba(10, 9, 8, 0.74);
  --scrim-3: rgba(10, 9, 8, 0.92);

  /* Type */
  --font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI Variable Text",
             "Segoe UI", system-ui, "Helvetica Neue", Helvetica, Arial, sans-serif;
  --font-voice: "Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua",
                Georgia, Cambria, ui-serif, serif;
  --font-he: "Arial Hebrew", "Narkisim", David, "Noto Sans Hebrew",
             "Segoe UI", system-ui, sans-serif;
  --font-num: ui-monospace, "SF Mono", SFMono-Regular, "Segoe UI Mono",
              Menlo, Consolas, monospace;

  --fs-eyebrow: 0.6875rem;
  --fs-micro:   0.75rem;
  --fs-small:   0.8125rem;
  --fs-body:    clamp(1rem, 0.965rem + 0.18vw, 1.0625rem);
  --fs-sub:     clamp(1.0625rem, 0.99rem + 0.36vw, 1.25rem);
  --fs-voice:   clamp(1.125rem, 1.02rem + 0.52vw, 1.375rem);
  --fs-h3:      clamp(1.1875rem, 1.06rem + 0.62vw, 1.5rem);
  --fs-h2:      clamp(1.75rem, 1.44rem + 1.5vw, 2.625rem);
  --fs-h1:      clamp(2.125rem, 1.62rem + 2.5vw, 3.75rem);

  /* Type as image. Four sizes that exist to carry a screen on their own.

     --fs-mark is set to fill the viewport width. YONI is four caps; across the
     system stacks this site actually gets (SF Pro, Segoe UI, Roboto, Helvetica)
     the four advances plus the tracking come to between 2.19em and 2.26em, so
     37vw lands the wordmark at 81 to 84 percent of the viewport on a phone.
     Held under 30rem on a desktop, where 37vw would be a 600px letterform and
     the thing stops reading as a name and starts reading as a poster. */
  --fs-mark:    clamp(4.5rem, 37vw, 30rem);
  --fs-opener:  clamp(2.75rem, 13vw, 8.5rem);   /* a section opener, full screen */
  --fs-words:   clamp(1.3125rem, 1.02rem + 1.45vw, 2.0625rem);  /* his own paragraphs */
  --fs-low:     clamp(1.625rem, 5.6vw, 3.75rem); /* the one line that is the low point */

  /* Space.
     --rail is the single vertical edge everything on the site lines up to:
     chrome, transport, surfacer and every view share it. One rail, one object.
     Narrower text is capped on the right with a measure, never re-centred,
     so the left edge never moves as you walk between rooms. */
  --rail: 82rem;
  --pad: clamp(1.25rem, 5vw, 4rem);
  --gap: clamp(1.5rem, 4vw, 2.5rem);
  --measure: 60ch;

  /* Fixed furniture.
     --chrome-h is only an estimate used for the room's first-screen height.
     Chrome is sticky, so it costs no layout maths anywhere else.
     --transport-h is the real measured bar height, excluding the safe area.
     Content is always padded clear of it. */
  /* Both of these are re-measured, at every breakpoint, in a real browser.
     --chrome-h was an estimate of 8rem and the header is really 173px on a
     360px phone, because five rooms do not fit on one line and the nav wraps.
     Being 45px short of the truth put the top of a full height section under
     the header, and put a keyboard user's focused control under it too, since
     scroll-padding-top is built from this number. Measured, per band:

       320 to 430   header 167 to 173    bar 134
       600          header 126           bar 134
       768          header 146           bar 144
       1024 up      header 105           bar 144

     Each value below is held a little above the real one, so different platform
     font metrics can never let the furniture cover content. */
  --chrome-h: 11.25rem;
  --transport-h: 8.75rem;
  --safe-b: env(safe-area-inset-bottom, 0px);
  --safe-t: env(safe-area-inset-top, 0px);
  --safe-l: env(safe-area-inset-left, 0px);
  --safe-r: env(safe-area-inset-right, 0px);

  /* The gutter is how the rail is actually drawn. Below the rail width it is
     just the page padding. Above it, it grows so the content sits centred and
     exactly --rail wide, while backgrounds still run full bleed. Applied to
     the chrome, the transport, the surfacer and every .shell, which is why
     they all share one left edge at every viewport width. */
  --gutter-l: max(calc(var(--pad) + var(--safe-l)), calc((100vw - var(--rail)) / 2));
  --gutter-r: max(calc(var(--pad) + var(--safe-r)), calc((100vw - var(--rail)) / 2));

  /* ------------------------------------------------------- ANIMATION GRAMMAR
     MASTER-BRIEF section 9, as properties, so every module speaks one
     vocabulary and the whole site feels like one hand made it.

     | purpose                          | duration      | easing         |
     | micro state, hover, focus        | 180ms         | ease-out       |
     | content reveal on intersection   | 900ms         | long ease-out  |
     | room to room transition          | 700ms         | soft in-out    |
     | the door opening                 | 1600ms        | very long      |
     | memory surfacing and leaving     | 1200 / 900ms  | soft           |
     | field and colour drift           | 4 to 12s      | linear         |

     Nothing here overshoots. There is no easing curve in this file whose output
     ever leaves the nought to one range, so nothing on this site can bounce or
     spring, which is a decision about how it feels to be in the room and not a
     decision about taste. */
  --e-micro:  cubic-bezier(0.22, 0.61, 0.36, 1);   /* ease-out */
  --e-reveal: cubic-bezier(0.16, 1, 0.30, 1);      /* long ease-out */
  --e-room:   cubic-bezier(0.45, 0.05, 0.25, 1);   /* soft in-out */
  --e-door:   cubic-bezier(0.12, 0.85, 0.16, 1);   /* very long ease-out */
  --e-soft:   cubic-bezier(0.25, 0.46, 0.45, 0.94);

  --t-micro:   180ms;
  --t-reveal:  900ms;
  --t-room:    700ms;
  --t-door:    1600ms;
  --t-mem-in:  1200ms;
  --t-mem-out: 900ms;
  --t-drift:      12s;   /* colour temperature, room to room */
  --t-drift-fast:  4s;   /* the slowest thing that still reads as moving */

  /* The names the rest of this file was written against. Kept, and re-pointed
     at the grammar above, so one vocabulary governs everything already built. */
  --ease:      var(--e-reveal);
  --ease-soft: var(--e-soft);
  --t-1: var(--t-micro);
  --t-2: var(--t-room);
  --t-3: 1100ms;   /* the door LEAVING. app.js removes the node at 1200ms, so
                      nothing on the way out may be slower than this. */

  /* Optional, and only ever written by the module that owns the audio analysis:
     a slow, rate limited 0..1 swell that the door backlight can breathe on.
     Nothing reads it directly. Everything reads --swell-safe, which is clamped
     here, so a bug upstream cannot put a flashing light on this page. */
  --swell: 0;
  --swell-safe: clamp(0, var(--swell, 0), 1);
}

/* The nav stops wrapping to three rows. */
@media (min-width: 36em) {
  :root { --chrome-h: 8.5rem; }
}

/* One row for the mark, the nav and the presence line, but a tight one. */
@media (min-width: 46em) {
  :root {
    --chrome-h: 9.5rem;
    --transport-h: 9.5rem;
  }
}

/* Room for all of it, on one line, with air. */
@media (min-width: 64em) {
  :root { --chrome-h: 7rem; }
}

/* --------------------------------------------------------------------------
   2. RESET AND BASE
   -------------------------------------------------------------------------- */

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

html {
  background: var(--ink-0);
  color-scheme: dark;
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;

  /* Two things are permanently pinned over the page: the chrome at the top and
     the transport at the bottom. Without this, tabbing through the wall scrolls
     each focused control to the very edge of the viewport, which puts it
     underneath one of them. A keyboard user then has focus on something they
     cannot see. Applies to every scroll the browser performs on its own,
     including focus, find in page, and anchor jumps. */
  scroll-padding-top: calc(var(--chrome-h) + 1rem);
  scroll-padding-bottom: calc(var(--transport-h) + 1rem);
}

body {
  margin: 0;
  min-height: 100dvh;
  background: transparent;
  color: var(--text);
  font-family: var(--font-ui);
  font-size: var(--fs-body);
  line-height: 1.6;
  font-weight: 400;
  letter-spacing: 0.002em;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  overflow-x: hidden;
  overscroll-behavior-y: none;
}

/* Content always clears the fixed transport. Nothing is ever trapped under it. */
body[data-state="open"] {
  padding-bottom: calc(var(--transport-h) + var(--safe-b) + 1.5rem);
}

/* hidden must always win. app.js toggles .hidden on flex/grid containers. */
[hidden] { display: none !important; }

h1, h2, h3, h4, p, figure, blockquote, ul, ol, dl, dd { margin: 0; padding: 0; }
ul, ol { list-style: none; }
img, svg, canvas, video { display: block; max-width: 100%; }
img { height: auto; }
button, input, textarea, select {
  font: inherit;
  color: inherit;
  letter-spacing: inherit;
  background: none;
  border: 0;
  margin: 0;
  border-radius: 0;
}
button { cursor: pointer; -webkit-appearance: none; appearance: none; }
a { color: inherit; text-decoration: none; }
hr { border: 0; margin: 0; }
::selection { background: rgba(224, 168, 96, 0.26); color: var(--text); }

/* Scrollbar, kept warm and quiet. */
* { scrollbar-width: thin; scrollbar-color: rgba(237,231,222,0.16) transparent; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(237,231,222,0.14); }
::-webkit-scrollbar-thumb:hover { background: rgba(237,231,222,0.22); }

/* Focus. Visible, warm, offset so it never crowds the letterforms. */
:focus { outline: none; }
:focus-visible {
  outline: 2px solid var(--amber);
  outline-offset: 4px;
}
.timeline:focus-visible { outline-offset: 8px; }

/* --------------------------------------------------------------------------
   3. LAYERS, AND THE NIGHT RAMP

   0  canvas          the visual, always behind everything
   1  html::before    the open scrim   the room shape, thin in the middle
   1  body::before    the closed scrim the reading shape, closed over
   1  body::after     the veil         the hour, as colour temperature
   2  views, footer
   4  surfacer
   6  chrome
   7  transport
   20 modal
   30 door

   Three fixed layers instead of one, for one reason: a gradient cannot be
   transitioned, so the old build had to swap its scrim outright when you
   changed room, and a swap reads as a theme change. These two cross fade on
   their opacity instead, which is a property that does transition, and which
   costs the compositor nothing. Walking from the room to the wall now looks
   like a light being brought down, because that is what it is.

   The two scrim gradients are the ones that were measured and verified against
   the brightest frame the shader can produce. Neither has been softened. The
   room still gets the open one at full strength and the reading rooms still get
   the closed one at full strength; all that changed is that they arrive by
   fading rather than by being replaced.
   -------------------------------------------------------------------------- */

#visual {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  z-index: 0;
  pointer-events: none;
  background: var(--ink-0);
}

/* The room shape. Open in the middle, because in the room the visual is the
   subject and type sits low, out of its way. */
html::before {
  content: "";
  position: fixed;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  opacity: var(--scrim-open);
  background-image: linear-gradient(
    to bottom,
    rgba(10, 9, 8, 0.90) 0%,
    rgba(10, 9, 8, 0.42) 16vh,
    /* The thinnest point of the scrim used to sit at 0.06, at the same height
       as the shader's light source, so anything scrolled through that band was
       reading against the brightest part of the visual. Raised to the level
       where the dimmest text still clears 4.5:1 there, which is still open
       enough to let the room breathe through. */
    rgba(10, 9, 8, 0.22) 36vh,
    rgba(10, 9, 8, 0.38) 60vh,
    rgba(10, 9, 8, 0.82) 84vh,
    rgba(10, 9, 8, 0.96) 100%
  );
  transition: opacity var(--t-3) var(--e-room);
}

/* The reading shape. Closed over, so somebody's paragraphs sit on near solid
   warm black and nothing moving underneath can compete with them. */
body::before {
  content: "";
  position: fixed;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  opacity: var(--scrim-close);
  background-image:
    linear-gradient(
      to bottom,
      rgba(10, 9, 8, 0.92) 0%,
      rgba(10, 9, 8, 0.70) 14vh,
      rgba(10, 9, 8, 0.66) 60vh,
      rgba(10, 9, 8, 0.90) 100%
    ),
    linear-gradient(rgba(10, 9, 8, 0.62), rgba(10, 9, 8, 0.62));
  transition: opacity var(--t-3) var(--e-room);
}

/* The hour. A wash of the room's own near black over everything, and the layer
   that makes the door cold and the wall warm.

   1400ms, a little longer than the scrims underneath it, so when you change
   room the shape of the light arrives first and its temperature is still
   settling behind it. That lag is the whole difference between walking into
   another room and a theme being switched.

   In the reading rooms this wash is doing most of the work, because the scrim
   under it has already taken the field down to almost nothing, so what you are
   standing on there is very nearly this colour and nothing else. In the room it
   is kept light, because in the room the field is the subject and a heavy wash
   over it would put the fire out. */
body::after {
  content: "";
  position: fixed;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  background-color: var(--veil);
  transition: background-color 1400ms linear;
}

/* -------------------------------------------------------- the rooms, in order

   Each block is one point on the ramp: the floor under everything, the wash,
   which scrim shape is up, and the temperature of the accent. Amber stays amber
   in every room, because that is him. Only its heat moves: hotter in the room,
   paler at dawn. No accent is ever allowed below the luminance of --amber-deep
   (0.311), which is the dimmest amber this site was verified with. */

/* THE DOOR, 10pm. Outside, before you go in, and cold. The only blue anywhere
   on the site. It is what makes the room feel warm when it arrives. */
body[data-state="closed"] {
  --veil: rgba(7, 8, 12, 0.62);
  --scrim-open: 0.55;
  --scrim-close: 0;
}

/* THE ROOM, 1am. The warmest and deepest thing on the site. */
body[data-room="room"] {
  --veil: rgba(22, 15, 9, 0.42);
  --scrim-open: 1;
  --scrim-close: 0;
  --amber: #DFA055;
  --amber-deep: #C9884A;
  --amber-lit: #F0BC76;
}

/* THE WALL, 4am. It cools a little and it thins out. The room has emptied and
   the people still here are talking. */
body[data-room="wall"] {
  --veil: rgba(26, 19, 16, 0.68);
  --scrim-open: 0;
  --scrim-close: 1;
}

/* HIM, 6am. The one room approaching light.

   It opens up with space, scale and the dawn plate, and NOT by thinning the
   scrim, which is the one lever that would cost somebody the ability to read
   him. The wash here is the palest on the site and the accent is the palest,
   which is what makes it feel like morning. */
body[data-room="him"] {
  --veil: rgba(31, 27, 24, 0.70);
  --scrim-open: 0;
  --scrim-close: 1;
  --amber: #E8BC86;
  --amber-deep: #D6A067;
  --amber-lit: #F4D2A6;
}

/* THE QUIET DOOR, late. Small, plain, and nearly out of the night. */
body[data-room="give"] {
  --veil: rgba(23, 17, 13, 0.62);
  --scrim-open: 0;
  --scrim-close: 1;
}

/* WHAT HE LOVED. Outside the night entirely. This is the drive home with the
   aux cable, so it is flatter, plainer and more daylit than any of the rooms:
   the wash comes up until the field behind it almost stops moving, and what is
   left is a list of records. The contrast is the point. */
body[data-room="loved"] {
  --veil: rgba(28, 26, 25, 0.74);
  --scrim-open: 0;
  --scrim-close: 1;
  --amber: #D9AE7C;
  --amber-deep: #C79A63;
}

/* --------------------------------------------------------------------------
   4. TYPE ROLES
   Four roles, deliberately unlike each other, built out of a system stack.
   eyebrow   tiny, wide, uppercase, muted
   heading   large, tight, bone
   subheading  serif italic, mid, secondary
   copy      sans, comfortable, secondary
   voice     serif roman. Reserved for words a person actually wrote.
   -------------------------------------------------------------------------- */

.eyebrow {
  font-family: var(--font-ui);
  font-size: var(--fs-eyebrow);
  font-weight: 600;
  line-height: 1.2;
  letter-spacing: 0.3em;
  text-transform: uppercase;
  color: var(--text-3);
  margin: 0 0 1.1rem;
}
.eyebrow--amber { color: var(--amber-deep); }

.heading {
  font-family: var(--font-ui);
  font-size: var(--fs-h2);
  font-weight: 600;
  line-height: 1.05;
  letter-spacing: -0.028em;
  color: var(--text);
  text-wrap: balance;
  margin: 0;
}
.heading--lg { font-size: var(--fs-h1); letter-spacing: -0.035em; }
.heading--sm { font-size: var(--fs-h3); letter-spacing: -0.018em; line-height: 1.15; }

.subheading {
  font-family: var(--font-voice);
  font-style: italic;
  font-weight: 400;
  font-size: var(--fs-sub);
  line-height: 1.38;
  letter-spacing: 0;
  color: var(--text-2);
  margin: 0;
}

.copy {
  font-family: var(--font-ui);
  font-size: var(--fs-body);
  line-height: 1.72;
  color: var(--text-2);
  max-width: var(--measure);
  text-wrap: pretty;
  margin: 0;
}

.voice {
  font-family: var(--font-voice);
  font-style: normal;
  font-weight: 400;
  font-size: var(--fs-voice);
  line-height: 1.62;
  letter-spacing: 0.002em;
  color: var(--text);
  max-width: var(--measure);
  text-align: left;      /* ragged right, always */
  text-wrap: pretty;
  overflow-wrap: break-word;
  white-space: pre-wrap; /* people write in paragraphs. keep their line breaks. */
  margin: 0;
}

.quiet {
  font-size: var(--fs-micro);
  line-height: 1.5;
  letter-spacing: 0.04em;
  color: var(--text-3);
  margin: 0;
}

.num {
  font-family: var(--font-num);
  font-variant-numeric: tabular-nums;
  font-size: var(--fs-micro);
  letter-spacing: 0.02em;
}

/* Hebrew. dir="rtl" is correct and must stay, but an rtl block fills its line
   box and pins its text to the right edge of the page. Shrinking the box to
   its content keeps the bidi correct and leaves the name on the rail. */
.hebrew {
  font-family: var(--font-he);
  direction: rtl;
  unicode-bidi: isolate;
  letter-spacing: 0;
  width: fit-content;
  max-width: 100%;
}

.measure   { max-width: var(--measure); }
.flow > * + * { margin-top: 1.15em; }
.flow--wide > * + * { margin-top: 1.9em; }

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

/* --------------------------------------------------------------------------
   5. SHARED FURNITURE
   -------------------------------------------------------------------------- */

/* The rail. Every view sits on it. Its left edge matches the chrome and the
   transport at every width, so the page reads as one object and not as a
   header, a page and a player that happen to be stacked. */
.shell {
  width: 100%;
  padding-inline: var(--gutter-l) var(--gutter-r);
}

/* A narrower block of reading, left aligned on the rail. Never centred:
   centring it would move the left edge and break the rail. */
.column       { max-width: 44rem; }
.column--text { max-width: var(--measure); }
.column--form { max-width: 34rem; }

/* A hairline of light, never a border. */
.rule {
  height: 1px;
  width: 100%;
  background-image: linear-gradient(
    90deg, transparent 0%, var(--line-2) 14%, var(--line-2) 86%, transparent 100%
  );
}
.rule--short { max-width: 6rem; background-image: linear-gradient(90deg, var(--line-3), transparent); }

.empty {
  padding: clamp(3rem, 12vh, 6rem) 0;
  max-width: 46ch;
}
.empty__line {
  font-family: var(--font-voice);
  font-size: var(--fs-sub);
  line-height: 1.6;
  color: var(--text-2);
  margin: 0;
}
.empty__line + .empty__line { margin-top: 0.9rem; color: var(--text-3); font-size: var(--fs-body); }

/* Loading. A slow breath, not a spinner. */
.loading {
  display: inline-flex;
  align-items: center;
  gap: 0.45rem;
  padding: 1.5rem 0;
}
.loading__dot {
  width: 5px; height: 5px;
  border-radius: 50%;
  background: var(--amber-deep);
  opacity: 0.25;
  animation: breathe 2600ms var(--ease-soft) infinite;
}
.loading__dot:nth-child(2) { animation-delay: 260ms; }
.loading__dot:nth-child(3) { animation-delay: 520ms; }

@keyframes breathe {
  0%, 100% { opacity: 0.20; }
  50%      { opacity: 0.72; }
}

.note {
  font-size: var(--fs-micro);
  line-height: 1.6;
  color: var(--text-3);
  max-width: 52ch;
  margin: 0;
}

.link {
  color: var(--amber);
  background-image: linear-gradient(90deg, var(--amber-deep), var(--amber-deep));
  background-repeat: no-repeat;
  background-size: 100% 1px;
  background-position: 0 100%;
  transition: color var(--t-1) var(--ease), background-size var(--t-1) var(--ease);
}
.link:hover { color: var(--amber-lit); }

/* --------------------------------------------------------------------------
   6. CONTROLS
   No boxes. Affordance comes from colour, a hairline of light, and size.
   -------------------------------------------------------------------------- */

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.6em;
  position: relative;
  min-height: 44px;
  padding: 0.55rem 0;
  font-family: var(--font-ui);
  font-size: var(--fs-small);
  font-weight: 500;
  line-height: 1.2;
  letter-spacing: 0.06em;
  color: var(--text);
  text-align: center;
  transition: color var(--t-1) var(--ease), opacity var(--t-1) var(--ease);
}
.btn:hover { color: var(--amber-lit); }

.btn svg { width: 1.05em; height: 1.05em; fill: currentColor; flex: none; }

.btn--quiet { color: var(--text-3); }
.btn--quiet:hover { color: var(--text); }

.btn--amber { color: var(--amber); }
.btn--amber:hover { color: var(--amber-lit); }

/* The one action on any screen that matters most. Light, not a box. */
.btn--primary {
  font-family: var(--font-ui);
  font-size: var(--fs-sub);
  font-weight: 500;
  letter-spacing: 0.1em;
  color: var(--amber);
  padding: 0.9rem 1.4rem 1rem;
  min-height: 52px;
}
.btn--primary::before {
  content: "";
  position: absolute;
  inset: -70% -25%;
  background: radial-gradient(50% 50% at 50% 50%, var(--glow), rgba(224,168,96,0) 70%);
  opacity: 0.55;
  transition: opacity var(--t-2) var(--ease);
  pointer-events: none;
}
.btn--primary::after {
  content: "";
  position: absolute;
  left: 50%;
  bottom: 0.55rem;
  width: calc(100% - 2.8rem);
  height: 1px;
  background-image: linear-gradient(90deg, transparent, var(--amber), transparent);
  transform: translateX(-50%) scaleX(0.7);
  transform-origin: 50% 50%;
  opacity: 0.8;
  transition: transform var(--t-2) var(--ease), opacity var(--t-2) var(--ease);
  pointer-events: none;
}
.btn--primary:hover::after,
.btn--primary:focus-visible::after { transform: translateX(-50%) scaleX(1); opacity: 1; }
.btn--primary:hover::before,
.btn--primary:focus-visible::before { opacity: 1; }
.btn--primary:hover { color: var(--amber-lit); }

/* Round transport controls. A circle is a control, not a card. */
.btn--round {
  width: 44px; height: 44px;
  min-height: 44px;
  padding: 0;
  border-radius: 50%;
  color: var(--text-2);
  flex: none;
}
.btn--round svg { width: 18px; height: 18px; }
.btn--round:hover { color: var(--text); }

/* The primary control. A dark disc with a lit rim, not a bright orange button.
   A memorial does not need a play button that shouts louder than the memories.
   Icon is amber-lit: 6.8:1 at the disc centre, 9.7:1 at its edge. */
.btn--lit {
  width: 46px; height: 46px;
  min-height: 46px;
  border-radius: 50%;
  color: var(--amber-lit);
  background-image:
    radial-gradient(circle at 50% 50%,
      rgba(224, 168, 96, 0) 46%,
      rgba(224, 168, 96, 0.55) 47.5%,
      rgba(224, 168, 96, 0.55) 50%,
      rgba(224, 168, 96, 0) 51.5%),
    radial-gradient(circle at 50% 32%, #4E351A 0%, #2A1C0E 96%);
  transition: filter var(--t-2) var(--ease);
}
.btn--lit svg { width: 17px; height: 17px; }
.btn--lit:hover { color: var(--amber-lit); filter: brightness(1.18); }
.btn--lit:active { filter: brightness(0.92); }
@media (min-width: 46em) {
  .btn--lit { width: 52px; height: 52px; min-height: 52px; }
  .btn--lit svg { width: 20px; height: 20px; }
}

.btn[disabled],
.btn:disabled {
  opacity: 0.3;
  pointer-events: none;
}

.btn.is-busy { opacity: 0.5; pointer-events: none; }

/* Chips: sort order, contribution kind. Selection shown with light, not a pill. */
.chips {
  display: flex;
  flex-wrap: wrap;
  gap: 0.25rem 1.5rem;
  align-items: center;
}
.chip {
  position: relative;
  min-height: 44px;
  padding: 0.5rem 0;
  font-size: var(--fs-micro);
  font-weight: 500;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-3);
  transition: color var(--t-1) var(--ease);
}
.chip::after {
  content: "";
  position: absolute;
  left: 0; right: 0; bottom: 0.7rem;
  height: 1px;
  background: var(--amber);
  transform: scaleX(0);
  transform-origin: 0 50%;
  opacity: 0;
  transition: transform var(--t-2) var(--ease), opacity var(--t-2) var(--ease);
}
.chip:hover { color: var(--text-2); }
.chip.is-on,
.chip[aria-pressed="true"],
.chip[aria-checked="true"] { color: var(--amber); }
.chip.is-on::after,
.chip[aria-pressed="true"]::after,
.chip[aria-checked="true"]::after { transform: scaleX(1); opacity: 1; }

/* --------------------------------------------------------------------------
   7. FORMS
   Bottom rule only, drawn as light. Zero radius. Nothing looks like a box.
   -------------------------------------------------------------------------- */

.form { max-width: 40rem; }

.field { display: block; margin: 0 0 2.25rem; }

.field__label {
  display: block;
  font-size: var(--fs-eyebrow);
  font-weight: 600;
  letter-spacing: 0.3em;
  text-transform: uppercase;
  color: var(--text-3);
  margin: 0 0 0.7rem;
  transition: color var(--t-1) var(--ease);
}

.field__input,
.field__area {
  display: block;
  width: 100%;
  padding: 0.7rem 0 0.85rem;
  font-family: var(--font-ui);
  font-size: var(--fs-body);
  line-height: 1.55;
  color: var(--text);
  background-color: transparent;
  background-image: linear-gradient(90deg, var(--line-2), var(--line-2));
  background-repeat: no-repeat;
  background-size: 100% 1px;
  background-position: 0 100%;
  transition: background-image var(--t-1) var(--ease), color var(--t-1) var(--ease);
}
.field__area {
  min-height: 9rem;
  resize: vertical;
}
/* The memory composer. You are writing to him, not filling in a form. */
.field__area--voice {
  font-family: var(--font-voice);
  font-size: var(--fs-voice);
  line-height: 1.6;
  min-height: 11rem;
}

.field__input::placeholder,
.field__area::placeholder { color: rgba(154, 144, 134, 0.62); }

.field__input:focus,
.field__area:focus {
  background-image: linear-gradient(90deg, var(--amber), var(--amber-deep));
  background-size: 100% 2px;
  outline: none;
}
.field__input:focus-visible,
.field__area:focus-visible { outline: none; }
.field:focus-within .field__label { color: var(--amber-deep); }

.field__hint {
  display: block;
  font-size: var(--fs-micro);
  line-height: 1.5;
  color: var(--text-3);
  margin: 0.6rem 0 0;
}
.field__count {
  display: block;
  font-family: var(--font-num);
  font-variant-numeric: tabular-nums;
  font-size: var(--fs-micro);
  color: var(--text-3);
  text-align: right;
  margin: 0.5rem 0 0;
}
.field__count.is-near { color: var(--amber-deep); }

.field__error {
  display: block;
  font-size: var(--fs-micro);
  line-height: 1.5;
  color: var(--warn);
  margin: 0.6rem 0 0;
}
.field.is-invalid .field__input,
.field.is-invalid .field__area {
  background-image: linear-gradient(90deg, var(--warn), var(--warn));
  background-size: 100% 2px;
}

.form__actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem 2rem;
  margin-top: 2.25rem;
}

.form__status {
  font-family: var(--font-voice);
  font-size: var(--fs-sub);
  line-height: 1.5;
  color: var(--text-2);
  margin: 1.75rem 0 0;
  max-width: 46ch;
}
.form__status.is-ok  { color: var(--amber); }
.form__status.is-err { color: var(--warn); }
.form__status:empty  { display: none; }

/* Honeypot, if a builder wants one. Off screen, never display:none. */
.field--trap {
  position: absolute;
  left: -9999px;
  width: 1px; height: 1px;
  overflow: hidden;
}

/* --------------------------------------------------------------------------
   8. THE DOOR
   Black. His name. One way in. Leaving it should feel like a door opening
   into a bigger dark space, not like a dialog being dismissed.
   -------------------------------------------------------------------------- */

#door.view--door {
  position: fixed;
  inset: 0;
  z-index: 30;
  display: grid;
  place-items: center;
  min-height: 100dvh;
  padding:
    calc(2rem + var(--safe-t))
    calc(var(--pad) + var(--safe-r))
    calc(2rem + var(--safe-b))
    calc(var(--pad) + var(--safe-l));
  isolation: isolate;
}

/* The ground outside. Cold, and the only cold on the site. */
#door.view--door::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  background: radial-gradient(
    125% 92% at 50% 44%,
    rgba(var(--door-ink), 0.30) 0%,
    rgba(var(--door-ink), 0.72) 52%,
    rgba(var(--door-ink), 0.95) 100%
  );
  transition: opacity var(--t-3) var(--ease);
}

.door__inner {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  /* Wide enough for a wordmark that fills the viewport. Every other child of
     this box carries its own measure, so widening it moves nothing but the
     name. */
  max-width: min(100%, 68rem);
  transform-origin: 50% 45%;
  will-change: transform, opacity;
  transition:
    transform var(--t-3) var(--ease),
    opacity   960ms var(--ease-soft);
}

/* The light he is standing in. It breathes, very slowly, on the swell of his
   own low end if anything is feeding --swell, and on a long sine if nothing is.

   The amplitude is capped in the arithmetic, not in the tuning: the term can
   only move this backlight between 0.86 and 1.10 of its own opacity, at a
   ceiling of 0.18 alpha over near black. There is no value any other module can
   write into --swell that turns this into a flashing light. That is the point
   of doing it here rather than trusting a caller. */
.door__inner::before {
  content: "";
  position: absolute;
  inset: -30% -28% -18%;
  z-index: -1;
  pointer-events: none;
  opacity: calc(0.86 + 0.24 * var(--swell-safe));
  background: radial-gradient(
    58% 44% at 50% 40%,
    rgba(201, 138, 75, 0.18) 0%,
    rgba(201, 138, 75, 0.055) 46%,
    rgba(201, 138, 75, 0) 72%
  );
  transition: opacity 220ms linear;
  animation: door-breathe 9s var(--e-soft) infinite;
}

/* 9 seconds. Slower than a resting breath, which is the register this whole
   site is pitched at. */
@keyframes door-breathe {
  0%, 100% { opacity: calc(0.80 + 0.24 * var(--swell-safe)); }
  50%      { opacity: calc(0.98 + 0.24 * var(--swell-safe)); }
}

/* The wordmark. It fills the viewport width, it is carved in light, and it is
   the single biggest thing on the site, because it is his name.

   Must look right as plain text, because app.js writes plain text into it. */
.wordmark {
  font-family: var(--font-ui);
  font-size: var(--fs-mark);
  font-weight: 600;
  line-height: 0.82;
  letter-spacing: -0.062em;
  text-transform: uppercase;
  color: var(--text);
  margin: 0 -0.06em 0 0;   /* cancel the trailing letterspace: optically centred */
  padding: 0;
  white-space: nowrap;
}
@supports ((-webkit-background-clip: text) or (background-clip: text)) {
  .wordmark {
    background-image: linear-gradient(
      180deg,
      #FFFAF2 0%,
      #EDE7DE 34%,
      #CFC4B5 72%,
      #B0A496 100%
    );
    -webkit-background-clip: text;
    background-clip: text;
    -webkit-text-fill-color: transparent;
    color: transparent;
  }
}

.wordmark__he {
  font-family: var(--font-he);
  direction: rtl;
  unicode-bidi: isolate;
  width: fit-content;
  max-width: 100%;
  font-size: clamp(1.25rem, 5.5vw, 1.9rem);
  font-weight: 400;
  line-height: 1.3;
  letter-spacing: 0;
  color: var(--amber);
  opacity: 0.92;
  margin: clamp(0.75rem, 2.5vw, 1.35rem) 0 0;
}

.door__dates {
  font-family: var(--font-ui);
  font-size: var(--fs-eyebrow);
  font-weight: 600;
  letter-spacing: 0.42em;
  text-transform: uppercase;
  color: var(--text-3);
  margin: clamp(1.5rem, 5vw, 2.5rem) -0.42em 0 0;  /* optically centred */
}

.door__enter {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 60px;
  margin: clamp(2.5rem, 8vw, 4rem) 0 0;
  padding: 1rem 2.25rem 1.15rem;
  font-family: var(--font-ui);
  font-size: clamp(1.0625rem, 3.2vw, 1.3125rem);
  font-weight: 400;
  letter-spacing: 0.24em;
  color: var(--amber);
  transition: color var(--t-2) var(--ease);
}
.door__enter > span { display: block; margin-right: -0.24em; } /* optically centred */
.door__enter::before {
  content: "";
  position: absolute;
  inset: -180% -60%;
  pointer-events: none;
  background: radial-gradient(
    50% 50% at 50% 50%, rgba(224, 168, 96, 0.16), rgba(224, 168, 96, 0) 70%
  );
  opacity: 0.6;
  transition: opacity var(--t-2) var(--ease);
}
.door__enter::after {
  content: "";
  position: absolute;
  left: 50%;
  bottom: 0.55rem;
  width: calc(100% - 1.6rem);
  height: 1px;
  background-image: linear-gradient(90deg, transparent, var(--amber), transparent);
  transform: translateX(-50%) scaleX(0.62);
  opacity: 0.85;
  transition: transform 900ms var(--ease), opacity 900ms var(--ease);
  pointer-events: none;
}
.door__enter:hover,
.door__enter:focus-visible { color: var(--amber-lit); }
.door__enter:hover::after,
.door__enter:focus-visible::after { transform: translateX(-50%) scaleX(1); opacity: 1; }
.door__enter:hover::before,
.door__enter:focus-visible::before { opacity: 1; }
.door__enter:active { color: var(--amber-deep); }

.door__presence {
  font-size: var(--fs-micro);
  line-height: 1.5;
  letter-spacing: 0.08em;
  color: var(--text-3);
  margin: clamp(1.75rem, 6vw, 2.75rem) 0 0;
  min-height: 1.5em;
  max-width: 28ch;
}

/* ------------------------------------------------------------- the opening

   Before the wordmark resolves there is a hold of near black with only the
   field moving and the light far away. Let people arrive before you show them
   anything. Then his name comes up out of it, over 1600ms, which is the longest
   easing in the grammar and is used exactly once, here.

   The button is hit testable and focusable through the whole hold, because an
   invisible control that still works is a held moment, and an invisible control
   that does not work is a locked door. Tab or touch it and it lights straight
   away rather than waiting its turn.

   Reduced motion collapses every one of these to nothing and the door is simply
   there, composed, on arrival. */

@keyframes door-arrive {
  from { opacity: 0; transform: translateY(10px); }
  to   { opacity: 1; transform: none; }
}
@keyframes door-arrive-still {
  from { opacity: 0; }
  to   { opacity: 1; }
}

body[data-state="closed"] .wordmark,
body[data-state="closed"] .wordmark__he,
body[data-state="closed"] .door__dates,
body[data-state="closed"] .door__enter,
body[data-state="closed"] .door__note,
body[data-state="closed"] .door__presence {
  animation: door-arrive var(--t-door) var(--e-door) both;
}

body[data-state="closed"] .wordmark       { animation-delay: 2400ms; animation-name: door-arrive-still; }
body[data-state="closed"] .wordmark__he   { animation-delay: 3100ms; animation-duration: 1200ms; }
body[data-state="closed"] .door__dates    { animation-delay: 3000ms; animation-duration: 1200ms; }
body[data-state="closed"] .door__enter    { animation-delay: 3200ms; animation-duration: 1100ms; }
body[data-state="closed"] .door__note     { animation-delay: 3800ms; animation-duration: 900ms; }
body[data-state="closed"] .door__presence { animation-delay: 4000ms; animation-duration: 900ms; }

/* The way in is never actually locked. */
body[data-state="closed"] .door__enter:focus-visible,
body[data-state="closed"] .door__enter:hover,
body[data-state="closed"] .door__enter:active { animation: none; opacity: 1; }

/* The door's own light comes up under all of it, first, so the room reads as
   having been running before anybody arrived. */
body[data-state="closed"] .door__inner::before {
  animation: door-breathe 9s var(--e-soft) 1200ms infinite;
}

/* Leaving. The room recedes and grows. app.js hides the node at 1200ms.

   Any part of the opening still in flight is cancelled outright, so nothing can
   be holding an element at zero opacity while the whole door tries to fade out
   underneath it. */
#door.is-leaving { pointer-events: none; }
#door.is-leaving .wordmark,
#door.is-leaving .wordmark__he,
#door.is-leaving .door__dates,
#door.is-leaving .door__enter,
#door.is-leaving .door__note,
#door.is-leaving .door__presence,
#door.is-leaving .door__inner::before { animation: none; opacity: 1; }
#door.is-leaving .door__inner {
  opacity: 0;
  transform: translateY(-2.2vh) scale(1.06);
}
#door.is-leaving::before { opacity: 0; }

/* --------------------------------------------------------------------------
   9. CHROME
   Sticky, so it costs no layout maths and never covers the top of a scroll.
   -------------------------------------------------------------------------- */

.chrome {
  position: sticky;
  top: 0;
  z-index: 6;
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  grid-template-areas:
    "mark presence"
    "nav  nav";
  align-items: center;
  column-gap: 1rem;
  row-gap: 0.35rem;
  padding: calc(1rem + var(--safe-t)) var(--gutter-r) 2.25rem var(--gutter-l);
  background-image: linear-gradient(
    to bottom,
    rgba(10, 9, 8, 0.95) 0%,
    rgba(10, 9, 8, 0.88) 42%,
    rgba(10, 9, 8, 0.60) 72%,
    rgba(10, 9, 8, 0) 100%
  );
}

.chrome__mark {
  grid-area: mark;
  font-size: var(--fs-small);
  font-weight: 600;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--text);
  padding: 0.35rem 0;
  margin-right: -0.22em;
  transition: color var(--t-1) var(--ease);
}
.chrome__mark:hover { color: var(--amber); }

.chrome__nav {
  grid-area: nav;
  display: flex;
  align-items: center;
  /* Five rooms do not fit across a 390px phone, and the fifth was pushing the
     whole document 49px wide, which drags the fixed canvas and transport out
     with it and makes the entire page scroll sideways. It wraps instead of
     scrolling so nothing is ever hidden off the edge of a memorial. */
  flex-wrap: wrap;
  gap: 0.35rem clamp(0.9rem, 4vw, 2rem);
  min-width: 0;
}
.chrome__nav a {
  position: relative;
  display: inline-block;
  padding: 0.55rem 0;
  font-size: var(--fs-eyebrow);
  font-weight: 500;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  white-space: nowrap;
  color: var(--text-3);
  transition: color var(--t-1) var(--ease);
}
.chrome__nav a::after {
  content: "";
  position: absolute;
  left: 0; right: 0; bottom: 0.2rem;
  height: 1px;
  background: var(--amber);
  transform: scaleX(0);
  transform-origin: 0 50%;
  opacity: 0;
  transition: transform var(--t-2) var(--ease), opacity var(--t-2) var(--ease);
}
.chrome__nav a:hover { color: var(--text-2); }
.chrome__nav a[aria-current="page"] { color: var(--amber); }
.chrome__nav a[aria-current="page"]::after { transform: scaleX(1); opacity: 1; }

.chrome__presence {
  grid-area: presence;
  justify-self: end;
  text-align: right;
  font-size: var(--fs-micro);
  line-height: 1.4;
  letter-spacing: 0.04em;
  color: var(--text-3);
  max-width: 22ch;
  margin: 0;
}
.chrome__presence:empty { display: none; }

@media (min-width: 46em) {
  .chrome {
    grid-template-columns: auto minmax(0, 1fr) auto;
    grid-template-areas: "mark nav presence";
    column-gap: clamp(1.5rem, 4vw, 3.5rem);
    padding-top: calc(1.35rem + var(--safe-t));
    padding-bottom: 3rem;
  }
  .chrome__nav { justify-content: flex-start; }
  .chrome__presence { max-width: 30ch; }
}

/* --------------------------------------------------------------------------
   10. VIEWS
   -------------------------------------------------------------------------- */

#main { position: relative; z-index: 2; }

.view {
  position: relative;
  z-index: 2;

  /* Routing is done with location.hash, and these sections carry the matching
     ids, so the browser also does its own native anchor scroll and drops the
     section flush against the viewport top, tucking its eyebrow under the
     sticky header. Reserve the header's height so a nav click lands with the
     heading fully visible. Costs nothing when there is no scroll to do. */
  scroll-margin-top: calc(var(--chrome-h) + 1rem);
}

/* Room to room, it should feel like walking rather than like content being
   swapped: a held beat, the light re-tempering to the new hour underneath, and
   then the new room arriving into it.

   app.js changes rooms with the hidden attribute, and an element coming back
   from display:none restarts its animations, so the whole transition is this
   one declaration and no JavaScript at all. It never blocks input: an
   animation cannot, and the room is hit testable from its first frame.

   The veil under it is on a twelve second drift and the scrims cross fade over
   1100ms, so the light is still settling after the room has arrived. That
   overlap is the walk. */
.view:not(.view--door) {
  animation: room-arrive var(--t-room) var(--e-room) 120ms both;
}

@keyframes room-arrive {
  from { opacity: 0; transform: translateY(9px); }
  to   { opacity: 1; transform: none; }
}

/* Reading rooms. */
#wall, #him, #give {
  padding-block: clamp(1rem, 3vh, 2rem) clamp(3rem, 10vh, 6rem);
}

/* The head of a reading room. */
.head { margin: 0 0 clamp(2.25rem, 7vh, 3.75rem); }
.head__title { margin: 0; }
.head__sub { margin: 0.9rem 0 0; max-width: 48ch; }
.head__meta { margin: 1.1rem 0 0; }

.section { margin: clamp(3rem, 9vh, 5.5rem) 0 0; }
.section__head { margin: 0 0 clamp(1.25rem, 4vh, 2rem); }

/* --------------------------------------------------------------------------
   11. THE ROOM
   The visual is the subject. Type sits low, in the bottom scrim, out of its way.
   -------------------------------------------------------------------------- */

#room {
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  min-height: calc(100dvh - var(--chrome-h));
  padding-block:
    clamp(2rem, 8vh, 5rem)
    calc(var(--transport-h) + var(--safe-b) + 1rem);
}

.room__stage {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: clamp(1.25rem, 3.5vh, 2rem);
}

.room__now { max-width: 34rem; }

.room__title {
  font-family: var(--font-ui);
  font-size: var(--fs-h1);
  font-weight: 600;
  line-height: 1.02;
  letter-spacing: -0.035em;
  color: var(--text);
  text-wrap: balance;
  margin: 0;
}

.room__where {
  font-family: var(--font-voice);
  font-style: italic;
  font-size: var(--fs-sub);
  line-height: 1.35;
  color: var(--text-2);
  margin: 0.65rem 0 0;
}

.room__meta {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.35rem 1.25rem;
  margin: 1rem 0 0;
  font-size: var(--fs-micro);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-3);
}
.room__meta > * { margin: 0; }

/* Same string as .lowpoint__line, same rule: his line break survives. */
.room__note {
  font-family: var(--font-voice);
  font-size: var(--fs-body);
  line-height: 1.6;
  color: var(--text-2);
  max-width: 46ch;
  white-space: pre-wrap;
  overflow-wrap: break-word;
  margin: 1.25rem 0 0;
}

.room__actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem 2rem;
  margin: 0;
}

/* "leave a memory here". Always available, never shouting. */
.room__here {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 0.7rem;
  min-height: 44px;
  padding: 0.6rem 0;
  font-size: var(--fs-small);
  font-weight: 500;
  letter-spacing: 0.1em;
  color: var(--amber);
  transition: color var(--t-1) var(--ease);
}
.room__here::before {
  content: "";
  width: 6px; height: 6px;
  border-radius: 50%;
  background: var(--amber);
  flex: none;
  animation: pulse-slow 5200ms var(--ease-soft) infinite;
}
.room__here:hover { color: var(--amber-lit); }

@keyframes pulse-slow {
  0%, 100% { opacity: 0.45; transform: scale(0.9); }
  50%      { opacity: 1;    transform: scale(1.12); }
}

/* No sets loaded yet. This is a real state and it has to be beautiful. */
.room__empty { max-width: 40ch; }

/* --------------------------------------------------------------------------
   12. TRANSPORT AND TIMELINE
   Fixed to the bottom for the life of the session. Never covers content:
   body padding-bottom always clears it.
   -------------------------------------------------------------------------- */

.transport {
  position: fixed;
  left: 0; right: 0; bottom: 0;
  z-index: 7;
  /* Height budget, mobile: 32 top + 40 seek + 4 gap + 46 controls + 12 bottom
     = 134px. --transport-h is 8.5rem (136px). Content always clears it. */
  padding:
    2rem
    var(--gutter-r)
    calc(0.75rem + var(--safe-b))
    var(--gutter-l);
  background-image: linear-gradient(
    to bottom,
    rgba(10, 9, 8, 0) 0%,
    rgba(10, 9, 8, 0.72) 26%,
    rgba(10, 9, 8, 0.94) 58%,
    rgba(10, 9, 8, 0.985) 100%
  );
}

@supports ((backdrop-filter: blur(6px)) or (-webkit-backdrop-filter: blur(6px))) {
  .transport {
    -webkit-backdrop-filter: blur(14px) saturate(115%);
    backdrop-filter: blur(14px) saturate(115%);
    -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 2rem);
    mask-image: linear-gradient(to bottom, transparent 0, #000 2rem);
  }
}

.transport__inner {
  width: 100%;
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
}

.transport__row {
  display: flex;
  align-items: center;
  gap: clamp(0.4rem, 2vw, 1rem);
  min-width: 0;
}
.transport__row--seek     { gap: 0.75rem; }
.transport__row--controls { gap: clamp(0.35rem, 2vw, 0.9rem); }

.transport__group {
  display: flex;
  align-items: center;
  gap: 0.15rem;
  flex: none;
}

/* Timecodes flank the timeline, so the bar stays two rows tall on a phone. */
.transport__time-now,
.transport__time-total {
  flex: none;
  font-family: var(--font-num);
  font-variant-numeric: tabular-nums;
  font-size: var(--fs-micro);
  letter-spacing: 0.02em;
  color: var(--text-3);
  min-width: 4ch;
  margin: 0;
}
.transport__time-now { color: var(--text-2); text-align: left; }
.transport__time-total { text-align: right; }

.transport__now {
  flex: 1 1 auto;
  min-width: 0;
  padding-inline: 0.6rem;
}
.transport__title {
  display: block;
  font-size: var(--fs-small);
  font-weight: 500;
  letter-spacing: 0.01em;
  color: var(--text);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  margin: 0;
}
.transport__where {
  display: none;
  font-family: var(--font-voice);
  font-style: italic;
  font-size: var(--fs-micro);
  color: var(--text-3);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  margin: 0.1rem 0 0;
}

.transport__volume {
  display: none;
  align-items: center;
  gap: 0.5rem;
  flex: none;
}

.transport__here {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.55rem;
  width: 44px;
  height: 44px;
  min-height: 44px;
  padding: 0;
  border-radius: 50%;
  font-size: var(--fs-micro);
  font-weight: 500;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--amber);
  white-space: nowrap;
  transition: color var(--t-1) var(--ease);
}
.transport__here:hover { color: var(--amber-lit); }
.transport__here svg { width: 16px; height: 16px; fill: currentColor; flex: none; }

/* On a phone the label is read out, not shown. The button keeps its aria-label. */
.transport__here-label {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@media (min-width: 46em) {
  .transport {
    padding-top: 2.25rem;
    padding-bottom: calc(1rem + var(--safe-b));
  }
  .transport__where { display: block; }
  .transport__volume { display: flex; }
  .transport__here {
    width: auto;
    height: auto;
    border-radius: 0;
    padding: 0 0 0 1rem;
  }
  .transport__here-label {
    position: static;
    width: auto; height: auto;
    margin: 0;
    clip-path: none;
  }
}

/* The timeline. His set, and every memory pinned inside it. */
.timeline {
  /* --played-n is written on the four elements that consume it, never here.
     .timeline is the parent of every memory light, and a custom property set
     on it would invalidate inherited style for all of them, every frame. */
  --buffered: 0%;
  position: relative;
  flex: 1 1 auto;
  width: 100%;
  height: 40px;
  min-width: 0;
  cursor: pointer;
  touch-action: none;
  -webkit-user-select: none;
  user-select: none;
}
@media (min-width: 46em) { .timeline { height: 36px; } }

.timeline__track,
.timeline__buffer,
.timeline__played {
  position: absolute;
  top: 50%;
  left: 0;
  height: 2px;
  transform: translateY(-50%);
  pointer-events: none;
}
.timeline__track  { right: 0; background: rgba(237, 231, 222, 0.13); }
.timeline__buffer { width: var(--buffered); background: rgba(237, 231, 222, 0.22); }
/* The playhead moves sixty times a second for an hour. It is the single most
   frequently updated thing on the page, so it is not allowed to cost layout.

   --played-n is a unitless 0 to 1 written straight onto this element and onto
   the head, the horizon bar and the horizon glow, four writes, never onto
   .timeline itself. That last part matters: .timeline is the parent of every
   memory light, and a custom property written there invalidates inherited
   style for all of them on every frame.

   The bar is full width and squashed with scaleX. The head is a full width
   zero height rail carrying the dot in its own ::after, because a percentage
   in translateX resolves against the translated element's own box, so the
   rail has to be as wide as the track for the percentage to mean what it
   says. Both are composite-only. */
.timeline__played {
  right: 0;
  width: auto;
  transform: translateY(-50%) scaleX(var(--played-n, 0));
  transform-origin: left center;
  background-image: linear-gradient(90deg, var(--amber-deep), var(--amber));
}

.timeline__head {
  position: absolute;
  top: 50%;
  left: 0;
  right: 0;
  height: 0;
  margin: 0;
  transform: translateX(calc(var(--played-n, 0) * 100%));
  pointer-events: none;
}
.timeline__head::after {
  content: "";
  position: absolute;
  left: 0;
  top: 0;
  width: 9px; height: 9px;
  margin: -4.5px 0 0 -4.5px;
  border-radius: 50%;
  background: var(--amber-lit);
}

/* A memory, sitting at its moment. A light, not a marker.

   The state and the breath deliberately live on two different elements. An
   infinite animation on opacity always beats a static opacity declaration in
   the cascade, so if both sat on the dot then .is-passed and .is-active would
   never once be visible. The button carries the state, the dot carries the
   breath, and the stacking context multiplies them.

   It has to be opacity. The previous version breathed with filter:
   brightness(), which Gecko does not composite at all and which held one extra
   layer per memory on Blink, forever, in a transport bar that can hold a
   hundred of them. Rule 8 of the brief is opacity and transform only, and this
   is why. */
.timeline__light {
  position: absolute;
  top: 50%;
  left: var(--at, 0%);
  width: 28px; height: 28px;      /* touch target */
  padding: 0;
  transform: translate(-50%, -50%);
  background: none;
  cursor: pointer;
  display: grid;
  place-items: center;
  opacity: 0.5;
  transition: opacity var(--t-2) var(--ease);
}
.timeline__light::after {
  content: "";
  width: 6px; height: 6px;
  border-radius: 50%;
  background: var(--amber);
  opacity: 1;
  transition: transform var(--t-2) var(--ease);
  animation: light-breathe 6400ms var(--ease-soft) infinite;
}
.timeline__light.is-passed { opacity: 0.78; }
.timeline__light.is-active { opacity: 1; }
.timeline__light.is-active::after { transform: scale(1.55); }
.timeline__light:hover,
.timeline__light:focus-visible { opacity: 1; }
.timeline__light:hover::after,
.timeline__light:focus-visible::after { transform: scale(1.6); }
.timeline__light:focus-visible { outline-offset: 0; }

@keyframes light-breathe {
  0%, 100% { opacity: 0.74; }
  50%      { opacity: 1; }
}

/* Volume. Builders may set --volume-fill on the input to show the level. */
.volume {
  --volume-fill: 100%;
  -webkit-appearance: none;
  appearance: none;
  width: 5.5rem;
  height: 44px;
  background: transparent;
  cursor: pointer;
}
.volume::-webkit-slider-runnable-track {
  height: 2px;
  background-image: linear-gradient(
    90deg,
    var(--amber-deep) 0 var(--volume-fill),
    rgba(237, 231, 222, 0.15) var(--volume-fill) 100%
  );
}
.volume::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 11px; height: 11px;
  margin-top: -4.5px;
  border-radius: 50%;
  background: var(--amber);
}
.volume::-moz-range-track {
  height: 2px;
  background-image: linear-gradient(
    90deg,
    var(--amber-deep) 0 var(--volume-fill),
    rgba(237, 231, 222, 0.15) var(--volume-fill) 100%
  );
}
.volume::-moz-range-thumb {
  width: 11px; height: 11px;
  border: 0;
  border-radius: 50%;
  background: var(--amber);
}

/* --------------------------------------------------------------------------
   13. THE SURFACER
   A memory rising over the visual as the playhead reaches it.
   Lives in the open middle of the room, where nothing else ever sits.
   -------------------------------------------------------------------------- */

.surfacer {
  position: fixed;
  left: 0; right: 0;
  top: 26dvh;
  z-index: 4;
  pointer-events: none;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: clamp(1.25rem, 4vh, 2.25rem);
  padding-inline: var(--gutter-l) var(--gutter-r);
  transition: opacity var(--t-3) var(--ease);
}

/* It belongs to the room. Elsewhere it would sit on top of someone's reading. */
body:not([data-room="room"]) .surfacer,
body[data-state="closed"] .surfacer {
  opacity: 0;
  visibility: hidden;
}

.surfacer__memory {
  max-width: min(38ch, 100%);
  animation: surface-in var(--t-mem-in) var(--e-reveal) both;
}
.surfacer__body {
  font-family: var(--font-voice);
  font-size: var(--fs-voice);
  line-height: 1.55;
  color: var(--text);
  white-space: pre-wrap;
  overflow-wrap: break-word;
  margin: 0;
}
/* This sits over the middle of the screen, which is both the brightest part of
   the visual and the point where the room scrim is at its thinnest, so the
   dimmest text colour lands at roughly 2:1 there. It is somebody's name under
   something they wrote about their friend, so it gets a colour that survives
   whatever is behind it, plus a shadow to hold it against the amber. */
.surfacer__by {
  font-size: var(--fs-micro);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-2);
  text-shadow: 0 1px 12px rgba(10, 9, 8, 0.92), 0 0 3px rgba(10, 9, 8, 0.8);
  margin: 0.85rem 0 0;
}
.surfacer__memory.is-leaving {
  animation: surface-out var(--t-mem-out) var(--e-soft) both;
}

@keyframes surface-in {
  from { opacity: 0; transform: translateY(14px); }
  to   { opacity: 1; transform: none; }
}
@keyframes surface-out {
  from { opacity: 1; transform: none; }
  to   { opacity: 0; transform: translateY(-10px); }
}

/* --------------------------------------------------------------------------
   14. THE WALL
   Every memory. Written by a person, so it reads like a person wrote it.
   -------------------------------------------------------------------------- */

.wall__tools {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem 1.5rem;
  margin: 0 0 clamp(2rem, 6vh, 3rem);
}

.wall__count {
  font-size: var(--fs-micro);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-3);
  margin: 0;
}

.wall__list { display: block; margin: 0; padding: 0; }

.memory {
  position: relative;
  padding: clamp(2rem, 6vh, 3rem) 0;
}
.memory + .memory {
  background-image: linear-gradient(
    90deg, transparent 0%, var(--line-1) 10%, var(--line-1) 78%, transparent 100%
  );
  background-repeat: no-repeat;
  background-size: 100% 1px;
  background-position: 0 0;
}

.memory__body {
  font-family: var(--font-voice);
  font-size: var(--fs-voice);
  line-height: 1.62;
  letter-spacing: 0.002em;
  color: var(--text);
  max-width: var(--measure);
  text-align: left;
  text-wrap: pretty;
  white-space: pre-wrap;
  overflow-wrap: break-word;
  margin: 0;
}

.memory__foot {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.4rem 1.25rem;
  margin: 1.15rem 0 0;
  max-width: var(--measure);
}

.memory__by {
  font-size: var(--fs-micro);
  font-weight: 500;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-3);
  margin: 0;
}

/* Anchored: click to go and stand at that exact moment in the set. */
.memory__at {
  display: inline-flex;
  align-items: baseline;
  gap: 0.5rem;
  min-height: 32px;
  padding: 0;
  font-size: var(--fs-micro);
  letter-spacing: 0.06em;
  color: var(--amber-deep);
  text-align: left;
  transition: color var(--t-1) var(--ease);
}
.memory__at:hover { color: var(--amber-lit); }
.memory__at-set {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  max-width: 22ch;
}
.memory__at-time {
  font-family: var(--font-num);
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.02em;
}

.memory__when {
  font-size: var(--fs-micro);
  letter-spacing: 0.06em;
  color: var(--text-3);
  margin: 0;
}

.memory__report {
  margin-left: auto;
  min-height: 32px;
  padding: 0;
  font-size: var(--fs-micro);
  letter-spacing: 0.1em;
  color: var(--text-2);
  transition: color var(--t-1) var(--ease), opacity var(--t-1) var(--ease);
}
.memory__report:hover { color: var(--warn); }
.memory__report.is-done { color: var(--text-3); opacity: 0.5; pointer-events: none; }

@media (hover: hover) and (pointer: fine) {
  /* It should stay quiet, because this is a memorial and not a moderation
     queue, but it is the only way a visitor can flag something vile and at 0.32
     it was about 1.4:1 against the page, which is invisible rather than quiet.
     This is the floor where it is still legible before you go looking for it. */
  .memory__report { opacity: 0.62; }
  .memory:hover .memory__report,
  .memory__report:focus-visible { opacity: 1; }
}

/* A memory that has just been left. It arrives, it does not pop. */
.memory.is-new { animation: surface-in var(--t-mem-in) var(--e-reveal) both; }

.wall__more { margin: clamp(2rem, 6vh, 3rem) 0 0; }
.wall__leave { margin: clamp(2.5rem, 8vh, 4rem) 0 0; }

/* --------------------------------------------------------------------------
   15. HIM
   -------------------------------------------------------------------------- */

.him__name {
  font-family: var(--font-ui);
  font-size: var(--fs-h1);
  font-weight: 600;
  line-height: 0.95;
  letter-spacing: -0.045em;
  text-transform: uppercase;
  color: var(--text);
  margin: 0 -0.045em 0 0;
}

.him__he {
  font-family: var(--font-he);
  direction: rtl;
  unicode-bidi: isolate;
  width: fit-content;
  max-width: 100%;
  font-size: clamp(1.125rem, 4vw, 1.5rem);
  color: var(--amber);
  opacity: 0.92;
  margin: 0.85rem 0 0;
}

.him__dates {
  font-size: var(--fs-eyebrow);
  font-weight: 600;
  letter-spacing: 0.36em;
  text-transform: uppercase;
  color: var(--text-3);
  margin: 1.5rem -0.36em 0 0;
}

.him__from {
  font-family: var(--font-voice);
  font-style: italic;
  font-size: var(--fs-sub);
  color: var(--text-2);
  margin: 0.9rem 0 0;
}

.him__about { margin: clamp(2.5rem, 8vh, 4rem) 0 0; }
.him__para {
  font-family: var(--font-voice);
  font-size: var(--fs-voice);
  line-height: 1.68;
  color: var(--text);
  max-width: var(--measure);
  text-wrap: pretty;
  margin: 0;
}
.him__para + .him__para { margin-top: 1.5em; }

/* Photos. Slow, unhurried, no frames. */
.him__photos {
  display: grid;
  grid-template-columns: 1fr;
  gap: clamp(1.75rem, 5vw, 3rem);
  margin: 0;
}
@media (min-width: 46em) {
  .him__photos { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .photo--wide { grid-column: 1 / -1; }
}

.photo { margin: 0; }
.photo__img {
  width: 100%;
  height: auto;
  display: block;
  background: var(--ink-1);
  filter: saturate(0.94) contrast(1.02);
  opacity: 0;
  transition: opacity 900ms var(--ease-soft);
}
.photo__img.is-loaded,
.photo__img[data-loaded="true"] { opacity: 1; }

/* A photograph used at real scale.

   Every photograph of him is square, and a square run to the full width of the
   rail is 1312px tall on a laptop, which is a slab you scroll through rather
   than a picture you look at. So the hero is given a band to sit in and the
   image is cropped into it, which is what a picture editor would do.

   A crop is a real decision on a memorial, so it is controllable and it is
   biased high, at 38 percent, because that is where faces sit in a photograph
   somebody took of their friend. Look at the photograph before you use this. If
   the band cuts across anybody, set --photo-pos on the figure, or use
   .photo--wide instead, which spans the same width and crops nothing.

   On a phone there is no band and no crop. The rail is narrower than the screen
   is tall, so a square already fits, and there is nothing to solve. */
.photo--hero { grid-column: 1 / -1; }
.photo--hero .photo__img {
  width: 100%;
  object-fit: cover;
  object-position: var(--photo-pos, 50% 38%);
}
@media (min-width: 46em) {
  .photo--hero .photo__img { aspect-ratio: 4 / 3; height: auto; }
}
@media (min-width: 64em) {
  .photo--hero .photo__img { aspect-ratio: 16 / 9; }
}

.photo__cap {
  font-family: var(--font-voice);
  font-size: var(--fs-body);
  line-height: 1.5;
  color: var(--text-2);
  max-width: 46ch;
  margin: 0.9rem 0 0;
}
.photo__credit {
  font-size: var(--fs-micro);
  letter-spacing: 0.1em;
  color: var(--text-3);
  margin: 0.45rem 0 0;
}

/* The gigs. Every room he stood in, oldest first. */
.gigs {
  position: relative;
  margin: 0;
  padding-left: 1.5rem;
}
.gigs::before {
  content: "";
  position: absolute;
  left: 3px; top: 0.5rem; bottom: 0.5rem;
  width: 1px;
  background-image: linear-gradient(
    to bottom, transparent 0%, var(--line-2) 8%, var(--line-2) 92%, transparent 100%
  );
}

.gig {
  position: relative;
  padding: 1.15rem 0;
}
.gig::before {
  content: "";
  position: absolute;
  left: -1.5rem;
  top: 1.65rem;
  width: 7px; height: 7px;
  border-radius: 50%;
  background: var(--amber-deep);
  opacity: 0.6;
}

.gig__date {
  display: block;
  font-family: var(--font-num);
  font-variant-numeric: tabular-nums;
  font-size: var(--fs-micro);
  letter-spacing: 0.1em;
  color: var(--text-3);
  margin: 0;
}
.gig__venue {
  display: block;
  font-size: var(--fs-h3);
  font-weight: 500;
  letter-spacing: -0.012em;
  color: var(--text);
  margin: 0.35rem 0 0;
}
.gig__event {
  display: block;
  font-family: var(--font-voice);
  font-style: italic;
  font-size: var(--fs-body);
  color: var(--text-2);
  margin: 0.3rem 0 0;
}
.gig__city {
  display: block;
  font-size: var(--fs-small);
  letter-spacing: 0.06em;
  color: var(--text-3);
  margin: 0.3rem 0 0;
}
.gig__note {
  display: block;
  font-size: var(--fs-small);
  line-height: 1.6;
  color: var(--text-3);
  max-width: 52ch;
  margin: 0.5rem 0 0;
}

.him__alias {
  font-size: var(--fs-eyebrow);
  font-weight: 600;
  letter-spacing: 0.3em;
  text-transform: uppercase;
  color: var(--amber-deep);
  margin: 0 -0.3em 0 0;
}

/* --------------------------------------------------------------------------
   16. GIVE
   -------------------------------------------------------------------------- */

.give__intro {
  font-family: var(--font-voice);
  font-size: var(--fs-voice);
  line-height: 1.62;
  color: var(--text);
  max-width: 52ch;
  margin: 0;
}
.give__intro + .give__intro { margin-top: 1.35em; }

.give__form { margin: clamp(2.5rem, 8vh, 4rem) 0 0; }

.give__promise {
  font-size: var(--fs-micro);
  line-height: 1.65;
  color: var(--text-3);
  max-width: 48ch;
  margin: 2.5rem 0 0;
}

/* --------------------------------------------------------------------------
   17. MODAL
   The composer. Not a card floating on a page. A quieter, deeper room.
   -------------------------------------------------------------------------- */

.modal {
  position: fixed;
  inset: 0;
  z-index: 20;
  display: grid;
  grid-template-rows: 1fr;
  overflow-y: auto;
  overscroll-behavior: contain;
  background-image:
    radial-gradient(120% 80% at 50% 30%, rgba(10, 9, 8, 0.82), rgba(10, 9, 8, 0.97) 70%),
    linear-gradient(rgba(10, 9, 8, 0.9), rgba(10, 9, 8, 0.9));
  animation: modal-in 520ms var(--ease) both;
}

@supports ((backdrop-filter: blur(6px)) or (-webkit-backdrop-filter: blur(6px))) {
  .modal {
    -webkit-backdrop-filter: blur(22px) saturate(110%);
    backdrop-filter: blur(22px) saturate(110%);
  }
}

@keyframes modal-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}

.modal__panel {
  width: 100%;
  max-width: 44rem;
  margin-inline: auto;
  padding:
    calc(clamp(3.5rem, 12vh, 6rem) + var(--safe-t))
    calc(var(--pad) + var(--safe-r))
    calc(clamp(3rem, 10vh, 5rem) + var(--safe-b))
    calc(var(--pad) + var(--safe-l));
  animation: modal-rise 760ms var(--ease) both;
}

@keyframes modal-rise {
  from { opacity: 0; transform: translateY(18px); }
  to   { opacity: 1; transform: none; }
}

.modal__title {
  font-family: var(--font-ui);
  font-size: var(--fs-h2);
  font-weight: 600;
  line-height: 1.05;
  letter-spacing: -0.03em;
  color: var(--text);
  text-wrap: balance;
  margin: 0;
}

.modal__where {
  font-family: var(--font-voice);
  font-style: italic;
  font-size: var(--fs-sub);
  line-height: 1.4;
  color: var(--text-2);
  margin: 0.85rem 0 0;
}
.modal__where .num { color: var(--amber); font-style: normal; }

.modal__form { margin: clamp(2.25rem, 7vh, 3.25rem) 0 0; }

.modal__close {
  position: fixed;
  top: calc(0.75rem + var(--safe-t));
  right: calc(0.75rem + var(--safe-r));
  z-index: 21;
  width: 48px; height: 48px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  color: var(--text-3);
  transition: color var(--t-1) var(--ease);
}
.modal__close:hover { color: var(--text); }
.modal__close svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.5; }

/* While the composer is open the room behind holds still. */
body.is-composing { overflow: hidden; }

/* --------------------------------------------------------------------------
   18. FOOTER
   His two lines. Nothing else belongs down here.
   -------------------------------------------------------------------------- */

.footer {
  position: relative;
  z-index: 2;
  padding:
    clamp(4rem, 14vh, 8rem)
    var(--gutter-r)
    clamp(2rem, 6vh, 3rem)
    var(--gutter-l);
  text-align: center;
}
.footer p {
  font-family: var(--font-voice);
  font-size: var(--fs-sub);
  line-height: 1.5;
  color: var(--text-2);
  margin: 0;
}
.footer__sig {
  color: var(--amber);
  margin-top: 0.7rem !important;
  letter-spacing: 0.01em;
}

/* --------------------------------------------------------------------------
   19. ARRIVAL
   Once the door has gone, the room fades up underneath it. Never at the same
   time as the door leaves, or it reads as a swap instead of a passage.
   -------------------------------------------------------------------------- */

body[data-state="open"] .chrome,
body[data-state="open"] #main,
body[data-state="open"] .transport,
body[data-state="open"] .footer {
  animation: arrive 1100ms var(--ease) both;
}
body[data-state="open"] .chrome    { animation-delay: 620ms; }
body[data-state="open"] #main      { animation-delay: 500ms; }
body[data-state="open"] .transport { animation-delay: 820ms; }
body[data-state="open"] .footer    { animation-delay: 900ms; }

@keyframes arrive {
  from { opacity: 0; }
  to   { opacity: 1; }
}

/* --------------------------------------------------------------------------
   20. REDUCED MOTION
   Everything resolves instantly to its end state, and stays beautiful.
   -------------------------------------------------------------------------- */

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 1ms !important;
    animation-delay: 0ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 1ms !important;
    transition-delay: 0ms !important;
    scroll-behavior: auto !important;
  }

  /* End states, held. */
  .loading__dot { opacity: 0.5; }
  .room__here::before { opacity: 0.85; transform: none; }
  /* The breathing lights hold still, at full. Their state is carried on the
     owning element's opacity, which is untouched, so a passed memory still
     reads dimmer than the one you are standing in. */
  .timeline__light::after,
  .nightmap__light::after { opacity: 1; }
  .horizon__fire::after { opacity: 0.7; }

  /* Anything that arrives by animation must simply be here. */
  body[data-state="open"] .chrome,
  body[data-state="open"] #main,
  body[data-state="open"] .transport,
  body[data-state="open"] .footer,
  .surfacer__memory,
  .memory.is-new,
  .modal,
  .modal__panel { opacity: 1; transform: none; }

  .surfacer__memory.is-leaving { opacity: 0; }

  #door.is-leaving .door__inner { opacity: 0; transform: none; }

  .photo__img { opacity: 1; }
}

/* --------------------------------------------------------------------------
   21. NARROW AND SHORT PHONES
   -------------------------------------------------------------------------- */

@media (max-width: 22.5em) {
  /* Still filling the width. Only the floor comes down, so a 320px phone gets
     his name at 118px rather than at a size that would overflow it. */
  :root { --fs-mark: 37vw; }
  .chrome__nav { gap: 0.7rem; }
  .chrome__nav a { letter-spacing: 0.1em; }
}

/* Short landscape phones. Do not let the door overflow, and do not let a full
   screen scene become a letterbox with a paragraph crushed into it. */
@media (max-height: 34em) and (orientation: landscape) {
  :root { --fs-mark: clamp(3rem, 16vh, 6rem); }
  .door__enter { margin-top: 1.5rem; }
  .door__dates { margin-top: 1rem; }
  .door__presence { margin-top: 1rem; }
  #room { min-height: 0; }

  /* The scene and snapping half of this safety block lives at the very bottom
     of the file, in section 32. It has to: the scene rules it overrides are
     declared later than this point, at the same specificity, and a media query
     adds none, so up here it would lose on source order and do nothing. */
}

/* --------------------------------------------------------------------------
   22. PRINT
   Someone will want the wall on paper. Give it to them properly.
   -------------------------------------------------------------------------- */

/* Somebody will print this. His family, or an order of service, or one person
   who wants his gig list on paper. The whole page is designed as light on a
   dark room, so without this every heading, his name and every gig prints as
   near white on white and the paper comes out blank. */
@media print {
  #visual, #door, .chrome, .transport, .surfacer, .modal,
  html::before, body::before, body::after { display: none !important; }
  html, body { background: #fff; color: #000; }
  body { padding: 0; }

  .memory__body, .voice, .him__para { color: #000; font-size: 12pt; }
  .memory__by, .memory__when, .quiet, .note { color: #444; }
  .memory { page-break-inside: avoid; }

  /* His name, the headings, and everything in the gig list. */
  .wordmark, .him__name, .him__he, .heading, .subheading,
  h1, h2, h3, .head__title, .gig__venue, .gig__event { color: #000; }
  .eyebrow, .him__dates, .him__from, .gig__date, .gig__city, .gig__note,
  .photo__cap, .photo__credit, .head__sub, .head__meta { color: #444; }

  .gig, .him__para, .photo { page-break-inside: avoid; }
  a { color: #000; text-decoration: underline; }
}

/* --------------------------------------------------------------------------
   22b. TWO PLAIN SENTENCES
   Both of these exist because the site has to admit something, and both are
   deliberately quiet rather than hidden. They are set at the same weight as the
   rest of the small copy, never smaller, because copy you have to squint at is
   not really a disclosure.
   -------------------------------------------------------------------------- */

/* Under the door button. Says that coming in loads SoundCloud, before you tap. */
.door__note {
  margin: 1.4rem auto 0;
  max-width: 34ch;
  font-size: var(--fs-micro);
  line-height: 1.7;
  letter-spacing: 0.04em;
  color: var(--text-3);
  text-wrap: pretty;
}

/* Under his set, when playback has actually failed. Blocked networks, a track
   that stopped being embeddable, a widget that never answered. */
.room__trouble {
  margin: 1.1rem 0 0;
  max-width: 52ch;
  font-size: var(--fs-small);
  line-height: 1.65;
  color: var(--text-2);
  text-wrap: pretty;
}
.room__trouble[hidden] { display: none; }
.room__trouble-link {
  color: var(--amber);
  text-underline-offset: 0.22em;
  text-decoration-thickness: 1px;
}
.room__trouble-link:hover { color: var(--amber-lit); }

/* --------------------------------------------------------------------------
   23. WHAT HE LOVED
   Added for #loved. Nothing above this line is changed by it.

   This room holds other people's music, so the artist who made a track is set
   as large and as bright as the things this site calls a person elsewhere. It
   is never the small print under a title.

   Nothing here draws a box. Tracks from the same label or the same artist sit
   in one list; the only thing marking one group off from the next is space.
   That shows the shape of his taste without anybody having to name it.

   The player is not built until somebody presses play, so .loved__player is
   empty and takes no room until there is a real iframe in it.
   -------------------------------------------------------------------------- */

#loved {
  padding-block: clamp(1rem, 3vh, 2rem) clamp(3rem, 10vh, 6rem);
}

/* A reading room, so the scrim closes over the way it does on the others. That
   now happens in section 3 with the rest of the ramp, by fading the closed
   scrim up rather than by swapping a gradient onto the veil. */

/* The line under a section heading. .copy carries no top margin of its own. */
.loved__what { margin-top: 0.9rem; }

/* One list per group. Inside a group the tracks sit close; between groups the
   space opens up. No rule, no label, no heading: just the gap. */
.loved__list { display: block; margin: 0; padding: 0; }
.loved__list + .loved__list { margin-top: clamp(2rem, 5.5vh, 3.25rem); }

.loved__track { padding: clamp(0.85rem, 2.2vh, 1.15rem) 0; }

.loved__row {
  display: grid;
  grid-template-columns: 44px minmax(0, 1fr);
  column-gap: clamp(0.75rem, 2vw, 1.15rem);
  align-items: start;
}

/* Column two is named explicitly, so a track with no playable link still lines
   up with the ones that have a button. */
.loved__text { grid-column: 2; min-width: 0; }

/* The button is 44px tall and the title's first line is not, so it is lifted to
   sit on the title rather than below it. The touch target keeps its full size. */
.loved__play { margin-top: -0.4rem; }
.loved__track.is-active .loved__play { color: var(--amber); }

.loved__title {
  font-size: var(--fs-h3);
  font-weight: 500;
  line-height: 1.2;
  letter-spacing: -0.012em;
  color: var(--text);
  max-width: var(--measure);
  text-wrap: pretty;
  overflow-wrap: break-word;
  margin: 0;
}

/* The person who made it. Serif, because it is a name, and at --text-2, which
   is the same weight of light this site gives every other person it names. */
.loved__artist {
  font-family: var(--font-voice);
  font-style: italic;
  font-size: var(--fs-sub);
  line-height: 1.35;
  color: var(--text-2);
  max-width: var(--measure);
  text-wrap: pretty;
  overflow-wrap: break-word;
  margin: 0.3rem 0 0;
}

/* The quiet row under a track: the year, the tag it was filed under, and the
   way out to the artist's own page. */
.loved__meta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0 1.15rem;
  margin: 0.4rem 0 0;
}
.loved__meta > * { margin: 0; }

.loved__when {
  font-family: var(--font-num);
  font-variant-numeric: tabular-nums;
  font-size: var(--fs-micro);
  letter-spacing: 0.1em;
  color: var(--text-3);
}

.loved__tag {
  font-size: var(--fs-micro);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-3);
}

/* What he wrote about somebody else's record. It carries .voice as well, so it
   is already the serif and the size this site gives a person talking. All this
   adds is the light down its left edge, which is the same hairline the gigs
   list is hung on. */
.loved__said {
  position: relative;
  padding-left: 1.15rem;
  margin: 1.15rem 0 0;
}
.loved__said::before {
  content: "";
  position: absolute;
  left: 0;
  top: 0.3em;
  bottom: 0.3em;
  width: 1px;
  background-image: linear-gradient(
    to bottom, transparent 0%, var(--amber-deep) 12%, var(--amber-deep) 88%, transparent 100%
  );
  opacity: 0.55;
}

.loved__said-by {
  font-size: var(--fs-micro);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-3);
  padding-left: 1.15rem;
  margin: 0.5rem 0 0;
}

/* Empty until a person asks for it, and it costs no layout while it is. */
.loved__player {
  max-width: 34rem;
  margin: 1.15rem 0 0;
}
.loved__player:empty { display: none; margin: 0; }
.loved__player iframe {
  display: block;
  width: 100%;
  height: 166px;
  border: 0;
  background: var(--ink-1);
}

/* Out to the track, so the artist gets the visit. */
.loved__out {
  position: relative;
  display: inline-flex;
  align-items: center;
  min-height: 36px;
  font-size: var(--fs-micro);
  font-weight: 500;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-2);
  margin: 0;
  transition: color var(--t-1) var(--ease);
}
.loved__out::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 7px;
  height: 1px;
  background: var(--line-2);
  transition: background var(--t-1) var(--ease);
}
.loved__out:hover { color: var(--amber); }
.loved__out:hover::after { background: var(--amber-deep); }

/* On paper there is no player and no link out, only who made what. */
@media print {
  .loved__player, .loved__play, .loved__out { display: none !important; }
  .loved__title, .loved__artist { color: #000; }
  .loved__when, .loved__tag, .loved__said-by { color: #444; }
  .loved__track { page-break-inside: avoid; }
}

/* ==========================================================================
   THE UPLIFT
   Sections 24 to 33. Everything above this line is the build that was measured
   and verified; nothing above it has been softened. What follows is the night,
   the scenes, the plates, and the four new things the room grew.
   ========================================================================== */

/* --------------------------------------------------------------------------
   24. ARRIVING
   One reveal, used by everything. Opacity and a little travel, once, and then
   it stays. Nothing re-animates when you scroll back up: re-triggering makes a
   page feel like a toy, and this is not a toy.

   THE FAILSAFE, WHICH MATTERS MORE THAN THE EFFECT:
   .reveal only hides anything while <html> carries data-reveal="on". The module
   that installs the IntersectionObserver sets that attribute as its first act
   and removes it if anything throws. So if the script never loads, or the
   observer is unavailable, or the view that owns it fails to mount, every
   .reveal renders as plain visible content. There is no path here that leaves a
   grieving person on a blank page waiting for an animation that is never coming.
   -------------------------------------------------------------------------- */

[data-reveal="on"] .reveal {
  opacity: 0;
  transform: translateY(var(--reveal-travel, 16px));
  transition:
    opacity   var(--t-reveal) var(--e-reveal),
    transform var(--t-reveal) var(--e-reveal);
  /* A stagger, for a block of lines that should arrive one after another.
     Capped at six steps so a long list can never leave somebody waiting. */
  transition-delay: calc(min(var(--i, 0), 6) * 90ms);
}
[data-reveal="on"] .reveal.is-in { opacity: 1; transform: none; }

/* 12 to 20px, and never more. Big travel reads as a slideshow. */
.reveal--near { --reveal-travel: 12px; }
.reveal--far  { --reveal-travel: 20px; }

/* --------------------------------------------------------------------------
   25. SCENES
   Full screen, with scroll as the transport between them.

   Snapping is proximity, never mandatory, and scroll-snap-stop is never
   "always". A person can always keep scrolling past anything. The site never
   takes the scroll off them, not once.

   Height is 100svh, the small viewport, and that is deliberate. 100dvh changes
   every time a phone browser hides or shows its toolbar, and a full screen
   section that re-lays-out mid scroll is exactly the unsteady feeling this
   build is not allowed to produce.
   -------------------------------------------------------------------------- */

/* Only arm snapping when the page actually holds scenes. Where :has is not
   supported you simply get ordinary scrolling, which is a perfectly good page. */
@supports selector(:has(*)) {
  html:has(.scenes) { scroll-snap-type: y proximity; }
}

.scenes { display: block; }

/* A view whose content is scenes gives up its own block padding, because each
   scene brings its own. This is the one class a view may add to its own root,
   and .scenes must be that view's first child.

   The negative margin is doing something specific. The chrome is sticky, which
   means it sits IN the flow at the top of the document, so everything after it
   starts 180px down and a full height section can never fit the first screen.
   Pulling the stack back up under it puts the first scene's plate against the
   top of the glass, where a full bleed image belongs, with the header floating
   over it on its own gradient. Each scene then pads its own content clear of
   the header again, so nothing is ever underneath it. */
.view--scenes { padding-block: 0; }
.view--scenes > .scenes:first-child { margin-top: calc(-1 * var(--chrome-h)); }

/* The room is a flex column that pins its content to the bottom of the first
   screen, which is right for the room as it is and wrong for a stack of
   scenes. Opting in unwinds it, so a builder gets one behaviour or the other
   and never a fight between them. */
#room.view--scenes {
  display: block;
  min-height: 0;
  padding-block: 0;
}

/* Section 11 sets padding-block on these three by id, which out-specifies the
   .view--scenes class rule above no matter where it sits in the file. Without
   this the negative margin lands the first plate 16 to 32px below the glass
   instead of against it, and every snap point inherits the offset. */
#wall.view--scenes,
#him.view--scenes,
#give.view--scenes { padding-block: 0; }

.scene {
  position: relative;
  display: grid;
  align-items: center;
  min-height: 100vh;
  min-height: 100svh;
  padding-block:
    calc(var(--chrome-h) + 1rem)
    calc(var(--transport-h) + var(--safe-b) + 1.5rem);
  isolation: isolate;
  scroll-snap-align: start;
  scroll-snap-stop: normal;
  /* html carries scroll-padding-top so the browser never drops a focused
     control under the header. That same padding would offset every snap point
     by the height of the header and leave a strip of the last scene on screen.
     Cancelling it here applies to snapping only.

     min-height, not height, and nothing clips: if somebody's phone, or their
     text size, makes a scene taller than the screen, the scene grows and they
     scroll. A full screen section is a composition, not a cage. */
  scroll-margin-top: calc(-1 * (var(--chrome-h) + 1rem));
}

/* The content of a scene. It draws the same rail as .shell, at the same left
   edge, so a scene lines up with the chrome and the transport like everything
   else. Inside a scene use this, not .shell. */
.scene__body {
  position: relative;
  z-index: 2;
  width: 100%;
  padding-inline: var(--gutter-l) var(--gutter-r);
}

/* The local scrim. It follows the type instead of guessing where the type will
   be, which is the only honest way to promise contrast over a photograph.
   Feathered for 7rem above the block, then held at 0.78 or more for the whole
   height of the text. See section 26 for the arithmetic 0.78 comes out of.

   It ends where the text ends, fading out over the last 2rem. An earlier
   version ran it 50vh past the bottom to be safe, and because a scene does not
   clip its overflow, that put a dark wash over whatever came next on the page.
   A scrim is allowed to protect its own type and nothing else. */
.scene__body::before {
  content: "";
  position: absolute;
  left: 0; right: 0;
  top: -7rem;
  bottom: -2rem;
  z-index: -1;
  pointer-events: none;
  opacity: 0;
  background-image: linear-gradient(
    to bottom,
    rgba(10, 9, 8, 0)    0,
    rgba(10, 9, 8, 0.55) 4.5rem,
    rgba(10, 9, 8, 0.78) 7rem,
    rgba(10, 9, 8, 0.88) calc(100% - 2rem),
    rgba(10, 9, 8, 0)    100%
  );
}
.scene--held .scene__body::before,
.scene--plate .scene__body::before { opacity: 1; }

/* Belt and braces: any scene holding a plate gets the local scrim, whatever
   modifier the builder reached for. */
@supports selector(:has(*)) {
  .scene:has(.plate) .scene__body::before { opacity: 1; }
}

/* Type held low over an image. The most used scene on the site. */
.scene--held { align-items: end; }

/* Type only. No image, nothing behind it but the night. */
.scene--type { align-items: center; }
.scene--type .scene__body { padding-block: 0; }

/* A full bleed plate with almost nothing on it. Atmosphere, and a line. */
.scene--plate { align-items: end; }

/* The quiet gap between scenes. Nothing in it, and not a snap point, because
   the gap is the pause and you should be able to fall through it. */
.scene--gap {
  min-height: 30vh;
  min-height: 30svh;
  padding-block: 0;
  scroll-snap-align: none;
}

/* --------------------------------------------------------------------------
   26. PLATES
   The six generated images. All abstract, all atmosphere, none of them a
   photograph of anybody. They are never the subject. They sit far back, under
   type, and the grade is built here rather than baked into the files so that
   all six read as one body of work whatever they came out of.

   THE TREATMENT
   heavy grain, crushed warm blacks, low contrast, desaturated except the amber.
   brightness pulls the highlights down, contrast above 1 crushes the shadows to
   black rather than lifting them, sepia puts the warmth back, saturate takes
   the rest of the colour out.

   THE CONTRAST GUARANTEE, which is the reason for every ceiling below.
   Measured, not assumed: the six plates were decoded and their per band
   luminance measured before any of these numbers were chosen. The brightest
   real pixel in the set is 0.948 relative luminance, in the dust beam. The
   worst case here is not that pixel though, it is a hypothetical pure white
   one, and the chain still holds for it:

     input          255
     brightness     x 0.68 ceiling            -> 173
     contrast 1.14  crushed around mid grey   -> 180
     opacity        x 0.45 ceiling over the
                    page, which the verified
                    build already holds at
                    43 or under                -> 105
     grain          0.14 of mid grey           -> 108
     scrim          0.78 ink under the type    ->  39
     luminance      0.0207
     --text-3       4.75:1     the dimmest text on the site
     --amber-deep   5.4:1
     --text-2       8.4:1
     --text         12.6:1

   So no plate, at any pixel, can put text under 4.5:1. Both ceilings are
   clamped in the CSS below rather than left to whoever picks the numbers.
   -------------------------------------------------------------------------- */

.plate {
  position: absolute;
  inset: 0;
  z-index: 0;
  overflow: hidden;
  pointer-events: none;
  /* Never a subject, never a competitor to something a person wrote. */
  contain: paint;
}

.plate__img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: var(--plate-pos, 50% 50%);
  opacity: clamp(0, var(--plate-o, 0.34), 0.45);
  filter:
    saturate(0.46)
    sepia(0.22)
    brightness(clamp(0, var(--plate-b, 0.58), 0.68))
    contrast(1.14);
  /* Slightly over size, so the drift below never shows an edge. */
  transform: scale(1.045);
  will-change: transform;
  animation: plate-drift 52s var(--e-soft) infinite alternate;
}

/* Not parallax. Nothing here is tied to the scroll, because a plate that moves
   when you move is the thing that makes a grieving person feel unsteady. It is
   one slow breath across the better part of a minute, and at 1.5 percent you
   will never catch it happening. */
@keyframes plate-drift {
  from { transform: scale(1.045) translate3d(-0.75%, 0.4%, 0); }
  to   { transform: scale(1.045) translate3d(0.75%, -0.4%, 0); }
}

/* Grain, over the image and under the scrim, so the scrim crushes the grain
   too and it can never lift the floor under somebody's text. Generated, not
   fetched: it is an inline SVG turbulence, so it costs no request. */
.plate::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  opacity: 0.14;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23g)'/%3E%3C/svg%3E");
}

/* The plate's own scrim. The type gets a second one of its own in section 25;
   this one is here so a plate never reads as a bright rectangle in the page. */
.plate::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  background-image:
    linear-gradient(
      to bottom,
      rgba(10, 9, 8, 0.34) 0%,
      rgba(10, 9, 8, 0.20) 24%,
      rgba(10, 9, 8, 0.44) 54%,
      rgba(10, 9, 8, 0.74) 76%,
      rgba(10, 9, 8, 0.92) 100%
    ),
    radial-gradient(
      120% 90% at 50% 42%,
      rgba(10, 9, 8, 0) 30%,
      rgba(10, 9, 8, 0.55) 100%
    );
}

/* The six, graded. The exposure on each one is set from its own measured
   histogram so that the smoke, the bush and the dawn sit at the same distance
   back from the reader, which is what makes them read as one body of work
   rather than six pictures.

     plate        measured p95   measured max   b      o
     smoke        0.154          0.841          0.58   0.42
     bush night   0.019          0.478          0.68   0.45
     dust beam    0.476          0.948          0.42   0.26
     ground       0.078          0.500          0.60   0.40
     shade sail   0.527          0.871          0.44   0.34
     dawn         0.546          0.674          0.50   0.44                  */

.plate--smoke  { --plate-b: 0.58; --plate-o: 0.42; }
.plate--bush   { --plate-b: 0.68; --plate-o: 0.45; --plate-pos: 50% 62%; }
.plate--dust   { --plate-b: 0.42; --plate-o: 0.26; }
.plate--ground { --plate-b: 0.60; --plate-o: 0.40; --plate-pos: 50% 70%; }
.plate--sail   { --plate-b: 0.44; --plate-o: 0.34; --plate-pos: 50% 40%; }
/* Dawn is lifted to the ceiling of both clamps, and it is the only one that is.
   Him is the one room approaching light and this plate is what carries that, so
   it is allowed to be the most present image on the site. It is still under a
   scrim that holds the dimmest text above 5.8:1, measured. */
.plate--dawn   { --plate-b: 0.64; --plate-o: 0.45; --plate-pos: 50% 34%; }

/* A plate can also sit as a texture over a whole room rather than inside one
   scene. Fixed, so it does not travel with the scroll, and quieter again. */
.plate--field {
  position: fixed;
  z-index: 0;
  --plate-o: 0.16;
}

/* A plate that has not decoded yet is nothing at all, not a grey box. The fade
   is opt in, the other way round from .photo__img: set data-loaded="false" when
   you build the node and drop it in the load handler. A builder who forgets
   gets a plate that simply appears, rather than one that never does. Atmosphere
   is never allowed to be the thing that breaks. */
.plate__img { transition: opacity var(--t-reveal) var(--e-soft); }
.plate__img[data-loaded="false"] { opacity: 0; }

/* --------------------------------------------------------------------------
   27. TYPE AS IMAGE
   Type carries this site. These four settings are the ones that are allowed to
   be the whole screen.
   -------------------------------------------------------------------------- */

/* A section opener. "the wall", "him", "what he loved" arrive as a held moment
   before their content, not as a small heading above a list. */
.opener { max-width: 20ch; }
.opener__word {
  font-family: var(--font-ui);
  font-size: var(--fs-opener);
  font-weight: 600;
  line-height: 0.92;
  letter-spacing: -0.045em;
  color: var(--text);
  text-wrap: balance;
  margin: 0 -0.045em 0 0;
}
.opener__sub {
  font-family: var(--font-voice);
  font-style: italic;
  font-size: var(--fs-sub);
  line-height: 1.42;
  color: var(--text-2);
  max-width: 34ch;
  margin: clamp(1.25rem, 4vh, 2rem) 0 0;
}
.opener__meta {
  font-size: var(--fs-micro);
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--text-3);
  margin: clamp(1rem, 3vh, 1.5rem) -0.22em 0 0;
}

/* His own words. The four paragraphs he wrote about himself, on his own
   profile, while he was alive. They are the only thing on this site he wrote,
   so they get the most generous setting in the build: one paragraph to a
   screen, at a size nothing else uses, with the leading opened right out.

   Never rewritten, never joined together, never corrected. */
.words { max-width: 46rem; }
.words__para {
  font-family: var(--font-voice);
  font-size: var(--fs-words);
  font-weight: 400;
  line-height: 1.46;
  letter-spacing: 0.004em;
  color: var(--text);
  /* Generous is the size and the leading, not a narrow column. His longest
     paragraph is 244 characters; at 26ch it ran to fifteen lines and stopped
     being one paragraph on one screen, which is the whole idea. */
  max-width: 34ch;
  text-wrap: pretty;
  overflow-wrap: break-word;
  white-space: pre-wrap;
  margin: 0;
}
.words--tight .words__para { max-width: 26ch; }
.words__by {
  font-size: var(--fs-micro);
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--text-3);
  margin: clamp(1.5rem, 5vh, 2.5rem) -0.22em 0 0;
}
@media (min-width: 46em) {
  .words { max-width: 54rem; }
  .words__para { max-width: 44ch; line-height: 1.4; }
  .words--tight .words__para { max-width: 32ch; }
}

/* THE LOW POINT.

   One line, alone, full width, on its own dark field, with nothing else on the
   screen. It is his, verbatim, off his own upload, missing apostrophe and all.
   The field is nearly solid because for this one screen the room should stop.

   Whatever is set here comes from content and from nowhere else. If the line is
   not in content, this block does not exist. */
.lowpoint {
  position: relative;
  display: grid;
  align-content: center;
  min-height: 100vh;
  min-height: 100svh;
  padding-block:
    calc(var(--chrome-h) + 1rem)
    calc(var(--transport-h) + var(--safe-b) + 1.5rem);
  padding-inline: var(--gutter-l) var(--gutter-r);
  scroll-snap-align: start;
  scroll-margin-top: calc(-1 * (var(--chrome-h) + 1rem));
  isolation: isolate;
}
.lowpoint::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  background-image:
    radial-gradient(120% 80% at 50% 50%, rgba(6, 5, 4, 0.80), rgba(6, 5, 4, 0.97) 78%),
    linear-gradient(rgba(6, 5, 4, 0.86), rgba(6, 5, 4, 0.86));
}
/* pre-wrap, not because it looks better, because he pressed return there.
   He wrote two sentences on two lines. Collapsing that break is the same
   class of edit as fixing his missing apostrophe, and we do not do that.
   text-wrap: pretty is deliberately absent: a balancer would fight the break
   he actually typed for control of where the line ends. */
.lowpoint__line {
  font-family: var(--font-voice);
  font-size: var(--fs-low);
  font-weight: 400;
  line-height: 1.22;
  letter-spacing: -0.012em;
  color: var(--text);
  max-width: 30ch;
  white-space: pre-wrap;
  overflow-wrap: break-word;
  margin: 0;
}
.lowpoint__by {
  font-size: var(--fs-micro);
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--text-3);
  margin: clamp(2rem, 7vh, 3.5rem) -0.22em 0 0;
}

/* --------------------------------------------------------------------------
   28. THE NIGHT MAP
   The gigs, as a slow constellation. Melbourne clustered, Far North Queensland
   far off on its own, and two thousand kilometres of nothing in between. It
   reads as "he was everywhere" without ever pretending to be cartography:
   no borders, no coastline, no scale.

   THE RULE THAT GOVERNS IT, and it is a truth rule, not a design rule:
   a light may only be placed for a place that is written in content. He listed
   Melbourne and he listed Far North Queensland, so those exist. Everything else
   he wrote down as a name with no place, and a name with no place gets no
   position, because putting it somewhere on a map of Australia would be
   inventing a fact about where a dead man played. Those entries live in the
   list underneath, which is the honest place for them.
   -------------------------------------------------------------------------- */

.nightmap { margin: 0; }

.nightmap__field {
  position: relative;
  width: 100%;
  max-width: 46rem;
  aspect-ratio: 5 / 4;
  margin: 0 0 clamp(2rem, 6vh, 3rem);
}

/* The country as an absence: a faint warmth low and right, where he mostly was,
   falling away to nothing. No outline, ever.

   It lives on its own layer with a mask, because a radial gradient large enough
   to read gets clipped by the edge of its own box, and a clipped glow draws a
   straight vertical line down the page. A straight line here would look like a
   border on a map, which is the one thing this must never be. The lights sit
   above it, unmasked, so nothing dims them. */
.nightmap__field::before {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background-image:
    radial-gradient(58% 44% at 72% 78%, rgba(201, 138, 75, 0.075), rgba(201, 138, 75, 0) 100%),
    radial-gradient(38% 30% at 78% 16%, rgba(201, 138, 75, 0.045), rgba(201, 138, 75, 0) 100%);
  -webkit-mask-image: radial-gradient(closest-side at 60% 56%, #000 24%, transparent 100%);
  mask-image: radial-gradient(closest-side at 60% 56%, #000 24%, transparent 100%);
}

/* One light. Placed with --x and --y, both percentages. */
.nightmap__light {
  position: absolute;
  left: var(--x, 50%);
  top: var(--y, 50%);
  width: 5px;
  height: 5px;
  margin: -2.5px 0 0 -2.5px;
  border-radius: 50%;
  background: var(--amber);
  opacity: 0.62;
}
/* The halo breathes, not the dot. Same reason as .timeline__light: the dot's
   opacity is carrying .is-lit, and an infinite animation would sit on top of
   it. The glow is the part you actually read as a fire anyway. */
.nightmap__light::after {
  content: "";
  position: absolute;
  inset: -11px;
  border-radius: 50%;
  background: radial-gradient(50% 50% at 50% 50%, var(--glow), rgba(224, 168, 96, 0) 70%);
  opacity: 1;
  animation: light-breathe 7400ms var(--e-soft) infinite;
  animation-delay: calc(var(--i, 0) * 640ms);
}
.nightmap__light.is-lit { opacity: 1; transform: scale(1.5); }

/* The name of a place he actually named. Two of these, at most.

   Any light past the middle of the field carries --left, so its name is set
   back toward the centre instead of off the edge of a phone. It wraps rather
   than running off, so getting that wrong costs a second line and never a name
   somebody cannot read. */
.nightmap__place {
  position: absolute;
  left: var(--x, 50%);
  top: var(--y, 50%);
  transform: translate(0.9rem, -50%);
  max-width: min(11rem, 42vw);
  font-size: var(--fs-micro);
  line-height: 1.4;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--text-3);
  margin: 0;
}
.nightmap__place--left {
  transform: translate(calc(-100% - 0.9rem), -50%);
  text-align: right;
}

.nightmap__count {
  font-family: var(--font-num);
  font-variant-numeric: tabular-nums;
  color: var(--amber-deep);
  margin-left: 0.6em;
}

/* The gig list under the map keeps its own classes, from section 15. Pointing
   at a name lights its light, which is the whole interaction and all it needs
   to be. */
.gig.is-lit .gig__venue { color: var(--amber-lit); }

/* --------------------------------------------------------------------------
   29. THE SET HORIZON
   The timeline in the room, grown into a full width horizon with the memories
   sitting on it like fires in a field, and the playhead a slow glow travelling
   along it.

   It is a picture, not a control. The one thing on this site that seeks is
   .timeline in the transport, because two sliders for one set is two ways to
   get it wrong. The fires are buttons, because a fire is a person.
   -------------------------------------------------------------------------- */

.horizon {
  position: relative;
  width: 100%;
  height: clamp(6.5rem, 15vh, 10rem);
  margin: 0;
  overflow: hidden;
  pointer-events: none;
}

/* The ground under the line. Warm, and darker as it comes toward you. */
.horizon__ground {
  position: absolute;
  left: 0; right: 0;
  top: 62%;
  bottom: 0;
  background-image: linear-gradient(
    to bottom, rgba(20, 15, 10, 0.55), rgba(10, 9, 8, 0) 90%
  );
}

/* The horizon itself. A hairline of light, fading out at both ends so it never
   reads as a rule with two hard stops in it. */
.horizon__line {
  position: absolute;
  left: 0; right: 0;
  top: 62%;
  height: 1px;
  background-image: linear-gradient(
    90deg, transparent 0%, var(--line-2) 10%, var(--line-2) 90%, transparent 100%
  );
}

/* How far through his set. The lit part of the horizon, behind the glow. */
/* Same rule as the transport bar: scaleX, not width. See .timeline__played. */
.horizon__played {
  position: absolute;
  left: 0;
  right: 0;
  top: 62%;
  height: 1px;
  transform: scaleX(var(--played-n, 0));
  transform-origin: left center;
  background-image: linear-gradient(90deg, rgba(201, 138, 75, 0.15), var(--amber));
}

/* The playhead. A glow travelling the horizon, not a marker sitting on it.

   A 224px radial gradient moved with `left` repaints a 224px square every
   frame, over the top of a canvas that is already painting. So the glow is a
   full width rail that translates, with the light itself in its ::after. */
.horizon__glow {
  position: absolute;
  left: 0;
  right: 0;
  top: 62%;
  height: 0;
  transform: translateX(calc(var(--played-n, 0) * 100%));
  pointer-events: none;
}
.horizon__glow::after {
  content: "";
  position: absolute;
  left: 0;
  top: 0;
  width: 14rem;
  height: 14rem;
  margin: -7rem 0 0 -7rem;
  border-radius: 50%;
  background: radial-gradient(
    50% 50% at 50% 50%,
    rgba(240, 190, 124, 0.20) 0%,
    rgba(224, 168, 96, 0.07) 42%,
    rgba(224, 168, 96, 0) 72%
  );
}

/* A memory, sitting at its moment, like a fire in a field. */
.horizon__fire {
  position: absolute;
  left: var(--at, 0%);
  top: 62%;
  width: 34px;
  height: 44px;
  margin: -30px 0 0 -17px;
  padding: 0;
  display: grid;
  place-items: end center;
  pointer-events: auto;
  cursor: pointer;
}
.horizon__fire::before {
  content: "";
  width: 5px;
  height: 5px;
  margin-bottom: 12px;
  border-radius: 50%;
  background: var(--amber);
  opacity: 0.55;
  transition: opacity var(--t-room) var(--e-room), transform var(--t-room) var(--e-room);
}
.horizon__fire::after {
  content: "";
  position: absolute;
  left: 50%;
  bottom: 6px;
  width: 3.5rem;
  height: 3.5rem;
  margin-left: -1.75rem;
  border-radius: 50%;
  background: radial-gradient(50% 50% at 50% 70%, var(--glow), rgba(224, 168, 96, 0) 70%);
  opacity: 0.7;
  transform: scale(1);
  transition: transform var(--t-room) var(--e-room);
  animation: fire-breathe 6400ms var(--e-soft) infinite;
  animation-delay: calc(var(--i, 0) * 900ms);
}
/* Opacity is spoken for by the breath here, so the fire answers a touch by
   flaring wider instead of brighter. Transform, so it costs nothing. */
@keyframes fire-breathe {
  0%, 100% { opacity: 0.58; }
  50%      { opacity: 0.84; }
}
.horizon__fire.is-passed::before { opacity: 0.85; }
.horizon__fire.is-active::before { opacity: 1; transform: scale(1.5); }
.horizon__fire.is-active::after,
.horizon__fire:hover::after,
.horizon__fire:focus-visible::after { transform: scale(1.34); }

/* A track boundary. A faint mark on the horizon where the mix changed. */
.horizon__mark {
  position: absolute;
  left: var(--at, 0%);
  top: 62%;
  width: 1px;
  height: 0.9rem;
  margin-top: -0.9rem;
  background-image: linear-gradient(to bottom, rgba(237, 231, 222, 0) 0%, var(--line-2) 100%);
}

.horizon__label {
  position: absolute;
  left: 0;
  top: 0;
  font-size: var(--fs-micro);
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--text-3);
  margin: 0;
}

/* --------------------------------------------------------------------------
   30. THE TRACKLIST
   What he played, and when. TRACKLIST-SPEC section 5.

   ID is not an error state and it must never look like one. In this scene an
   unnamed track is the most ordinary thing in the world and asking is how you
   join in, so it is set as a proper piece of typography: the tabular face, wide
   tracking, amber, over a short rule like a blank somebody is going to fill in.
   The word is what a raver would write on a comment at 2am, and it is set the
   way they would write it.

   Nothing here appears until there is something to say. If no set has a
   tracklist, none of these classes are ever emitted and the room is unchanged.
   -------------------------------------------------------------------------- */

/* What is playing right now, beside the elapsed time, in the room. */
.nowtrack {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.35rem 1rem;
  margin: 0;
}
.nowtrack__at {
  font-family: var(--font-num);
  font-variant-numeric: tabular-nums;
  font-size: var(--fs-micro);
  letter-spacing: 0.02em;
  color: var(--amber);
  flex: none;
  margin: 0;
}
.nowtrack__title {
  font-size: var(--fs-small);
  font-weight: 500;
  letter-spacing: 0.01em;
  color: var(--text);
  margin: 0;
}
.nowtrack__artist {
  font-family: var(--font-voice);
  font-style: italic;
  font-size: var(--fs-small);
  color: var(--text-2);
  margin: 0;
}

/* The whole tracklist, under the set, readable top to bottom. */
.tracklist {
  display: block;
  margin: 0;
  padding: 0;
  counter-reset: track;
}

.track {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  column-gap: clamp(0.9rem, 3vw, 1.6rem);
  align-items: baseline;
  padding: clamp(0.7rem, 2vh, 1rem) 0;
}
.track + .track {
  background-image: linear-gradient(
    90deg, transparent 0%, var(--line-1) 8%, var(--line-1) 72%, transparent 100%
  );
  background-repeat: no-repeat;
  background-size: 100% 1px;
  background-position: 0 0;
}

/* The timestamp. Click it and you are standing there. */
.track__at {
  grid-column: 1;
  display: inline-flex;
  align-items: baseline;
  min-height: 32px;
  padding: 0;
  font-family: var(--font-num);
  font-variant-numeric: tabular-nums;
  font-size: var(--fs-micro);
  letter-spacing: 0.02em;
  color: var(--text-3);
  transition: color var(--t-micro) var(--e-micro);
}
.track__at:hover { color: var(--amber); }

.track__text { grid-column: 2; min-width: 0; }

.track__title {
  font-size: var(--fs-body);
  font-weight: 500;
  line-height: 1.3;
  color: var(--text);
  text-wrap: pretty;
  overflow-wrap: break-word;
  margin: 0;
}
.track__artist {
  font-family: var(--font-voice);
  font-style: italic;
  font-size: var(--fs-small);
  line-height: 1.4;
  color: var(--text-2);
  margin: 0.15rem 0 0;
}

/* ID. Honest, and in this culture a beautiful answer. */
.track__id {
  display: inline-block;
  font-family: var(--font-num);
  font-size: var(--fs-small);
  font-weight: 500;
  letter-spacing: 0.34em;
  text-transform: uppercase;
  color: var(--amber-deep);
  background-image: linear-gradient(90deg, var(--amber-deep), var(--amber-deep));
  background-repeat: no-repeat;
  background-size: 3.2em 1px;
  background-position: 0 100%;
  padding-bottom: 0.4em;
  margin: 0;
  opacity: 0.85;
}

/* Where a name came from. A fingerprint and somebody who was standing there are
   different kinds of fact and this site never blurs them. */
.track__source {
  font-size: var(--fs-micro);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-3);
  margin: 0.35rem 0 0;
}
/* Under 0.7 confidence it is a suggestion, not a fact, and it says so. */
.track--suggested .track__title { color: var(--text-2); }
.track--suggested .track__source { color: var(--amber-deep); }

.track__say {
  min-height: 32px;
  padding: 0;
  font-size: var(--fs-micro);
  letter-spacing: 0.12em;
  color: var(--text-2);
  transition: color var(--t-micro) var(--e-micro);
}
.track__say:hover { color: var(--amber); }

.track__votes {
  font-family: var(--font-num);
  font-variant-numeric: tabular-nums;
  font-size: var(--fs-micro);
  color: var(--text-3);
  margin: 0;
}

/* Nothing in the room may sit at --text-3 any more.

   The open scrim on html::before thins to 0.22 at 36vh, which is deliberate:
   that is the height of the shader's own light source and the picture has to
   be visible through it. Section 11's contract made that safe by pinning the
   room to justify-content: flex-end, so every word in it sat below 60vh where
   the scrim is 0.38 to 0.82 and the dimmest text still clears 4.5:1.

   The tracklist broke that contract. #room is now an arbitrarily long
   scrolling column, and its rows travel straight through the thin band, over a
   field running at full exposure, on the way past. So the three dimmest things
   in it come up a step. Specificity, not source order, because .track__at and
   the rest are declared later in this file. */
#room .eyebrow,
#room .room__meta,
#room .track__at,
#room .track__source,
#room .track__votes { color: var(--text-2); }

.track__foot {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.25rem 1.15rem;
  margin: 0.45rem 0 0;
}
.track__foot > * { margin: 0; }

.track.is-playing .track__title { color: var(--amber-lit); }
.track.is-playing .track__at { color: var(--amber); }

/* --------------------------------------------------------------------------
   31. MEMORY LIGHTS
   On the wall, memories arrive as points of light that resolve into text as
   they enter, like people coming into focus across a dark room. The light does
   not leave when the text arrives; it settles, and stays as the mark that
   somebody is there.
   -------------------------------------------------------------------------- */

.lights .memory { position: relative; }

.memory__light {
  position: absolute;
  left: -1.15rem;
  top: clamp(2.5rem, 6.6vh, 3.6rem);
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--amber);
  opacity: 0.9;
  transition: opacity var(--t-mem-in) var(--e-reveal), transform var(--t-mem-in) var(--e-reveal);
}
.memory__light::after {
  content: "";
  position: absolute;
  inset: -12px;
  border-radius: 50%;
  background: radial-gradient(50% 50% at 50% 50%, var(--glow), rgba(224, 168, 96, 0) 70%);
}
/* The row itself never fades, only what is written on it. The light has to be
   there BEFORE the words are, or there is nothing to resolve out of: a light in
   a dark room that turns into a person as they come toward you. Fading the
   whole list item, light included, gives you an empty page that fills in, which
   is a different and much worse idea.

   The builder's contract does not change. Put .reveal on the .memory and add
   .is-in when it enters, exactly as everywhere else. */
[data-reveal="on"] .lights .memory.reveal { opacity: 1; transform: none; }

[data-reveal="on"] .lights .memory.reveal .memory__body,
[data-reveal="on"] .lights .memory.reveal .memory__foot {
  opacity: 0;
  transform: translateY(var(--reveal-travel, 16px));
  transition:
    opacity   var(--t-mem-in) var(--e-reveal),
    transform var(--t-mem-in) var(--e-reveal);
}
[data-reveal="on"] .lights .memory.reveal .memory__foot { transition-delay: 180ms; }
[data-reveal="on"] .lights .memory.reveal.is-in .memory__body,
[data-reveal="on"] .lights .memory.reveal.is-in .memory__foot { opacity: 1; transform: none; }

[data-reveal="on"] .lights .memory.reveal .memory__light { opacity: 1; transform: scale(1.35); }
[data-reveal="on"] .lights .memory.reveal.is-in .memory__light { opacity: 0.5; transform: none; }

/* The rail the lights hang on needs room at 46em and up, where there is room.
   On a phone the light sits inside the text block's own left edge instead of
   pushing the measure around. */
@media (max-width: 45.99em) {
  .memory__light { left: 0; top: clamp(1.1rem, 3vh, 1.5rem); }
  .lights .memory__body { padding-top: 0.9rem; }
}

/* --------------------------------------------------------------------------
   32. REDUCED MOTION, SECOND PASS
   The first pass, in section 20, still stands. This covers everything the
   uplift added. What a person who asks for less motion gets here is not a
   degraded page: it is the same page, still, composed, and with the light in
   the right place.
   -------------------------------------------------------------------------- */

@media (prefers-reduced-motion: reduce) {
  /* The page never adjusts itself under somebody. */
  html:has(.scenes) { scroll-snap-type: none; }
  .scene, .lowpoint { scroll-snap-align: none; }

  /* Everything that arrives is simply here. */
  [data-reveal="on"] .reveal,
  [data-reveal="on"] .reveal.is-in { opacity: 1; transform: none; }
  .view:not(.view--door) { animation: none; opacity: 1; transform: none; }

  /* The door is composed on arrival rather than choreographed into place. */
  body[data-state="closed"] .wordmark,
  body[data-state="closed"] .wordmark__he,
  body[data-state="closed"] .door__dates,
  body[data-state="closed"] .door__enter,
  body[data-state="closed"] .door__note,
  body[data-state="closed"] .door__presence { animation: none; opacity: 1; transform: none; }
  .door__inner::before { animation: none; }

  /* Held frames, not stopped animations. */
  .plate__img { animation: none; transform: scale(1.045); }
  /* The breath moved onto the halos, so the animation has to be stopped where
     it actually is. The dot keeps its own state opacity either way. */
  .nightmap__light { opacity: 0.72; }
  .nightmap__light::after { animation: none; opacity: 1; }
  .timeline__light::after { animation: none; opacity: 1; }
  .horizon__fire::after { animation: none; opacity: 0.8; }
  .memory__light { opacity: 0.5; transform: none; }
  [data-reveal="on"] .lights .memory.reveal .memory__light { opacity: 0.5; transform: none; }
  [data-reveal="on"] .lights .memory.reveal .memory__body,
  [data-reveal="on"] .lights .memory.reveal .memory__foot { opacity: 1; transform: none; }

  /* The colour still moves between rooms, because that is the light and not an
     animation, but it moves in one step rather than over twelve seconds. */
  html::before, body::before, body::after { transition-duration: 1ms !important; }
}

/* --------------------------------------------------------------------------
   33. PRINT, SECOND PASS
   Everything the uplift added, on paper. The plates are atmosphere and
   atmosphere does not print, but every word does.
   -------------------------------------------------------------------------- */

@media print {
  .plate, .horizon, .nightmap__field, .scene__body::before,
  .lowpoint::before, .memory__light { display: none !important; }

  .scene, .lowpoint {
    min-height: 0 !important;
    padding: 0 0 1.5rem !important;
    page-break-inside: avoid;
  }
  .scene--gap { display: none !important; }

  .opener__word, .words__para, .lowpoint__line, .track__title, .nowtrack__title {
    color: #000;
  }
  .opener__sub, .words__by, .lowpoint__by, .opener__meta,
  .track__artist, .track__source, .track__votes, .nightmap__place { color: #444; }
  .track__id { color: #000; background-image: none; }
  .track__say, .track__at { display: none !important; }

  .lowpoint__line { font-size: 18pt; line-height: 1.3; }
  .words__para { font-size: 13pt; }
  .track { page-break-inside: avoid; }
}

/* --------------------------------------------------------------------------
   32. LAST WORD
   Rules that exist only because they have to be declared last.

   Everything here is an override of something declared earlier at the same
   specificity. Media queries and @supports add none, so for these the only
   thing that decides the winner is source order. Nothing new belongs in this
   section. If you are adding a rule, add it in its own section above and only
   move it here when you have proved it loses.
   -------------------------------------------------------------------------- */

/* Short landscape phones, continued from section 21.

   A 390px-tall phone on its side has 152px of chrome and transport. Left to
   the section 25 rules, every scene is pinned to min-height 100svh with the
   full padding, which leaves about 46px of content box, and snapping stays
   armed with a snap point on each one. A person cannot read a paragraph
   through a 46px slot and cannot scroll out of it smoothly either. So on a
   short screen the scenes stop being screens: they become as tall as their
   content and the page scrolls normally. */
@media (max-height: 34em) and (orientation: landscape) {
  .scene, .lowpoint {
    min-height: 0;
    padding-block: calc(var(--chrome-h) + 0.5rem) calc(var(--transport-h) + var(--safe-b));
    scroll-snap-align: none;
  }
  .scene--gap { min-height: 4rem; }
  html:has(.scenes) { scroll-snap-type: none; }
}
