/* ==========================================================================
   ewolution.cloud
   A typographic index. Four projects, no chrome, nothing that isn't earning
   its place. Display type is Fraunces (variable: opsz/wght/SOFT/WONK) — the
   axes are animated on interaction, which is the whole idea: the type
   evolves as you engage with it.
   ========================================================================== */

/* --- Fonts (self-hosted; no third-party requests) ------------------------ */

@font-face {
  font-family: 'Fraunces';
  src: url('/fonts/fraunces-var-latin.woff2') format('woff2-variations');
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: 'Outfit';
  src: url('/fonts/outfit-var-latin.woff2') format('woff2-variations');
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
}

/* --- Tokens -------------------------------------------------------------- */

:root {
  --ink:        #07080b;
  --ink-raised: #0c0e14;
  --paper:      #f4f2ee;
  --paper-dim:  #a7a49e;
  --paper-mute: #6a6862;
  --hairline:   rgba(244, 242, 238, 0.11);
  --hairline-2: rgba(244, 242, 238, 0.06);

  --serif: 'Fraunces', 'Iowan Old Style', Georgia, serif;
  --sans:  'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;

  --gutter: clamp(1.25rem, 5vw, 5.5rem);
  --measure: 34ch;

  --ease: cubic-bezier(0.22, 1, 0.36, 1);
  --ease-io: cubic-bezier(0.65, 0, 0.35, 1);
  --dur: 0.7s;
}

/* --- Runtime state ---------------------------------------------------------

   main.js binds prop-for-that sources, which write `--live-` and `--const-`
   custom properties. Everything below derives page-level signals from them;
   the rest of the stylesheet consumes those, never the raw sources.

   These three are registered with @property on purpose. An *unregistered*
   custom property holding a calc() computes to its token stream rather than a
   number, so `@container style(--power-save: 1)` would compare against the
   literal text and never match. Registering with syntax '<number>' makes them
   compute to real values, which is what style queries actually compare.
   Where round() is unsupported the declaration is simply invalid and the
   initial-value 0 stands — the page just never enters power-save. */

@property --low-power  { syntax: '<number>'; initial-value: 0; inherits: true; }
@property --strain     { syntax: '<number>'; initial-value: 0; inherits: true; }
@property --power-save { syntax: '<number>'; initial-value: 0; inherits: true; }

:root {
  /* Signed scroll speed, clamped so a violent flick can't tear the layout. */
  --vel: clamp(-42, var(--live-scroll-velocity, 0), 42);

  /* Device tilt in degrees. gamma (left/right) rests at 0; beta (front/back)
     rests near 45 when a phone is held at a normal reading angle, so it is
     offset to make "however you were already holding it" the neutral position. */
  --tilt-x: clamp(-26, var(--live-orient-gamma, 0), 26);
  --tilt-y: clamp(-22, calc(var(--live-orient-beta, 45) - 45), 22);

  /* Under 20% battery and not charging. round(up, …) turns any positive
     remainder into exactly 1 — a threshold without a JS branch. */
  --low-power: clamp(0, round(up, calc(
      (0.2 - var(--live-battery-level, 1)) * (1 - var(--live-battery-charging, 0))
    ), 1), 1);

  /* Compute Pressure tiers: nominal 0, fair 1, serious 2, critical 3.
     Serious and above counts as strain. */
  --strain: clamp(0, round(up, calc((var(--live-cpu-pressure, 0) - 1) / 3), 1), 1);

  --power-save: max(var(--low-power), var(--strain));
}

/* Escape hatch for testing the state on hardware that will never report it —
   Battery and Compute Pressure are both Chromium-only. main.js sets this from
   ?power=save. */
:root.force-power-save { --power-save: 1; }

/* --- Reset --------------------------------------------------------------- */

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

html {
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;
}

body {
  margin: 0;
  background: var(--ink);
  color: var(--paper);
  font-family: var(--sans);
  font-size: clamp(1rem, 0.95rem + 0.25vw, 1.125rem);
  line-height: 1.55;
  font-synthesis-weight: none;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  overflow-x: hidden;
}

h1, h2, p, ul, figure { margin: 0; }
ul { list-style: none; padding: 0; }
a { color: inherit; text-decoration: none; }

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

