/* reko.studio — reset, typography, layout primitives, a11y, motion, print
   Load order: tokens.css -> base.css -> glass.css */
/* ============================================================
 * BASE
 * ========================================================== */
*,*::before,*::after{ box-sizing:border-box; }
html{
  touch-action:manipulation;        /* pan-x + pan-y + pinch-zoom; only double-tap-zoom suppressed */
  -webkit-text-size-adjust:100%;
  background:
    radial-gradient(ellipse 62% 48% at 22% 34%, rgba(29,56,62,.55), transparent 62%),
    radial-gradient(ellipse 54% 44% at 76% 60%, rgba(29,56,62,.44), transparent 64%),
    radial-gradient(ellipse 48% 40% at 52% 86%, rgba(138,92,246,.085), transparent 66%),
    var(--bg-base);
  background-attachment:fixed;
}
body{
  margin:0;
  overscroll-behavior-y:none;
  color:var(--text-primary);
  font-family:var(--font-text);
  font-size:var(--fs-body);
  line-height:1.55;
  -webkit-font-smoothing:antialiased;
  -moz-osx-font-smoothing:grayscale;
  font-optical-sizing:auto;
  text-rendering:optimizeLegibility;
}
h1,.wordmark{ font-family:var(--font-display); }
img,svg{ max-width:100%; }

/* ---------- fixed background layers (z-ledger §4.6) ---------- */
#fluid{
  position:fixed; inset:0; z-index:0; display:block;
  width:100%; height:100%;
  pointer-events:none;
  opacity:0;
  transition:opacity 1200ms var(--ease-out);
}
.no-fluid #fluid{ display:none; }

/* ---------- skip link / a11y helpers ---------- */
.skip{
  position:absolute; left:max(16px, env(safe-area-inset-left, 0px)); top:-64px; z-index:100;
  padding:10px 16px; border-radius:var(--r-hit);
  background:#0B1119; color:var(--text-primary);
  font-size:15px; font-weight:500; text-decoration:none;
  transition:top 160ms var(--ease-out);
}
.skip:focus-visible{ top:16px; }
.visually-hidden{
  position:absolute; width:1px; height:1px; margin:-1px; padding:0;
  overflow:hidden; clip:rect(0 0 0 0); clip-path:inset(50%);
  white-space:nowrap; border:0;
}
:focus{ outline:none; }
:focus-visible{
  outline:2px solid var(--accent);
  outline-offset:3px;
  border-radius:inherit;
  box-shadow:0 0 0 6px var(--accent-dim);
}

/* ============================================================
 * TOPBAR (§4.7) — wordmark only
 * ========================================================== */
/* viewport-fit=cover is set, so the bar owns the notch strip: it grows by the
   top inset and pads it out, which keeps the wordmark's 44px hit area clear of
   the status bar instead of tucked under it. In landscape the left/right insets
   are the 44px ear, hence max() rather than a plain add — a phone with no inset
   keeps exactly the old gutter. */
.topbar{
  position:fixed; top:0; left:0; right:0; z-index:20;
  height:calc(var(--topbar-h) + env(safe-area-inset-top, 0px));
  display:flex; align-items:center;
  padding:env(safe-area-inset-top, 0px) 0 0;
  padding-inline:max(var(--gutter), env(safe-area-inset-left, 0px))
                 max(var(--gutter), env(safe-area-inset-right, 0px));
}
/* Long content used to scroll under the wordmark with nothing behind it.
   Invisible at rest so the hero stays clean; fades in once the page moves. */
.topbar::before{
  content:""; position:absolute; inset:0; z-index:-1;
  pointer-events:none;
  background:linear-gradient(180deg,
    rgba(5,7,12,.82) 0%, rgba(5,7,12,.58) 58%, rgba(5,7,12,0) 100%);
  opacity:0;
  transition:opacity 320ms var(--ease-out);
}
.scrolled .topbar::before{ opacity:1; }
.wordmark{
  display:inline-flex; align-items:center;
  min-height:44px; padding:4px 2px;
  font-size:var(--fs-wordmark); font-weight:600; letter-spacing:-.011em; line-height:1.15;
  color:var(--text-primary); text-decoration:none;
  text-shadow:var(--bare-shadow);
}
.wordmark .soft{ font-weight:500; }
.wordmark .dot{ color:var(--accent); text-shadow:0 0 12px rgba(var(--accent-rgb),.55); }