.skip {
  position: fixed;
  top: 0.75rem;
  left: 0.75rem;
  z-index: 100;
  padding: 0.6rem 1rem;
  background: var(--paper);
  color: var(--ink);
  font-size: 0.85rem;
  font-weight: 500;
  border-radius: 999px;
  transform: translateY(-160%);
  transition: transform 0.25s var(--ease);
}
.skip:focus { transform: none; }

/* --- Atmosphere ---------------------------------------------------------- */

/* The WebGL field. z-index -1 puts it above the propagated body background but
   below every piece of content, with no stacking-context games needed — body
   has no transform, filter or opacity, so nothing traps it. */
.field {
  position: fixed;
  inset: 0;
  z-index: -1;
  display: block;
  width: 100%;
  height: 100%;
  pointer-events: none;
  /* The backing store is deliberately smaller than the viewport; the field is
     soft and low-frequency, so the upscale is invisible and much cheaper. */
  opacity: 0;
  animation: field-in 2.4s var(--ease) 0.25s forwards;

  /* Tilt parallax. The canvas is overscanned by 12.5% a side so the field can
     slide under the viewport without ever exposing an edge — the translate
     below peaks at ~11.7%, deliberately just inside that margin.
     Device-orientation samples are jittery, so a short linear transition
     smooths them without feeling laggy. */
  scale: 1.25;
  translate: calc(var(--tilt-x) * 0.45%) calc(var(--tilt-y) * 0.45%);
  transition: translate 0.3s linear;
}

@keyframes field-in { to { opacity: 1; } }

/* Film grain. Sits above everything, blends, never intercepts a pointer. */
.grain {
  position: fixed;
  inset: -150%;
  z-index: 9;
  pointer-events: none;
  opacity: 0.28;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='300' height='300' filter='url(%23n)' opacity='0.55'/%3E%3C/svg%3E");
  animation: grain 6s steps(1) infinite;
  will-change: transform;
}

@keyframes grain {
  0%   { transform: translate(0, 0); }
  20%  { transform: translate(-3%, 2%); }
  40%  { transform: translate(2%, -3%); }
  60%  { transform: translate(-2%, -2%); }
  80%  { transform: translate(3%, 1%); }
  100% { transform: translate(0, 0); }
}

.vignette {
  position: fixed;
  inset: 0;
  z-index: 8;
  pointer-events: none;
  background:
    radial-gradient(120% 80% at 50% 0%, rgba(139, 92, 246, 0.07), transparent 60%),
    radial-gradient(100% 100% at 50% 100%, rgba(0, 0, 0, 0.6), transparent 55%);
}

/* With the shader running, the field supplies the colour — the CSS wash only
   needs to keep the type legible over it. */
.has-gl .vignette {
  background:
    radial-gradient(115% 75% at 50% 0%, rgba(0, 0, 0, 0.45), transparent 55%),
    radial-gradient(100% 90% at 50% 100%, rgba(0, 0, 0, 0.55), transparent 60%);
}

/* --- Masthead ------------------------------------------------------------ */

.masthead {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 20;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  padding: clamp(1rem, 2.5vw, 1.75rem) var(--gutter);
  mix-blend-mode: difference;
}

.wordmark {
  font-family: var(--serif);
  font-size: clamp(0.95rem, 1.4vw, 1.1rem);
  font-variation-settings: 'opsz' 14, 'wght' 500, 'SOFT' 0, 'WONK' 0;
  letter-spacing: -0.01em;
  display: inline-flex;
  align-items: baseline;
}
.wordmark__e {
  font-variation-settings: 'opsz' 14, 'wght' 700, 'SOFT' 40, 'WONK' 1;
  font-style: italic;
}
.wordmark__tld { opacity: 0.45; }

.masthead__meta {
  display: inline-flex;
  align-items: center;
  gap: 0.55rem;
  font-size: 0.72rem;
  font-weight: 400;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--paper);
}

/* The chip states its own truth. Text lives in a pseudo-element so a style
   query can rewrite it with no JS branch — see --low-power below. */
.masthead__state::after { content: 'Self\002011hosted'; }

.pulse {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: currentColor;
  animation: pulse 2.6s var(--ease-io) infinite;
}

@keyframes pulse {
  0%, 100% { opacity: 0.25; transform: scale(0.8); }
  50%      { opacity: 1;    transform: scale(1); }
}

/* --- Shared bits --------------------------------------------------------- */

.eyebrow {
  display: flex;
  align-items: center;
  gap: 1rem;
  font-size: 0.72rem;
  font-weight: 400;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--paper-mute);
}
.eyebrow .rule {
  flex: 1;
  height: 1px;
  background: var(--hairline);
}
.eyebrow .count { color: var(--paper-dim); }

/* --- Hero ---------------------------------------------------------------- */

.hero {
  min-height: 100svh;
  padding: 0 var(--gutter) clamp(2rem, 5vh, 3.5rem);
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  gap: clamp(1.5rem, 4vh, 3rem);
}

.display {
  font-family: var(--serif);
  font-weight: 400;
  line-height: 0.82;
  letter-spacing: -0.035em;
  margin-left: -0.035em; /* optical: pull the 'e' onto the grid line */
}

.display__line { display: block; }

.ltr {
  display: inline-block;
  font-size: clamp(4.5rem, 19vw, 20rem);
  font-variation-settings: 'opsz' 144, 'wght' 340, 'SOFT' 0, 'WONK' 0;
  /* Letters settle into place on load — the "evolution" of the wordmark.
     Staggered here rather than with inline styles, so the page can ship a
     CSP with no 'unsafe-inline'. */
  opacity: 0;
  transform: translateY(0.14em);
  animation: settle 1.15s var(--ease) forwards;
}

.ltr:nth-child(1) { animation-delay: 120ms; }
.ltr:nth-child(2) { animation-delay: 175ms; }
.ltr:nth-child(3) { animation-delay: 230ms; }
.ltr:nth-child(4) { animation-delay: 285ms; }
.ltr:nth-child(5) { animation-delay: 340ms; }
.ltr:nth-child(6) { animation-delay: 395ms; }
.ltr:nth-child(7) { animation-delay: 450ms; }
.ltr:nth-child(8) { animation-delay: 505ms; }
.ltr:nth-child(9) { animation-delay: 560ms; }

/* The leading 'e' keeps the italic/wonk flourish the wordmark uses. */
.ltr:first-child {
  font-style: italic;
  font-variation-settings: 'opsz' 144, 'wght' 420, 'SOFT' 60, 'WONK' 1;
}

@keyframes settle {
  from { opacity: 0; transform: translateY(0.14em); }
  to   { opacity: 1; transform: none; }
}

.hero__foot {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  font-size: 0.72rem;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--paper-mute);
  opacity: 0;
  animation: fade-in 0.9s var(--ease) 1.1s forwards;
}

.hero__arrow {
  width: 1px;
  height: 2.5rem;
  background: linear-gradient(to bottom, var(--hairline), transparent);
  animation: drop 2.4s var(--ease-io) infinite;
}

@keyframes drop {
  0%, 100% { transform: scaleY(0.4); transform-origin: top; opacity: 0.4; }
  50%      { transform: scaleY(1);   transform-origin: top; opacity: 1; }
}

@keyframes fade-in { to { opacity: 1; } }

/* --- The index ----------------------------------------------------------- */

.index {
  border-top: 1px solid var(--hairline);
}

/* Per-project accents. In the stylesheet rather than inline style attributes,
   so style-src can stay 'self' with no 'unsafe-inline'. Order matches the
   markup; each pair is the project's own brand colour where it has one. */