/* ---------- variant switcher (easter egg, not nav) ----------
   Fine pointers: nothing at rest — no dots, no hint. A generous 120x56 hotspot
   in the corner reveals three icon links that are IMMEDIATELY clickable; the
   links live INSIDE the hotspot, so the cursor can never cross a dead gap on
   its way to them. No disclosure button, no click required.
   Keyboard: the links are always in the DOM and tabbable; :focus-within makes
   them visible the moment one is focused.
   Coarse pointers: no hover exists, so a faint three-dot button remains and a
   tap expands the icons. */
/* Dev tool only: site.js sets .dev on <html> (localhost or ?dev).
   In production the switcher never renders. */
html:not(.dev) .eggs{ display:none; }
.eggs{
  position:relative; margin-left:auto;
  display:flex; align-items:center; justify-content:flex-end;
}
.eggs-toggle{
  display:flex; align-items:center; justify-content:flex-end; gap:3px;
  width:44px; height:44px; padding:0 4px;
  background:none; border:0; cursor:pointer;
  -webkit-appearance:none; appearance:none;
}
.eggs-dot{
  width:3px; height:3px; border-radius:50%;
  background:var(--text-primary); opacity:.25;
}
.eggs-list{
  list-style:none; margin:0; padding:0;
  display:flex; align-items:center; gap:4px;
  opacity:0; pointer-events:none;
  transition:opacity 200ms var(--ease-out);
}
.eggs-list li{
  opacity:0; transform:translateY(-3px);
  transition:opacity 240ms var(--ease-out), transform 240ms var(--ease-out);
}
.eggs-list li:nth-child(2){ transition-delay:60ms; }
.eggs-list li:nth-child(3){ transition-delay:120ms; }
.eggs-list button{
  display:flex; align-items:center; justify-content:center;
  width:30px; height:30px; padding:0; border:0; border-radius:var(--r-icon);
  background:none; cursor:pointer;
  -webkit-appearance:none; appearance:none;
  color:var(--text-tertiary);
  filter:drop-shadow(0 1px 2px rgba(0,0,0,.45));
  transition:color 200ms var(--ease-soft), background-color 200ms var(--ease-soft);
}
.eggs-list button:hover,.eggs-list button:focus-visible{
  color:var(--accent); background:rgba(var(--accent-rgb),.10);
}
.eggs-list button[aria-pressed="true"]{
  color:var(--accent); background:rgba(var(--accent-rgb),.16);
}

@media (hover:hover){
  /* no disclosure on fine pointers — the hotspot IS the affordance */
  .eggs-toggle{ display:none; }
  .eggs{ width:120px; height:56px; }
  .eggs:hover .eggs-list,.eggs:focus-within .eggs-list{ opacity:1; pointer-events:auto; }
  .eggs:hover .eggs-list li,.eggs:focus-within .eggs-list li{ opacity:1; transform:none; }
}
@media (hover:none){
  /* Coarse pointers keep the three-dot toggle, so the expanded icons hang below
     the bar over live hero type. Without a plate they read as three loose
     glyphs floating on the fluid; a flat dark tray on the shared 12px radius
     makes the tap target legible without adding a rule or a border. */
  .eggs-list{
    position:absolute; top:100%; right:0; margin-top:6px; gap:2px;
    padding:4px; border-radius:var(--r-hit);
    background:rgba(5,7,12,.88);
  }
  /* no hover on coarse pointers, so the icon buttons are the hit area: 44px */
  .eggs-list button{ width:44px; height:44px; }
  .eggs.is-open .eggs-list{ opacity:1; pointer-events:auto; }
  .eggs.is-open .eggs-list li{ opacity:1; transform:none; }
}
@media (prefers-reduced-motion:reduce){
  .eggs-list,.eggs-list li{ transition:none; transform:none; }
  .eggs-list li:nth-child(2),.eggs-list li:nth-child(3){ transition-delay:0s; }
}