.proj:nth-of-type(1) { --accent: #8b5cf6; --accent-soft: #a78bfa; } /* Axioma  */
.proj:nth-of-type(2) { --accent: #38bdf8; --accent-soft: #7dd3fc; } /* Planum  */
.proj:nth-of-type(3) { --accent: #33e6c8; --accent-soft: #5eead4; } /* Harbor  */
.proj:nth-of-type(4) { --accent: #ffb545; --accent-soft: #fcd34d; } /* Mulpur  */

.proj {
  position: relative;
  display: grid;
  grid-template-columns: clamp(2.5rem, 5vw, 5rem) 1fr minmax(0, var(--measure));
  align-items: baseline;
  gap: clamp(1rem, 3vw, 3rem);
  padding: clamp(1.75rem, 4.5vw, 3.25rem) var(--gutter);
  border-bottom: 1px solid var(--hairline);
  isolation: isolate;
  transition: background-color 0.5s var(--ease);
}

/* Accent wash that tracks the pointer. Positioned by --mx/--my from main.js. */
.proj__glow {
  position: absolute;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  opacity: 0;
  background: radial-gradient(
    22rem 22rem at var(--mx, 50%) var(--my, 50%),
    color-mix(in oklab, var(--accent) 20%, transparent),
    transparent 70%
  );
  transition: opacity 0.55s var(--ease);
}

/* Hairline that wipes across on hover. */
.proj::after {
  content: '';
  position: absolute;
  left: 0;
  bottom: -1px;
  height: 1px;
  width: 100%;
  background: linear-gradient(to right, var(--accent), transparent);
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 0.75s var(--ease);
}

.proj__num {
  font-family: var(--sans);
  font-size: 0.78rem;
  font-weight: 400;
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.12em;
  color: var(--paper-mute);
  transition: color 0.45s var(--ease);
}

.proj__name {
  font-family: var(--serif);
  font-weight: 400;
  font-size: clamp(3.25rem, 13vw, 7.5rem);
  line-height: 0.9;
  letter-spacing: -0.03em;
  transition:
    color 0.45s var(--ease),
    transform 0.6s var(--ease),
    /* Smooths the scroll-momentum translate below. --live-scroll-velocity is a
       raw per-frame delta, and wheel scrolling is impulsive — spike, gap,
       spike — so binding it straight to a transform makes the name vibrate.
       A short linear transition acts as a slew limiter: the name still trails
       the scroll, but it can no longer jump between frames. */
    translate 0.25s linear;
  transform-origin: left center;
}

/* Letters are split by main.js. Each carries its index as --c, which staggers
   the axis animation so the morph travels through the word rather than
   snapping all at once. */
.chr {
  display: inline-block;
  font-variation-settings: 'opsz' 144, 'wght' 300, 'SOFT' 0, 'WONK' 0;
  transition:
    font-variation-settings 0.55s var(--ease),
    transform 0.55s var(--ease);
  /* A left-to-right wave (--c is the letter index) loosened by a per-letter
     random roll, so the morph travels through the word organically instead of
     marching. The roll is seeded, so it is the same on every load. */
  transition-delay: calc((var(--c, 0) * 24ms) + var(--const-random, 0) * 70ms);
  /* Deliberately no will-change. There are 24 of these: font-variation-settings
     is not compositable so the hint buys nothing, and hinting transform would
     promote 24 layers and cost real memory on a phone. */
}

.proj__purpose {
  font-size: clamp(0.9rem, 0.85rem + 0.2vw, 1.0625rem);
  line-height: 1.5;
  color: var(--paper-dim);
  text-wrap: pretty;
  transition: color 0.45s var(--ease);
}

/* The active state — the type gains weight, softness and wonk.

   Driven by scroll position (main.js marks the row nearest the focus line),
   which is what makes this work on a phone where there is no hover at all.
   Pointer hover simply maps onto the same state. */

.proj.is-active { background-color: rgba(244, 242, 238, 0.02); }
.proj.is-active::after { transform: scaleX(1); }
.proj.is-active .proj__num { color: var(--accent); }
.proj.is-active .proj__purpose { color: var(--paper); }
.proj.is-active .proj__name { color: var(--accent-soft); }

.proj.is-active .chr {
  font-variation-settings: 'opsz' 144, 'wght' 620, 'SOFT' 70, 'WONK' 1;
  transform: translateY(-0.012em);
}

@media (hover: hover) and (pointer: fine) {
  .proj:hover { background-color: rgba(244, 242, 238, 0.02); }
  .proj:hover .proj__glow { opacity: 1; }
  .proj:hover::after { transform: scaleX(1); }
  .proj:hover .proj__num { color: var(--accent); }
  .proj:hover .proj__purpose { color: var(--paper); }
  .proj:hover .proj__name {
    color: var(--accent-soft);
    transform: translateX(0.6rem);
  }
  .proj:hover .chr {
    font-variation-settings: 'opsz' 144, 'wght' 620, 'SOFT' 70, 'WONK' 1;
  }
}

/* --- Scroll reveal ---------------------------------------------------------

   --const-has-entered latches to 1 the first time an element is wholly in view
   and never resets, so this is a one-way reveal with no observer, no class
   toggling and no failsafe: there is no code path that can strand an element
   invisible, because the latch is the only state.

   Gated behind .has-props (set by main.js once the sources are bound) so that
   with JS blocked the page renders fully visible rather than fully hidden.

   Note: "wholly in view" is the latch condition, so this suits elements
   comfortably shorter than a phone viewport — which every one of these is. */

.has-props .proj,
.has-props .contact > * {
  opacity: var(--const-has-entered, 0);
  translate: 0 calc((1 - var(--const-has-entered, 0)) * 1.75rem);
}

/* Transitions are armed one forced reflow *after* .has-props lands.

   Without that gap the page flashes: markup paints at full opacity, then JS
   adds .has-props, and the un-entered rows animate from visible down to
   hidden before fading back in — content visibly retreating on load. Arming
   separately lets the hidden state apply instantly and only the entrance
   animate. */
.has-props.props-armed .proj,
.has-props.props-armed .contact > * {
  /* The delay is per-property rather than a blanket transition-delay: the
     entrance is staggered by the element's random roll, but the scroll-driven
     properties on .proj below must never be delayed or they lag the scroll. */
  transition:
    opacity 0.9s var(--ease) calc(var(--const-random, 0) * 140ms),
    translate 0.9s var(--ease) calc(var(--const-random, 0) * 140ms);
}

/* .proj also transitions the two properties driven by live state. They have to
   be listed alongside the reveal rather than in a rule of their own, because
   `transition` is a shorthand — a second declaration would replace this list,
   not extend it. That is exactly how .proj lost its background-color
   transition when the reveal was added, making the hover tint snap. */
.has-props.props-armed .proj {
  transition:
    opacity 0.9s var(--ease) calc(var(--const-random, 0) * 140ms),
    translate 0.9s var(--ease) calc(var(--const-random, 0) * 140ms),
    transform 0.25s linear,
    background-color 0.5s var(--ease);
}

/* --- Scroll momentum -------------------------------------------------------

   --live-scroll-velocity is signed and decays to zero on its own, so the rows
   shear and the names trail behind a flick, then settle. `translate` and
   `transform` are separate properties, so these compose with the reveal
   translate and the hover translateX without any of them fighting.

   Both are smoothed by short linear transitions (declared with the properties
   themselves, above) — the raw signal is a per-frame delta and is far too spiky
   to drive a transform directly. Amplitudes are deliberately modest: the
   smoothing already damps the peak, and anything larger reads as the type
   sliding around rather than carrying weight. */

.has-props .proj {
  transform: skewY(calc(var(--vel) * -0.026deg));
}

.has-props .proj__name {
  translate: calc(var(--vel) * -0.4px) 0;
}

/* --- Contact ------------------------------------------------------------- */

.contact {
  padding: clamp(4rem, 12vh, 9rem) var(--gutter) clamp(2rem, 6vh, 3.5rem);
  display: flex;
  flex-direction: column;
  gap: clamp(2rem, 5vh, 3.5rem);
}

.contact__list {
  display: grid;
  gap: 1px;
  background: var(--hairline-2);
  border-block: 1px solid var(--hairline-2);
}

.contact__link {
  --accent: var(--paper);
  position: relative;
  display: grid;
  grid-template-columns: clamp(4.5rem, 11vw, 8rem) 1fr auto;
  align-items: center;
  gap: 1rem;
  padding: clamp(1.1rem, 2.5vw, 1.6rem) 0;
  background: var(--ink);
  transition: padding-inline 0.5s var(--ease), background-color 0.5s var(--ease);
}

.contact__label {
  font-size: 0.72rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--paper-mute);
  transition: color 0.4s var(--ease);
}

.contact__value {
  font-family: var(--serif);
  font-size: clamp(1.1rem, 2.6vw, 1.9rem);
  font-variation-settings: 'opsz' 40, 'wght' 350, 'SOFT' 0, 'WONK' 0;
  letter-spacing: -0.015em;
  overflow-wrap: anywhere;
  transition: font-variation-settings 0.55s var(--ease);
}

.contact__arrow {
  width: 1.5rem;
  height: 1px;
  background: var(--paper-mute);
  position: relative;
  transition: width 0.45s var(--ease), background-color 0.45s var(--ease);
}
.contact__arrow::after {
  content: '';
  position: absolute;
  right: 0;
  top: 50%;
  width: 7px;
  height: 7px;
  border-top: 1px solid currentColor;
  border-right: 1px solid currentColor;
  color: var(--paper-mute);
  transform: translateY(-50%) rotate(45deg);
  transition: color 0.45s var(--ease);
}

@media (hover: hover) and (pointer: fine) {
  .contact__link:hover { padding-inline: 1.25rem; background-color: var(--ink-raised); }
  .contact__link:hover .contact__label { color: var(--paper); }
  .contact__link:hover .contact__value { font-variation-settings: 'opsz' 40, 'wght' 560, 'SOFT' 60, 'WONK' 1; }
  .contact__link:hover .contact__arrow { width: 2.5rem; background-color: var(--paper); }
  .contact__link:hover .contact__arrow::after { color: var(--paper); }
}

.colophon {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem;
  font-size: 0.72rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--paper-mute);
}

/* --- Narrow screens ------------------------------------------------------ */

@media (max-width: 860px) {
  .proj {
    grid-template-columns: clamp(2rem, 8vw, 3rem) 1fr;
    align-items: start;
    row-gap: 0.9rem;
  }
  .proj__num { grid-row: 1; }
  .proj__name { grid-column: 2; }
  .proj__purpose { grid-column: 2; max-width: 46ch; }

  .contact__link { grid-template-columns: 1fr auto; row-gap: 0.35rem; }
  .contact__label { grid-column: 1 / -1; }
}

@media (max-width: 520px) {
  .masthead__meta { display: none; }
}

/* --- Power saving ----------------------------------------------------------

   Low battery (and not charging) or sustained CPU pressure strips the page back
   to a still frame. The shader is paused from main.js; this is everything CSS
   owns, and it needs no JS branch — --power-save is derived from the battery
   and Compute Pressure sources up top.

   This block lives here, near the end, on purpose: @container contributes no
   specificity, so against equally-specific base rules it is source order that
   decides. Placed earlier, .grain and .masthead__state::after would simply
   override it. State overrides belong late in the cascade. */

@container style(--power-save: 1) {
  .masthead__state::after { content: 'Power saving'; }
  .grain { animation: none; opacity: 0.1; }
  .pulse { animation: none; opacity: 0.55; }
  .hero__arrow { animation: none; }
  .field { transition: none; }
  .has-props .proj { transform: none; }
  .has-props .proj__name { translate: none; }
}

/* --- Motion & contrast preferences --------------------------------------- */

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }

  *, *::before, *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
  }

  .grain { animation: none; }
  .ltr, .hero__foot { opacity: 1; transform: none; }

  /* Show everything outright — no latch, no entrance, no scroll momentum. */
  .has-props .proj,
  .has-props .contact > * { opacity: 1; translate: none; }
  .has-props .proj { transform: none; }
  .has-props .proj__name { translate: none; }

  /* Tilt parallax is motion by definition. */
  .field { translate: none; scale: 1; transition: none; }

  /* The field still renders — main.js draws a single static frame — it simply
     never moves. A still gradient is not a motion problem, and removing it
     entirely would strip the page of its depth for no accessibility gain. */
  .field { opacity: 1; animation: none; }

  /* The active row keeps its colour and weight; only the travelling stagger
     across the letters goes away. */
  .chr { transition-delay: 0ms !important; }
}

@media (prefers-contrast: more) {
  :root {
    --paper-dim: #d8d5cf;
    --paper-mute: #b0ada6;
    --hairline: rgba(244, 242, 238, 0.3);
  }
  .grain { opacity: 0.1; }
}

/* Printing an index of four things should just be the four things. */
@media print {
  .grain, .vignette, .masthead, .hero__foot { display: none; }
  body { background: #fff; color: #000; }
  .proj__name, .contact__value { color: #000; }
  .proj__purpose { color: #333; }
  .field { display: none; }
  /* Never print a blank page because a row hadn't latched yet. */
  .has-props .proj,
  .has-props .contact > * {
    opacity: 1 !important;
    translate: none !important;
    transform: none !important;
  }
}