/* ============================================================
 * LAYOUT (§4.2–4.4, §7.1)
 * ========================================================== */
main{ position:relative; z-index:10; }
.frame{ max-width:var(--frame-max); margin:0 auto; }
section{
  display:grid; place-items:center;
  padding-inline:max(var(--gutter), env(safe-area-inset-left, 0px))
                 max(var(--gutter), env(safe-area-inset-right, 0px));
}
#hero   { min-height:var(--hero-minh);    padding-block:96px 64px; }
#reko   { min-height:var(--reko-minh);    padding-block:64px 72px; }
#contact{ min-height:var(--contact-minh); padding-block:64px 72px; }
@media (max-width:767px){
  #hero{ padding-block:84px 72px; }
  #reko,#contact{ padding-block:56px 56px; }
}

.card-hero{
  width:100%; max-width:var(--card-hero-max);
  padding:var(--card-hero-pad);
}
.card-slim{
  width:100%; max-width:var(--card-slim-max);
  padding:var(--card-slim-pad);
}
@media (max-width:767px){
  .card-hero,.card-slim{ max-width:min(100%, 358px); }
}

.hero-inner{ opacity:var(--hero-fade); }
.eyebrow{
  margin:0 0 var(--gap-eyebrow);
  font-size:var(--fs-eyebrow); font-weight:590; letter-spacing:.14em; line-height:1;
  text-transform:uppercase; color:var(--text-tertiary);
}
h1{
  margin:0;
  max-width:var(--h1-measure);
  text-wrap:var(--h1-wrap);
  font-size:var(--fs-h1); font-weight:600;
  letter-spacing:var(--h1-track); line-height:var(--h1-lh);
  color:var(--text-primary);
}
.intro{
  margin:var(--gap-h1) 0 0;
  max-width:46ch;
  font-size:var(--fs-body); font-weight:400;
  letter-spacing:.004em; line-height:1.55;
  color:var(--text-secondary);
  text-wrap:pretty;
}
/* ---------- one aligned row system, shared by every card ----------
   Fixed tracks (not auto) so columns line up across separate row grids without
   needing display:contents. Everything snaps to the card's content left edge:
   micro-labels, the year/label gutter, and the empty gutter of allocation rows.
   Groups are separated by whitespace and micro-labels — no divider lines. */
.row-list{ list-style:none; margin:0; padding:0; }
.row-grid{
  display:grid;
  grid-template-columns:var(--grid-gutter) minmax(0,1fr) auto;
  column-gap:var(--grid-gap); row-gap:3px;
  align-items:baseline;
  padding:11px 0;
}
.row-grid > .gut  { grid-column:1; grid-row:1; }
.row-grid > .lead { grid-column:2; grid-row:1; color:var(--text-primary); font-weight:590; }
.row-grid > .sub  { grid-column:2; color:var(--text-secondary); font-weight:400; }
.row-grid > .trail{ grid-column:3; grid-row:1; justify-self:end; }
@media (min-width:768px){
  .row-grid{ grid-template-columns:var(--grid-gutter) var(--grid-lead) minmax(0,1fr) auto; }
  .row-grid > .sub  { grid-column:3; grid-row:1; }
  .row-grid > .trail{ grid-column:4; }
}
/* Groups with no gutter content (Clients) drop the track entirely rather than
   leaving it empty, so they start at the card's content left edge. */
.row-grid.flush{ grid-template-columns:minmax(0,1fr) auto; }
.row-grid.flush > .lead { grid-column:1; }
.row-grid.flush > .sub  { grid-column:1; grid-row:2; }
.row-grid.flush > .trail{ grid-column:2; grid-row:1; }
@media (min-width:768px){
  .row-grid.flush{ grid-template-columns:var(--grid-lead) minmax(0,1fr) auto; }
  .row-grid.flush > .sub  { grid-column:2; grid-row:1; }
  .row-grid.flush > .trail{ grid-column:3; }
}

/* ---------- narrow reflow (<768): the row folds, the axis holds ----------
   A phone cannot carry year + company + role + tag on one line. Measured at
   320: the 84px year track plus a 'Fractional' tag left the company column
   22px wide, so 'Apuraha Akatemia' spilled 41px out of its own cell and over
   the tag. Below 768 the row folds into a META LINE (year left, tag right)
   with the name and the role stacked under it — every cell still starts at the
   card's content left edge, so the ledger keeps its aligned-grid feel and
   gains no divider lines. The 768 threshold is base.css's existing structural
   breakpoint, not a knife edge: the single-line form needs ~440px of viewport
   before it stops colliding, so 430 and 414 must fold too. */
@media (max-width:767px){
  .row-list .row-grid{
    grid-template-columns:minmax(0,1fr) auto;
    row-gap:4px;
  }
  .row-list .row-grid > .gut  { grid-column:1; grid-row:1; }
  .row-list .row-grid > .trail{ grid-column:2; grid-row:1; justify-self:end; }
  .row-list .row-grid > .lead { grid-column:1 / -1; grid-row:2; }
  .row-list .row-grid > .sub  { grid-column:1 / -1; grid-row:3; }
}

.group + .group{ margin-top:var(--group-gap); }
.microlabel{
  display:block; margin:0 0 11px;
  font-size:11px; font-weight:590; letter-spacing:.06em; line-height:1;
  text-transform:uppercase; color:var(--text-tertiary);
}

.now-role{ margin:0; font-size:var(--fs-link); letter-spacing:-.004em; line-height:1.35; }
.now-role > .sub{ font-size:13.5px; line-height:1.4; }
.tag{
  padding:5px 10px; border-radius:999px;
  border:1px solid rgba(255,255,255,.09);
  font-size:11px; font-weight:590; letter-spacing:.08em; line-height:1;
  text-transform:uppercase; color:var(--text-tertiary); white-space:nowrap;
}

.exp-year{
  font-variant-numeric:tabular-nums; font-feature-settings:"tnum" 1;
  font-size:12px; font-weight:590; letter-spacing:.04em; line-height:1.5;
  color:var(--text-tertiary); white-space:nowrap;
}
.exp-role{ margin:0; font-size:14px; letter-spacing:.004em; line-height:1.45; }
.exp-role > .sub{ font-size:13.5px; }
/* stacked roles under one company: the .sub cell holds both lines, so they
   share the role column at >=768 and the same wrapped indent below it */
.role-line{ display:block; }
.role-line + .role-line{ margin-top:2px; }
@media (min-width:1440px){
  .exp-year{ font-size:12.5px; }
  .exp-role{ font-size:15px; }
  .exp-role > .sub{ font-size:14.5px; }
}
/* Footnote-weight links: no glass, no knockout, no sweep — just a calm
   brightening and a text-width rule. Visibly subordinate to EMAIL/LINKEDIN. */
.quiet-list{ list-style:none; margin:0; padding:0; }
.quiet-link{
  /* fixed tracks: an empty icon gutter on the rows without a glyph keeps the
     names aligned, and the fixed name column puts every qualifier at the same x */
  display:grid;
  grid-template-columns:var(--qicon) var(--qname) minmax(0,1fr) auto;
  column-gap:10px; align-items:baseline;
  /* 44px hit area; the pill is inset from the text on every side, so the
     background never starts at the same x as a word */
  min-height:44px; padding:11px 12px; margin-inline:-12px;
  border-radius:var(--r-hit);
  color:var(--text-tertiary); text-decoration:none;
  font-size:13.5px; font-weight:500; letter-spacing:.004em; line-height:1.4;
  transition:color 220ms var(--ease-soft), background-color 220ms var(--ease-soft);
}
.quiet-link .qtext{
  grid-column:2;
  background-image:linear-gradient(currentColor,currentColor);
  background-size:0% 1px; background-position:0 100%; background-repeat:no-repeat;
  transition:background-size 260ms var(--ease-out);
}
.quiet-link .brand{ grid-column:1; align-self:center; }
.quiet-link .qsub{ grid-column:3; color:var(--text-tertiary); opacity:.72; font-weight:400; }
.quiet-link .qarrow{ grid-column:4; align-self:center; opacity:.6;
  transition:transform 220ms var(--ease-soft), opacity 220ms var(--ease-soft); }
.quiet-link:hover,.quiet-link:focus-visible{
  color:var(--text-secondary); background:rgba(255,255,255,.035);
}
.quiet-link:hover .qtext,.quiet-link:focus-visible .qtext{ background-size:100% 1px; }
.quiet-link:hover .qarrow{ transform:translate(1px,-1px); opacity:1; }
@media (min-width:1440px){ .quiet-link{ font-size:14px; } }

/* Education: same year-column grid as History, one step quieter. Separated by
   group-sized whitespace, never a divider line. */
.edu-list{ margin-top:var(--group-gap); }
.edu-row{ padding:7px 0; }
/* Degree in the LEAD column, so "MSc Computer Science" starts at exactly the
   same x as "Terveystalo" and every other company name — alignment comes from
   the shared grid track, not from font metrics, so the smaller education type
   lines up regardless. Institution sits in the sub column, on the MSc row only. */
.edu-row > .lead,
.edu-row > .sub{
  color:var(--text-tertiary);
  font-size:12.5px; letter-spacing:.004em; line-height:1.5;
}
.edu-row > .lead{ font-weight:500; }
.edu-row > .sub { font-weight:400; }
@media (min-width:1440px){
  .edu-row > .lead,
  .edu-row > .sub{ font-size:13px; }
}

/* ---------- contact card ---------- */
.status{
  margin:0 0 var(--group-gap);
  font-size:13px; font-weight:400; letter-spacing:.004em; line-height:1.5;
  color:var(--text-tertiary);
}
.card-contact h2{
  margin:0 0 18px;
  font-size:15px; font-weight:600; letter-spacing:-.006em; line-height:1.3;
  color:var(--text-primary);
}
.rows{ list-style:none; margin:0; padding:0; }
.rows li + li{ border-top:1px solid rgba(255,255,255,.07); }
.rows .row-grid{
  grid-template-columns:var(--grid-gutter) minmax(0,1fr) auto;
  min-height:56px; padding:12px; margin-inline:-12px;
  align-content:center; border-radius:var(--r-hit);
  width:100%; font:inherit; text-align:left; border:0; background:none;
  color:var(--text-primary); text-decoration:none;
  font-size:var(--fs-link); font-weight:500; letter-spacing:-.004em;
}
.rows .copy-btn{ cursor:pointer; }
.rows .row-grid > .gut  { grid-column:1; }
.rows .row-grid > .knock{ grid-column:2; }
.rows .row-grid > .trail{ grid-column:3; }

/* Same fold, same reason: 'labs@reko.studio' has no break opportunity, so on
   one line with an 84px EMAIL track and the hint beside it the address escaped
   its own knockout plate (measured 94px out of a 98px plate at 320, painting
   straight over the hint). Folded, the address owns the full column and the
   label and hint become the quiet meta line above it. The plate stays
   fit-content, so the hover knockout still hugs the address and nothing else. */
@media (max-width:767px){
  .rows .row-grid{
    grid-template-columns:minmax(0,1fr) auto;
    /* the knockout plate paints 10px above the address line (padding 10 /
       margin -10), so the gap must exceed that or the plate covers the
       label — 20px leaves 10px of air even with the plate up */
    row-gap:20px;
  }
  .rows .row-grid > .gut  { grid-column:1; grid-row:1; align-self:center; }
  .rows .row-grid > .trail{ grid-column:2; grid-row:1; justify-self:end; align-self:center; }
  .rows .row-grid > .knock{ grid-column:1 / -1; grid-row:2; }
}

.rows .label{
  font-size:11px; font-weight:590; letter-spacing:.06em; line-height:1;
  text-transform:uppercase; color:var(--text-tertiary);
}
.rows .value{
  min-width:0; justify-self:start;
  display:inline-flex; align-items:center; gap:8px;
}
/* the rule sits on the text span, so it is exactly as wide as the text —
   never the full grid column */
/* At rest a quiet text-width rule reads as the affordance; it is exactly as
   wide as the text because the rule lives on the text span, not the column. */
.rows .vtext{
  background-image:linear-gradient(var(--hairline-lo),var(--hairline-lo));
  background-size:100% 1px; background-position:0 100%; background-repeat:no-repeat;
  transition:background-image 200ms var(--ease-out);
}
.rows a:hover .vtext,.rows a:focus-visible .vtext,
.rows .brand{ flex:0 0 auto; }

.rows .hint{
  font-size:11px; font-weight:590; letter-spacing:.06em; line-height:1;
  text-transform:uppercase; color:var(--text-tertiary);
  opacity:0;
  transition:opacity 200ms var(--ease-soft), color 220ms var(--ease-soft);
}
.rows .copy-btn:hover .hint,.rows .copy-btn:focus-visible .hint{
  opacity:1; color:var(--accent);
}
/* no hover to reveal it on touch, so the affordance must stay put */
@media (hover:none){ .rows .hint{ opacity:1; } }
.copy-done{ display:none; }
.copy-btn.is-copied .copy-idle{ display:none; }
.copy-btn.is-copied .copy-done{ display:inline; color:var(--accent); }
.site-footer{
  position:relative; z-index:10;
  padding-block:0 calc(48px + env(safe-area-inset-bottom, 0px));
  padding-inline:max(var(--gutter), env(safe-area-inset-left, 0px))
                 max(var(--gutter), env(safe-area-inset-right, 0px));
  max-width:var(--frame-max); margin:0 auto;
  text-align:center;
}
.site-footer p{
  margin:0;
  display:inline-flex; flex-wrap:wrap; justify-content:center;
  column-gap:12px; row-gap:2px;
  font-size:var(--fs-footer); font-weight:400; letter-spacing:.01em; line-height:1.45;
  color:var(--text-tertiary);
  text-shadow:var(--bare-shadow);
}
/* ---------- scroll affordance (§4.3) ----------
   Every dimension is a whole pixel: a 4px box, a 2px rule inset 1px on each
   side, a 4px dot filling the box, and a 56px integer travel. The old 1px rule
   with a 2px dot at left:-1px straddled a half pixel, which is what made it
   look soft and pixelated on non-retina. Nothing here is fractional.
   Hidden until site.js decides the visitor has stalled — see bindScrollHint. */
.scroll-hint{
  position:relative; width:4px; height:60px; margin-top:36px;
  opacity:0;
  transition:opacity 600ms var(--ease-out);
}
.scroll-hint::before{
  content:""; position:absolute; left:1px; top:0; width:2px; height:100%;
  background:linear-gradient(180deg, rgba(255,255,255,.20), transparent);
}
.scroll-hint::after{
  content:""; position:absolute; left:0; top:0; width:4px; height:4px;
  border-radius:50%; background:rgba(255,255,255,.72);
  will-change:transform;
}
.hint-ready .scroll-hint{ opacity:1; }
.hint-ready .scroll-hint::after{ animation:hint-travel 2.6s var(--ease-soft) infinite; }
.scrolled .scroll-hint{ opacity:0; }
.scrolled .scroll-hint::after{ animation:none; }
@media (max-width:767px){ .scroll-hint{ margin-top:28px; height:52px; } }

/* ============================================================
 * ENTRANCE MOTION (§8.2)
 * ========================================================== */
@keyframes rise{
  from{ opacity:0; transform:translateY(12px); filter:blur(8px); }
  to  { opacity:1; transform:none; filter:none; }
}
@keyframes rise-flat{           /* no filter — backdrop-filter surfaces + scroll reveals */
  from{ opacity:0; transform:translateY(12px); }
  to  { opacity:1; transform:none; }
}
/* translate3d on integer pixels so the dot never lands on a half pixel. The
   opacity stops stay — they shape the loop, and opacity is not what was
   causing the softness. */
@keyframes hint-travel{
  0%  { transform:translate3d(0,0,0);    opacity:0; }
  18% { opacity:1; }
  82% { opacity:1; }
  100%{ transform:translate3d(0,56px,0); opacity:0; }
}
.wordmark          { animation:rise 720ms var(--ease-out) 0ms both; }
.card-hero .eyebrow{ animation:rise 720ms var(--ease-out) 90ms both; }
.card-hero h1      { animation:rise 720ms var(--ease-out) 170ms both; }
.card-hero .intro  { animation:rise 720ms var(--ease-out) 260ms both; }
.js .reveal{ opacity:0; }          /* only hide when the marker script actually ran */
.is-in .reveal{ animation:rise-flat 500ms var(--ease-out) both; opacity:1; }
.is-in .reveal:nth-child(2){ animation-delay:0ms; }
.is-in .reveal:nth-child(3){ animation-delay:70ms; }
.is-in .reveal:nth-child(4){ animation-delay:140ms; }
.is-in .reveal:nth-child(5){ animation-delay:210ms; }
.is-in .reveal:nth-child(6){ animation-delay:250ms; }
.is-in .reveal:nth-child(7){ animation-delay:300ms; }
.is-in .reveal:nth-child(8){ animation-delay:350ms; }

/* ---------- scroll-driven choreography (progressive enhancement) ----------
   Only where `animation-timeline: view()` exists AND motion is welcome.
   Everything else keeps the IntersectionObserver reveals untouched.

   NOT applied to #contact: opacity/transform on an ancestor of `.knock`
   creates an isolated group, which severs the knockout blend from the card's
   backdrop — the email row's letters would stop showing the fluid. A
   scroll-linked animation never finishes, so that isolation would be
   permanent rather than the ~500ms the IO reveal costs. */
@keyframes card-enter{
  from{ opacity:.4; transform:translateY(28px) scale(.985); }
  to  { opacity:1;  transform:none; }
}
@keyframes card-leave{
  from{ opacity:1; }
  to  { opacity:.55; }
}
@supports (animation-timeline: view()){
  @media (prefers-reduced-motion: no-preference){
    #reko .card-slim{
      animation:card-enter linear both, card-leave linear both;
      animation-timeline:view(), view();
      animation-range:entry 0% entry 35%, cover 95% cover 100%;
    }
    /* the card carries the entrance now — drop the per-child stagger inside it */
    #reko .reveal{ opacity:1; }
    #reko.is-in .reveal{ animation:none; }
  }
}

/* ============================================================
 * REDUCED MOTION (§8.1)
 * ========================================================== */
.rm #fluid,.rm .scroll-hint{ display:none; }
.rm .reveal{ opacity:1; }
.rm .hero-inner{ opacity:1; }      /* no scroll-linked fade under reduced motion */
.rm *,.rm *::before,.rm *::after{
  animation-duration:.01ms !important; animation-iteration-count:1 !important;
  transition-duration:.01ms !important; scroll-behavior:auto !important;
}
/* Mirror of the above without the class, so the preference is honoured with no
   JS and reacts to a live OS toggle. Equal specificity, later source order. */
@media (prefers-reduced-motion:reduce){
  #fluid,.scroll-hint{ display:none; }
  .reveal{ opacity:1; }
  .hero-inner{ opacity:1; }
  *,*::before,*::after{
    animation-duration:.01ms !important; animation-iteration-count:1 !important;
    transition-duration:.01ms !important; scroll-behavior:auto !important;
  }
}
/* ============================================================
 * PRINT
 * ========================================================== */
@media print{
  /* every text colour in the sheet resolves through these three tokens, so one
     rule covers h1, section headings, roles, links and the footer */
  :root{ --text-primary:#000; --text-secondary:#222; --text-tertiary:#444; --accent:#000; }
  #fluid,.scroll-hint{ display:none; }
  html{ background:#fff; }
  body{ color:#000; }
  /* IntersectionObserver never fires for off-screen sections when printing */
  .reveal{ opacity:1 !important; animation:none !important; }
  #hero,#contact,#reko{ min-height:0; }
}
