/* ==========================================================================
   kylechowning.com — "Quiet '99"
   Plain CSS. No framework, no build step.

   Anchors: desktop 1440 (artboard 3a), mobile 390 (artboard 3c).
   Between them the layout is fluid; the rail becomes a top bar at 900px.

   NO COPY LIVES IN THIS FILE. There are no content-bearing ::before/::after
   rules, so text can always be edited in index.html alone.
   ========================================================================== */

:root {
  --bg:        #06080D;
  --bg-2:      #0A0F1C;
  --bg-3:      #0E1424;
  --text:      #E6EAF2;
  --text-2:    #C5CBD8;
  --text-3:    #B9C0CE;
  --muted:     #8B94A7;
  --blue:      #2F6BFF;
  --blue-tint: #A7C0FF;
  --rule:      rgba(47,107,255,.30);
  --rule-soft: rgba(47,107,255,.15);

  /* THE ONE DELIBERATE EXCEPTION TO THE SINGLE-ACCENT PALETTE.
     since Kyle's grandfather bought it new, and it is teal. So the silhouette
     is teal: it is a FACT ABOUT THE CAR, not a decorative second accent.

     It must never be used on the hover affordance, the focus ring, or
     anything else; the moment it appears elsewhere the site has two accent
     colours and this stops being a detail and starts being drift.

     Tuned against --blue #2F6BFF on the near-black ground: pushed green and
     desaturated slightly from the first pick so it reads unmistakably TEAL
     rather than as a slightly-off blue, which would just look like a mistake. */

  /* Silkscreen is now used for ONE thing: the h1 name. See README
     "Divergence from the design handoff". Everything that used to be
     Silkscreen at 11-30px is now Geist Mono (--f-mono) for legibility. */
  --f-display: 'Silkscreen', monospace;
  --f-body:    'IBM Plex Sans', system-ui, sans-serif;
  --f-mono:    'Geist Mono', ui-monospace, monospace;

  /* Fluid side padding: 20px at 390 → 80px at 1440.
     Solved against 100vw: slope = (80-20)/(1440-390) = 5.714vw per 100vw,
     intercept = 20 - 390*0.05714 = -2.29px. Verified: 20px @390, 80px @1440. */
  --pad-x: clamp(20px, 5.714vw - 2.29px, 80px);

  --rail-w: 220px;
}

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

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

body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font: 400 17px/1.6 var(--f-body);
  -webkit-font-smoothing: antialiased;
  overflow-x: hidden;
}

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

.skip-link {
  position: absolute; left: -9999px; top: 0;
  background: var(--blue); color: #fff;
  padding: 10px 16px; font: 400 13px/1 var(--f-mono); z-index: 10;
}
.skip-link:focus { left: 0; }

:focus-visible { outline: 1px solid var(--blue); outline-offset: 3px; }

/* ==========================================================================
   Shell / rail
   ========================================================================== */

.shell {
  display: grid;
  grid-template-columns: var(--rail-w) minmax(0, 1fr);
}

.topbar { display: none; }

.rail {
  border-right: 1px solid var(--rule);
  padding: 40px 32px;
  display: flex;
  flex-direction: column;
  gap: 18px;
  font: 400 13px/1 var(--f-mono);
  color: var(--muted);
  position: sticky;
  top: 0;
  height: 100vh;
}

.rail__logo {
  /* was Silkscreen 700 20px. In mono at 18px this read as ordinary text and
     lost its mark-like presence against the 13px nav links below. Wider
     tracking gives it back the stamped, deliberate feel without more weight
     (Geist Mono ships only 400/500). */
  font: 500 18px/1 var(--f-mono);
  letter-spacing: .16em;
  color: var(--blue);
  margin-bottom: 24px;
}

.rail__nav { display: flex; flex-direction: column; gap: 18px; }

.rail__link { color: var(--muted); transition: color .18s ease; }
.rail__link:hover,
.rail__link.is-current { color: var(--text); }

.rail__counter {
  margin-top: 48px;
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.rail__counter-label { font-size: 11px; letter-spacing: .1em; }
.rail__counter-value {
  /* was Silkscreen 700 26px. Mono digits are wider; 24px holds the same
     optical size against the 11px label above it. */
  font: 500 24px/1 var(--f-mono);
  letter-spacing: .01em;
  color: var(--blue);
  /* tabular figures stop the number jittering while it settles */
  font-variant-numeric: tabular-nums;
}
.rail__counter-note { font-size: 11px; }

.rail__foot {
  margin-top: auto;
  padding-top: 48px;
  font-size: 11px;
  line-height: 1.7;
}

/* ==========================================================================
   Main / sections
   ========================================================================== */

.main { display: flex; flex-direction: column; min-width: 0; }

.section {
  padding: 72px var(--pad-x) 88px;
  border-bottom: 1px solid var(--rule);
}

.label {
  font: 400 12px/1 var(--f-mono);
  color: var(--blue);
  letter-spacing: .12em;
}
.label--muted { color: var(--muted); }

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

.section--hero {
  padding-top: 96px;
  display: grid;
  grid-template-columns: minmax(0, 1fr) 360px;
  gap: 72px;
  align-items: start;
  /* Establishes the containing block for .hero__glow AND clips it. The clip is
     the performance guarantee, not a cosmetic choice: it bounds the repaint to
     the hero instead of letting a 900px radial smear over the whole document.
     See the .hero__glow block near the flair section. */
  position: relative;
  overflow: hidden;
}

/* THE GLOW. A single fixed-size, pre-rendered radial that MOVES by transform.
   It never restyles its own gradient, so the paint is done once and every
   subsequent frame is a composited transform. Full rationale with the JS. */
.hero__glow {
  position: absolute;
  /* Sized in one place so JS never needs to know the number: the element is
     its own 900px circle and is centred on the pointer by a -50%,-50% offset
     baked into the transform below. */
  width: 900px;
  height: 900px;
  left: 0;
  top: 0;
  z-index: 0;
  pointer-events: none;
  /* 7% at the core. Measured against the type rather than picked: at 10% the
     wash was legible as a SHAPE behind the thesis, which is exactly what the
     brief rules out. At 7% it reads as the dark being slightly uneven.
     The stops are close-packed early and long-tailed so there is no visible
     edge anywhere -- a hard stop at any radius makes it read as an object. */
  background: radial-gradient(circle closest-side,
    rgba(47, 107, 255, .07) 0%,
    rgba(47, 107, 255, .045) 28%,
    rgba(47, 107, 255, .018) 52%,
    rgba(47, 107, 255, .005) 74%,
    rgba(47, 107, 255, 0) 100%);
  /* --gx/--gy are written by app.js in px, relative to the hero box. The
     -50% pair centres the circle on that point. JS writes ONLY these two
     custom properties; it never touches the transform string or the gradient. */
  /* MEASURED BUG, do not "simplify" back.
     These were once percentages consumed inside translate(). A percentage in
     translate() resolves against the ELEMENT's own box -- this element's own
     900px -- not against the hero, so `calc(50% - 50%)` collapsed to 0 and the
     glow parked against the hero's left edge. Measured: centre landed at
     36.9%/56.6% of the hero instead of the intended 50%/42%.

     So the RESTING position is expressed as left/top, which do resolve against
     the hero (the containing block), and transform carries only the -50%
     centring plus the live pointer offset. left/top are set once and never
     animated; every per-frame write still goes through the transform. */
  left: 50%;
  top: 42%;
  --gx: 0px;
  --gy: 0px;
  transform: translate(calc(var(--gx) - 50%), calc(var(--gy) - 50%));
  will-change: transform;
}

.hero__text { display: flex; flex-direction: column; gap: 40px; min-width: 0; }

.hero__name {
  margin: 0;
  font: 700 44px/1.1 var(--f-display);
  letter-spacing: 0;
}

.hero__thesis {
  margin: 0;
  font: 500 44px/1.15 var(--f-body);
  letter-spacing: -.02em;
  color: var(--blue);
  max-width: 820px;
  text-wrap: balance;
}

.hero__second {
  margin: 0;
  font: 400 19px/1.6 var(--f-body);
  color: var(--text-3);
  max-width: 680px;
}

.hero__intro {
  margin: 0;
  font-size: 17px;
  color: var(--text-2);
  max-width: 720px;
}

.hero__portrait { margin: 0; display: flex; flex-direction: column; gap: 12px; }

.portrait {
  position: relative;
  aspect-ratio: 4 / 5;
  border: 1px solid var(--rule);
  background: repeating-linear-gradient(135deg, var(--bg-3) 0 10px, var(--bg-2) 10px 20px);
  display: flex;
  align-items: flex-end;
  padding: 14px;
  overflow: hidden;
}

/* The real portrait. Grayscale + the blue overlay above gives the duotone the
   handoff asks for (blue #2F6BFF over near-black) without a second asset. */
.portrait__img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  z-index: 0;
}

.hero__caption { font: 400 12px/1.7 var(--f-mono); color: var(--muted); }

/* ---- 02 building ---- */

.section--building .label { display: block; margin-bottom: 40px; }

.cards {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 56px;
}

.card { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
.card__meta { font: 400 12px/1 var(--f-mono); color: var(--muted); }
/* was Silkscreen 700 17px. Mono at 16px + open tracking keeps the company
   name reading as a label-ish heading, distinct from the 15px body below. */
.card__name {
  margin: 0;
  font: 500 15px/1.3 var(--f-mono);
  /* Silkscreen's pixel texture used to separate this from the 12px meta line
     above it. Mono alone flattened that, and Geist Mono only ships 400/500 so
     extra weight is not available. Uppercase + wide tracking restores the
     hierarchy using an idiom the design already uses for its section labels. */
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--text);
}
.card__number {
  /* was Silkscreen 700 24px. This is the card's focal point and the element
     most at risk of going weak in mono, so it keeps the full 24px and takes
     weight 500 with slightly tight tracking to stay emphatic. */
  font: 500 24px/1.3 var(--f-mono);
  letter-spacing: -.01em;
  color: var(--blue);
  /* tabular figures: the live value swaps in without reflowing the line */
  font-variant-numeric: tabular-nums;
}
/* "165 →" is historical and static; the trailing number is live. */
.card__number-was { color: var(--blue); }
.card__number-now { color: var(--blue); }
.card__body { margin: 0; font-size: 15px; color: var(--text-3); }
.card__link {
  font: 400 13px/1 var(--f-mono);
  color: var(--blue-tint);
  align-self: flex-start;
  transition: color .18s ease;
}
.card__link:hover { color: #fff; }

.quiet-line {
  margin: 48px 0 0;
  font: 400 13px/1.6 var(--f-mono);
  color: var(--muted);
}

/* ---- 03 levato ---- */

.section--levato {
  position: relative;
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: 72px;
}

.levato__left { display: flex; flex-direction: column; gap: 32px; min-width: 0; }

/* Deliberately NOT a wordmark: Levato is a real product with its own future
   branding, so this is set as a word, not a display logo. Plex 500 with
   -.03em matches the large-thesis treatment in this system. Pulled from 84px
   to 72px because Plex at 84 is far more massive than pixel type at the same
   size; 72 keeps the presence without swamping the lede beneath it. */
.levato__word {
  font: 500 72px/1 var(--f-body);
  letter-spacing: -.03em;
}

.levato__lede {
  margin: 0;
  font: 400 22px/1.4 var(--f-body);
  text-wrap: pretty;
}

/* ---- The Curve, as background ---- */

.curvebg {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  overflow: hidden;
}

.curvebg__svg {
  position: absolute;
  /* Sit ON the section's bottom rule and rise from there. Inset the right edge
     so the final riser clears the body copy in the right-hand column instead
     of running through it. */
  left: 0;
  right: 0;
  bottom: 0;
  width: 100%;
  height: 62%;
  display: block;
}

/* Everything in the section sits above the curve. */
.section--levato > *:not(.curvebg) { position: relative; z-index: 1; }

/* The fill is the body, the stroke is the edge. The fill covers far more
   pixels than the 1px line did, so it runs at a much lower opacity -- tuned
   against measured text contrast, not by feel. See README. */
.curve__fill {
  fill: var(--blue);
  /* 6%: measured. At 7% the worst case (muted text over stroke-on-fill) fell
     to 4.58:1 -- above AA but with almost no margin. 6% restores headroom. */
  opacity: .06;
  stroke: none;
  shape-rendering: crispEdges;
}

.curve__line {
  fill: none;
  stroke: var(--blue);
  /* A 1px hairline is invisible at background contrast; thickening the stroke
     buys perceptibility without raising opacity, which is what the overlaid
     text's contrast is actually sensitive to. */
  stroke-width: 3;
  opacity: .24;
  shape-rendering: crispEdges;
  vector-effect: non-scaling-stroke;
}

/* Staging: JS adds .curve-staged, which clips the curve back to nothing and
   then widens it one beat at a time. Fill and stroke are clipped together,
   so the area grows WITH the line rather than fading in separately.
   With JS off the class is never added and the full curve is already drawn.
   A step is instant -- no easing -- matching the steps(24) name wipe. */
.curve-staged .curvebg__svg { clip-path: inset(0 100% 0 0); }
.curve-staged .curvebg__svg.is-b1 { clip-path: inset(0 100.00% 0 0); }
.curve-staged .curvebg__svg.is-b2 { clip-path: inset(0 89.59% 0 0); }
.curve-staged .curvebg__svg.is-b3 { clip-path: inset(0 75.57% 0 0); }
.curve-staged .curvebg__svg.is-b4 { clip-path: inset(0 61.99% 0 0); }
.curve-staged .curvebg__svg.is-b5 { clip-path: inset(0 47.96% 0 0); }
.curve-staged .curvebg__svg.is-b6 { clip-path: inset(0 34.39% 0 0); }
.curve-staged .curvebg__svg.is-b7 { clip-path: inset(0 20.36% 0 0); }
.curve-staged .curvebg__svg.is-b8 { clip-path: inset(0 6.33% 0 0); }
.curve-staged .curvebg__svg.is-b9 { clip-path: inset(0 3.17% 0 0); }
.curve-staged .curvebg__svg.is-b10 { clip-path: inset(0 0.00% 0 0); }

.levato__right {
  display: flex;
  flex-direction: column;
  gap: 40px;
  padding-top: 44px;
  min-width: 0;
}
.levato__block .label { display: block; margin-bottom: 14px; }
.levato__body { margin: 0; font-size: 16px; color: var(--text-2); }

/* ---- 04 pei ---- */

.pei__head {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 24px;
  margin-bottom: 56px;
}
/* was Silkscreen 700 26px. Sits opposite the section label on a baseline row;
   24px mono holds that balance without out-shouting the principle titles. */
.pei__title {
  font: 500 24px/1 var(--f-mono);
  letter-spacing: .01em;
}

.pei__grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 56px 72px;
}

.principle { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
.principle__title {
  margin: 0;
  font: 600 22px/1.3 var(--f-body);
  color: var(--blue);
}
.principle__body { margin: 0; font-size: 15px; color: var(--text-3); }

.pei__book {
  margin: 56px 0 0;
  font: 400 16px/1.6 var(--f-body);
  color: var(--muted);
}
.pei__book-title { color: var(--text); }

/* ---- 05 before ---- */

.section--before .label { display: block; margin-bottom: 40px; }

/* align-items defaults to stretch, but any row whose content is narrower
   than the track can still shrink; make the stretch explicit so every
   row spans the full timeline width and its grid tracks resolve. */
.timeline { display: flex; flex-direction: column; align-items: stretch; font-size: 15px; }
.trow { width: 100%; }

.trow {
  display: grid;
  grid-template-columns: 140px 260px minmax(0, 1fr);
  gap: 32px;
  padding: 18px 0;
  border-top: 1px solid var(--rule-soft);
  position: relative;
}
.trow--last { border-bottom: 1px solid var(--rule-soft); }

.trow__date { font: 400 13px/1.6 var(--f-mono); color: var(--blue-tint); }
/* was Silkscreen 400 13px. The date column beside it is ALSO mono 13px, so
   dropping to plain mono would flatten the two columns into one texture.
   Weight 500 + open tracking + full --text colour keeps the company name
   reading as the row's anchor against the dimmer blue-tint date. */
.trow__company {
  font: 500 13px/1.6 var(--f-mono);
  letter-spacing: .04em;
  color: var(--text);
}
.trow__desc { color: var(--text-3); }

.offclock {
  margin: 40px 0 0;
  font: 400 14px/1.7 var(--f-mono);
  color: var(--muted);
}

/* ---- 06 contact ---- */

.section--contact {
  border-bottom: 0;
  padding-bottom: 48px;
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr);
  gap: 72px;
  align-items: start;
}

.contact__left { display: flex; flex-direction: column; gap: 20px; min-width: 0; }

.contact__email {
  /* was Silkscreen 700 30px. Mono at 30 sets a long address much wider, so
     26px keeps it on one line at 1440 while staying the biggest thing in the
     contact block. */
  font: 500 26px/1.2 var(--f-mono);
  letter-spacing: -.01em;
  color: var(--text);
  overflow-wrap: anywhere;
  align-self: flex-start;
  transition: color .18s ease;
}
.contact__email:hover { color: var(--blue); }

.contact__linkedin {
  font: 400 18px/1 var(--f-mono);
  color: var(--muted);
  align-self: flex-start;
  transition: color .18s ease;
}
.contact__linkedin:hover { color: var(--text); }

.introbox {
  padding: 28px;
  border: 1px solid var(--rule);
  display: flex;
  flex-direction: column;
  gap: 10px;
}
.introbox__label {
  font: 400 11px/1 var(--f-mono);
  letter-spacing: .14em;
  color: var(--muted);
}
.introbox__body { margin: 0; font-size: 13px; line-height: 1.65; color: var(--text-3); }

.footer {
  padding: 0 var(--pad-x) 40px;
  display: flex;
  justify-content: space-between;
  gap: 16px;
  font: 400 12px/1 var(--f-mono);
  color: var(--muted);
}

/* ==========================================================================
   Responsive
   ========================================================================== */

/* Intermediate: keep the rail, relax the rigid column widths. */
@media (max-width: 1200px) {
  .section--hero { grid-template-columns: minmax(0, 1fr) clamp(220px, 28vw, 360px); gap: 48px; }
  .section--levato,
  .section--contact { gap: 48px; }
  .section--contact { grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr); }
  .cards { gap: 40px; }
  /* Below ~1200 the two fixed columns starve the description, which then
     wraps one word per line. Let them shrink and keep a real minimum. */
  .trow { grid-template-columns: 96px minmax(0, 150px) minmax(220px, 1fr); gap: 20px; }
}

@media (max-width: 1040px) {
  .cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

/* Rail → top bar. Single column from here down. */
@media (max-width: 900px) {
  .shell { grid-template-columns: minmax(0, 1fr); }
  .rail { display: none; }

  .topbar {
    display: flex;
    justify-content: space-between;
    align-items: center;
    gap: 16px;
    padding: 16px 20px;
    border-bottom: 1px solid var(--rule);
    font: 400 12px/1 var(--f-mono);
    color: var(--muted);
    position: sticky;
    top: 0;
    background: var(--bg);
    z-index: 5;
  }
  .topbar__logo { font: 500 15px/1 var(--f-mono); letter-spacing: .16em; color: var(--blue); }
  .topbar__count { color: var(--blue); font-variant-numeric: tabular-nums; }

  .section { padding: 40px var(--pad-x) 56px; }

  /* The top bar is sticky, so an anchor jump would land a section's label
     underneath it. Offset the scroll target by the bar's height. */
  .section { scroll-margin-top: 48px; }

  /* Hero: portrait sits between name and thesis, per artboard 3c. */
  .section--hero {
    padding-top: 48px;
    grid-template-columns: minmax(0, 1fr);
    gap: 28px;
  }
  .hero__text { display: contents; }
  .hero__name     { order: 1; }
  .hero__portrait { order: 2; gap: 12px; }
  .hero__thesis   { order: 3; }
  .hero__second   { order: 4; }
  .hero__intro    { order: 5; }

  .section--levato,
  .section--contact,
  .pei__grid,
  .cards {
    grid-template-columns: minmax(0, 1fr);
  }

  .section--levato { gap: 24px; padding-bottom: 150px; }
  .levato__right { padding-top: 0; gap: 24px; }
  .levato__left { gap: 24px; }
  .cards { gap: 40px; }

  .pei__head { flex-direction: column; align-items: flex-start; gap: 24px; margin-bottom: 32px; }
  .pei__grid { gap: 32px; }

  .section--contact { gap: 24px; align-items: start; padding-bottom: 32px; }

  /* Timeline rows stack: date / company / description */
  .trow {
    grid-template-columns: minmax(0, 1fr);
    gap: 4px;
    padding: 14px 0;
  }

  .footer { padding-bottom: 32px; }
}

/* Mobile anchor: 390. Type scale from artboard 3c. */
@media (max-width: 560px) {
  body { font-size: 16px; }

  .hero__name   { font-size: 24px; line-height: 1.15; }
  .hero__thesis { font-size: 28px; line-height: 1.2; }
  .hero__second { font-size: 16px; }
  .hero__intro  { font-size: 15px; }

  .label, .introbox__label { font-size: 11px; }
  .card__meta { font-size: 11px; }
  /* mono at 15px sits too close to the 14px body; 13px uppercase + tracking
     keeps the company name distinct as a heading without crowding it. */
  .card__name { font-size: 13px; }
  .card__number { font-size: 20px; }
  .card__body { font-size: 14px; }
  .card__link { font-size: 12px; }
  .quiet-line { font-size: 12px; }

  /* Plex at 46 is much heavier than pixel type at 46; 40px keeps the same
     presence against the 18px lede without dominating the 390 column. */
  .levato__word { font-size: 40px; }

  /* BACKGROUND CURVE AT 390.
     The section is tall and narrow here, so a full-height curve stretches the
     riser into a vertical bar that runs straight through the body copy -- and
     a FILL makes that overlap worse, not better. Pin it to a short bottom band
     so it sits under the prose rather than behind it, and drop the fill's
     opacity further since a narrow column concentrates it. */
  /* The riser is the tall part, so height alone does not keep it off the text:
     it climbs the right edge beside the last paragraph. Push the whole curve
     below the prose by pinning it to a short band at the section's foot and
     letting the section's bottom padding be the space it lives in. */
  .curvebg__svg { height: 128px; }
  .curve__fill { opacity: .05; }
  .curve__line { stroke-width: 2; opacity: .24; }

  .levato__lede { font-size: 18px; }
  .levato__body { font-size: 14px; }

  .pei__title { font-size: 18px; }
  .principle__title { font-size: 19px; line-height: 1.3; }
  .principle__body { font-size: 14px; }
  .pei__book { font-size: 14px; }

  .timeline { font-size: 14px; }
  .trow__date { font-size: 12px; line-height: 1.5; }
  .trow__company { font-size: 12px; }
  .offclock { font-size: 12px; line-height: 1.7; }

  .contact__email { font-size: 16px; line-height: 1.3; }
  .contact__linkedin { font-size: 14px; }
  .introbox { padding: 20px; }
  .footer { font-size: 11px; }
}

/* ==========================================================================
   Load animations (README §Interactions)
   ========================================================================== */

@keyframes wipe { from { clip-path: inset(0 100% 0 0); } to { clip-path: inset(0); } }
@keyframes rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }

.js-wipe { animation: wipe 1.4s .1s both steps(24); }

.js-rise { animation: rise .8s both cubic-bezier(.2,.8,.2,1); }
.js-rise[data-rise="0"] { animation-duration: 1s; animation-delay: .6s; }
.js-rise[data-rise="1"] { animation-delay: 1.3s; }
.js-rise[data-rise="2"] { animation-delay: 1.6s; }
.js-rise[data-rise="3"] { animation-delay: 1.8s; }

/* ==========================================================================
   Flair — see app.js. All of it is additive: every rule here degrades to the
   plain design when JS is off, and all of it is disabled under
   prefers-reduced-motion at the bottom of this file.
   ========================================================================== */

/* (1) Timeline hover: a hairline DRAWS IN across the row, left to right.
   A frameset-era rule being drawn, not a fill fading up. Uses transform on a
   pseudo-element, so it never affects layout. */
.trow::after {
  content: "";
  position: absolute;
  left: 0; right: 0; top: -1px;
  height: 1px;
  background: var(--blue);
  transform: scaleX(0);
  transform-origin: left center;
  transition: transform .34s cubic-bezier(.2,.8,.2,1);
  pointer-events: none;
}
@media (hover: hover) {
  .trow:hover::after { transform: scaleX(1); }
  .trow:hover .trow__date { color: var(--text); }
  .trow__date { transition: color .34s ease; }
}

/* (2) Portrait: a static duotone. The pointer-tracking version was cut --
   it was invisible against a placeholder with no image, cost a pointermove
   listener plus a repainting radial gradient, and was desktop-only. Revisit
   only if a real kyle.jpg lands. */
/* NO treatment on the portrait. The supplied file is already pure grayscale,
   and a blue duotone has been explicitly rejected. Do not add a filter, blend
   layer or overlay here -- ship the image as-is. */

/* (3) The rail counter counts up from 165 (see app.js). No CSS animation
   needed -- the motion is the number itself changing. */

@media (hover: hover) {
}

/* THE CAR. Fixed to the viewport and pinned to the FOOT of the section, so it
   drives across open space under the text rather than through it. Layered
   under everything and pointer-events:none, so it can never cover or block a
   word while it passes. */

/* Left to right, once, then gone. `forwards` holds it off-screen right; JS
   removes the element immediately afterwards so nothing is left behind and
   nothing can loop. */

@media (max-width: 900px) {
}

/* ==========================================================================
   Reduced motion — disables BOTH load animations and all flair motion.
   ========================================================================== */

@media (prefers-reduced-motion: reduce) {
  .js-wipe,
  .js-rise,
  .card__number-was.is-live,
  .card__number-now.is-live {
    animation: none !important;
    opacity: 1 !important;
    transform: none !important;
    clip-path: none !important;
  }

  /* Belt and braces: staging clips the SVG ELEMENT, so that is what must be
     force-unclipped. The curve must never be left partly drawn under reduced
     motion. */
  .curve-staged .curvebg__svg,
  .curvebg__svg { clip-path: none !important; }

  /* THE SOURCE COLLAPSE: skipped entirely, landing on the finished hero.
     app.js already returns before staging, so this is belt and braces -- it
     guarantees that even if .hero-staged were somehow present, the hero can
     never be stranded in source view. */
  .src-tag, .src-caret { display: none !important; }
  .hero-staged .hero__name,
  .hero-staged .hero__thesis,
  .hero-staged .hero__second,
  .hero-staged .hero__intro {
    font-family: inherit !important;
    color: inherit !important;
  }
  .hero-staged .portrait__img { opacity: 1 !important; }

  .trow::after { transition: none; }

  /* The universal backstop. Every decorative effect on this page is additive
     and individually guarded in app.js, but this rule is the floor underneath
     all of them: whatever ships later, under reduced motion it does not move.

     It was silently missing for a while -- an orphaned comment body left a
     stray close marker here, and CSS recovers from that by discarding rules up
     to the next brace, which ate this block whole. Guarded by
     test/asset-integrity.test.mjs, which parses rather than greps. */
  *, *::before, *::after {
    animation-duration: .001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .001ms !important;
    scroll-behavior: auto !important;
  }
}

/* ==========================================================================
   Background curve: faint month ticks.
   The SVG uses preserveAspectRatio="none" (it stretches to the section), so
   text inside it would distort. These sit in HTML, positioned by percentage
   against the same x coordinates as the path's steps.
   ========================================================================== */
.curvebg__ticks {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 6px;
  height: 0;
  pointer-events: none;
  z-index: 0;
}
.curvebg__tick {
  position: absolute;
  bottom: 0;
  transform: translateX(4px);
  font: 400 10px/1 var(--f-mono);
  letter-spacing: .12em;
  color: rgba(47, 107, 255, .38);
  white-space: nowrap;
}
.curvebg__tick:last-child { transform: translateX(-100%); padding-right: 4px; }

@media (max-width: 900px) {
  /* Too many ticks collide at narrow widths: keep the endpoints only. */
  .curvebg__tick { display: none; }
  .curvebg__tick:first-child,
  .curvebg__tick:last-child { display: block; }
}

/* ==========================================================================
   THE SOURCE COLLAPSE — the hero moment.

   The argument: this page claims Kyle hand-coded HTML in 1999. So the hero
   opens as its own source -- real tags, mono, blue, the way markup looked in
   a 1999 text editor -- and then the markup COLLAPSES into the page it
   describes. The medium is the evidence.

   INVERSION (same as the Levato curve): the finished hero is what ships. Every
   rule in this block is scoped under .hero-staged, which ONLY app.js adds. With
   JS off, or under prefers-reduced-motion, none of this can apply and the hero
   is simply the finished hero. Never put .hero-staged in the markup.

   Performance: the collapse animates ONLY transform and opacity on the tag
   spans, plus one transform/opacity pair per hero element. No layout property
   is animated, so nothing here can cause a reflow mid-flight, and the tags are
   position:absolute while staged so they never occupy layout space -- which is
   what makes "no layout shift after resolution" true by construction rather
   than by tuning.
   ========================================================================== */

/* Base state: the tags do not exist. This is the JS-off truth. */
.src-tag { display: none; }

/* ---- absorbing the two original load animations ----
   VERIFIED BUG, not a preference: the handoff's name-wipe and hero-stagger
   start the hero at opacity 0 with delays out to 1.8s. Running the collapse
   underneath them left the hero BLANK for the whole source phase -- the moment
   played to an empty screen and reading was delayed by ~2s.

   Two load sequences cannot both own the hero. The collapse now IS the load
   animation: when staged, the wipe and stagger are cancelled and the content is
   visible from frame one, in source form. The vocabulary they established --
   steps(), the stepped reveal -- is inherited by the caret and the compile
   beats, so nothing is lost; it is paid off in a different place.

   They are cancelled ONLY while staged, so with JS off (or reduced motion, or
   a second visit in the same session) the original handoff animations run
   exactly as they always have. */
.hero-staged .js-wipe,
.hero-staged .js-rise,
.hero-done .js-wipe,
.hero-done .js-rise {
  animation: none;
  opacity: 1;
  transform: none;
  clip-path: none;
}

/* ---- staged: the hero is showing its own source ---- */

.hero-staged .src-tag {
  display: inline;
  position: absolute;
  font: 400 var(--src-size, 15px)/1 var(--f-mono);
  letter-spacing: .02em;
  color: var(--blue);
  white-space: nowrap;
  pointer-events: none;
  /* Tags fade and collapse toward the element they wrap. */
  opacity: 1;
  transform: none;
  transition: opacity .34s cubic-bezier(.4,0,1,1), transform .34s cubic-bezier(.4,0,1,1);
}

/* The opening tag sits to the LEFT of the element's FIRST line and the closing
   tag to the RIGHT of its LAST line -- the block is literally bracketed by its
   own markup, the way it is in a text editor.

   Aligned to the TEXT LINES (top:0 / bottom:0), not to the middle of the block:
   centring on the block put the tags floating in the whitespace between
   paragraphs, detached from any line -- verified on screen. Absolute
   positioning keeps all of this out of layout entirely. */
.hero-staged .src-tag:first-child {
  left: 0;
  top: 0;
  transform: translate(calc(-100% - .8ch), 0);
}
/* ---- closing tags are deliberately not shown ----
   They were tried and cut. The elements are height-pinned to their RESOLVED
   text, so the box bottom is not where the last MONO line falls; every attempt
   to anchor a closing tag ended with it floating in the whitespace below the
   paragraph, which read as a bug rather than as markup.

   Cutting them is not a compromise: the opening tag alone is what makes a line
   read as markup (it is the part carrying the element name), and one tag per
   line is quieter and more like a real editor's gutter than a pair of
   brackets chasing the text. It also makes desktop and mobile the same
   mechanism instead of two. */
.hero-staged .src-tag:last-child { display: none; }

/* The <img> tag sits INSIDE the portrait frame, not above it. Above the frame
   it overlapped the text column at 1440 (measured), and it belongs inside
   regardless: in source view the frame IS the unloaded image, so the tag
   naming it should be in the box, the way a 1999 browser drew a broken image
   with its alt text inside the placeholder. */
.hero-staged .src-tag.src-tag--img {
  position: absolute;
  left: 12px;
  top: 12px;
  bottom: auto;
  right: auto;
  transform: none;
  z-index: 1;
}

/* The staged elements are pinned to their RESOLVED height by app.js, so the
   taller source-view text must be allowed to spill out of the box without
   being clipped and without pushing anything. See the measurement note in
   app.js: without this the hero collapsed upward by up to 250px mid-sequence. */
.hero-staged .hero__name,
.hero-staged .hero__thesis,
.hero-staged .hero__second,
.hero-staged .hero__intro { overflow: visible; }

/* Hero elements need to be positioning contexts for their tags, and need a
   transform origin for the collapse. position:relative is inert when not
   staged, so it lives here rather than in the base rules. */
.hero-staged .hero__name,
.hero-staged .hero__thesis,
.hero-staged .hero__second,
.hero-staged .hero__intro,
.hero-staged .hero__portrait { position: relative; }

/* ---- the source state itself ----
   Every hero element is temporarily set in mono, blue, at source size: this is
   what makes it read as A TEXT EDITOR rather than as "the finished page with
   some tags floating near it". It is the whole difference between the idea
   landing and not landing. Only colour/font/transform change -- never a
   layout property -- so the resolve cannot shift anything. */
.hero-staged .hero__name,
.hero-staged .hero__thesis,
.hero-staged .hero__second,
.hero-staged .hero__intro {
  font-family: var(--f-mono);
  color: var(--blue-tint);
  transition:
    color .5s cubic-bezier(.2,.8,.2,1),
    font-size .42s cubic-bezier(.2,.8,.2,1),
    opacity .5s cubic-bezier(.2,.8,.2,1);
}

/* ---- why the source view is set smaller ----
   Geist Mono is much wider per character than IBM Plex Sans, so the same copy
   at the same size wraps to noticeably more lines. Pinned to their resolved
   heights (app.js), the paragraphs then overflowed and collided with each
   other -- verified on screen, not assumed.

   The honest fix is not to fight the metric but to accept it: source view is
   set at .82em, which is both what makes mono FIT the reserved box and what an
   editor actually looks like next to rendered type. The size then animates up
   as each line compiles, which turns the constraint into part of the effect --
   the text visibly grows into the page.

   font-size is animated here, and it is the ONE exception to the
   transform/opacity-only rule: the elements are height-pinned, so a font-size
   change cannot reflow anything outside its own box. Verified: 0px shift. */
.hero-staged .hero__thesis,
.hero-staged .hero__second,
.hero-staged .hero__intro { font-size: .82em; line-height: 1.5; }
.hero-staged .hero__name  { font-size: .8em; }

/* The name is the last thing to convert, and it converts INTO Silkscreen --
   his 1999 signature. Held in mono until then. */
.hero-staged .hero__name { color: var(--blue); }

/* The portrait is un-rendered in source view: it is an <img> tag that has not
   loaded yet. A 1px blue rule box, exactly like a 1999 broken-image frame.
   NOTE: this is not a filter or overlay ON the photo -- the photo itself is
   untouched (see README "The portrait"); the IMG is simply hidden, then shown. */
/* In source view the portrait has not loaded: an empty 1px blue frame holding
   the tag that names it, exactly like a 1999 broken-image box. The photo then
   appears when the tag compiles.
   This is emphatically NOT a treatment on the photo -- no filter, no overlay,
   no blend mode (see README "The portrait"). The img is hidden, then shown at
   full opacity, untouched. */
.hero-staged .portrait__img { opacity: 0; transition: opacity .45s ease; }
.hero-staged .portrait {
  background: none;
  border-color: var(--blue);
  transition: border-color .45s ease;
}
.hero-staged .src-live.hero__portrait .portrait { border-color: var(--rule); }
.hero-staged .src-live.hero__portrait .portrait__img { opacity: 1; }

/* ---- resolution ----
   Applied per-element as each one "compiles". Removing .src-live returns the
   element to its own stylesheet rules, so the finished state is the page's
   real CSS, not a copy of it that could drift. */
.hero-staged .src-live.hero__name,
.hero-staged .src-live.hero__thesis,
.hero-staged .src-live.hero__second,
.hero-staged .src-live.hero__intro {
  font-family: inherit;
  color: inherit;
}
/* Resolving restores font-size/line-height to the page's own values by
   unsetting them, so the finished state can never drift from the real
   stylesheet. */
.hero-staged .src-live.hero__name,
.hero-staged .src-live.hero__thesis,
.hero-staged .src-live.hero__second,
.hero-staged .src-live.hero__intro { font-size: revert; line-height: revert; }

.hero-staged .src-live.hero__name   { font-family: var(--f-display); color: var(--text); }
.hero-staged .src-live.hero__thesis { font-family: var(--f-body); color: var(--blue); }
.hero-staged .src-live.hero__second { font-family: var(--f-body); color: var(--text-3); }
.hero-staged .src-live.hero__intro  { font-family: var(--f-body); color: var(--text-2); }

/* The tags collapse INTO the element as it resolves -- they do not merely fade.
   Scaling to 0 along x toward the element's edge reads as the markup being
   absorbed by what it describes, which is the point. */
.hero-staged .src-live .src-tag {
  opacity: 0;
  transform: translate(0, 0) scaleX(.2);
}
.hero-staged .src-live .src-tag:first-child { transform-origin: right center; }
.hero-staged .src-live .src-tag:last-child  { transform-origin: left center; }
.hero-staged .src-live .src-tag.src-tag--img { transform: scaleX(.2); transform-origin: left center; }


/* The cursor. One blinking block, Geist Mono, at the point of composition --
   it moves down the hero as each line is written, then disappears for good.
   It is the only thing on this page that blinks, it blinks for under three
   seconds, and it is removed from the DOM when the moment ends. */
.src-caret {
  position: absolute;
  top: 0;
  left: 0;
  width: 7px;
  height: 20px;
  background: var(--blue);
  pointer-events: none;
  z-index: 2;
  animation: src-blink .7s steps(1, end) infinite;
  /* Only the position animates -- transitioning width/height made the rendered
     caret disagree with the measured line box mid-flight.

     90ms against a 210ms beat, so the caret SNAPS to the next line and then
     rests there, visibly blinking, before the line compiles. At 160ms it was
     still travelling when the next beat fired and read as a floating dot
     sliding down the page (measured: it never came to rest). A caret that
     rests is a caret; a caret that glides is a cursor trail. */
  transition: transform .09s steps(2, end), opacity .2s linear;
}
@keyframes src-blink { 0%, 50% { opacity: 1; } 50.01%, 100% { opacity: 0; } }

/* ---- mobile (390) ----
   Same idea, simpler expression: the tags stack ABOVE each line rather than
   bracketing it, because there is no horizontal room to bracket anything at
   390. The conversion of type from mono to the real faces -- which is the part
   that actually reads -- is unchanged. */
@media (max-width: 900px) {
  .hero-staged .src-tag,
  .hero-staged .src-tag:first-child {
    left: 0;
    right: auto;
    top: -1.35em;
    bottom: auto;
    transform: none;
    font-size: 11px;
  }

  /* The <img> tag stays INSIDE the portrait frame here too -- above it, it
     collided with the caption of the element above. Same rule as desktop, but
     it has to be restated because the block above resets `top`. */
  .hero-staged .src-tag.src-tag--img {
    left: 10px;
    top: 10px;
    font-size: 11px;
  }

  .hero-staged .src-live .src-tag { transform: scaleX(.2); transform-origin: left center; }

  /* The caret is desktop-only. At 390 there is no room beside a line for it,
     it lands in the gutter or off-screen (verified), and the stacked tags
     already carry the "this is being written" reading on their own. The
     conversion of the type itself -- which is the part that actually lands --
     is identical on both. */
  .src-caret { display: none; }
}

/* Stewardship lede: sits between the section header and the values grid. */
.pei__lede {
  margin: 0 0 40px;
  max-width: 720px;
  font: 400 19px/1.6 var(--f-body);
  color: var(--text-2);
}
@media (max-width: 900px) {
  .pei__lede { font-size: 16px; margin-bottom: 32px; }
}

/* Levato goal line: sits under the lede, mono so it reads as a stated target. */
.levato__goal {
  margin: 20px 0 0;
  font: 400 15px/1.7 var(--f-mono);
  color: var(--blue-tint);
}
.levato__goal-label {
  color: var(--muted);
  letter-spacing: .1em;
  text-transform: uppercase;
  font-size: 12px;
  margin-right: 8px;
}
@media (max-width: 900px) { .levato__goal { font-size: 13px; } }

/* History: the highlighted accomplishment leads each row's description. */
.trow__hi {
  display: block;
  margin-bottom: 6px;
  font: 500 13px/1.4 var(--f-mono);
  letter-spacing: .04em;
  color: var(--blue);
}
@media (max-width: 900px) { .trow__hi { font-size: 12px; } }

/* Family photo: closes the page above the footer rule. */
.famphoto { margin: 0 0 28px; display: block; }
.famphoto__img {
  display: block;
  width: 100%;
  height: auto;
  border: 1px solid var(--rule);
}
.famphoto__cap { display: none; }
@media (max-width: 900px) {
  .famphoto { margin-bottom: 24px; }
  .famphoto__cap { font-size: 11px; }
}

/* The human note beside the family photo. */
.famnote {
  margin: 14px 0 28px;
  font: 400 14px/1.7 var(--f-body);
  color: var(--text-3);
}
@media (max-width: 900px) { .famnote { font-size: 13px; } }

/* How I'm wired: assessments, stated plainly. */
.eq {
  margin: 56px 0 0;
  padding-top: 32px;
  border-top: 1px solid var(--rule-soft);
  max-width: 720px;
}
.eq__intro {
  margin: 0 0 14px;
  font: 400 14px/1.7 var(--f-body);
  color: var(--text-3);
}
.eq__list {
  margin: 0;
  display: grid;
  grid-template-columns: max-content 1fr;
  gap: 6px 16px;
  font: 400 12px/1.5 var(--f-mono);
}
.eq__list dt { color: var(--muted); letter-spacing: .06em; }
.eq__list dd { margin: 0; color: var(--blue-tint); }
@media (max-width: 900px) {
  .eq__list { grid-template-columns: 1fr; gap: 2px; }
  .eq__list dd { margin-bottom: 8px; }
}

/* 06 Family: photo left, the human details right. */
.family__grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: 56px;
  align-items: start;
}
.family__text .famnote { margin: 0; font-size: 15px; }
@media (max-width: 900px) {
  .family__grid { grid-template-columns: minmax(0, 1fr); gap: 24px; }
  .family__text .famnote { font-size: 14px; }
}

/* The family grid must clear its section label. */
.section--family .family__grid { margin-top: 28px; }

/* Sub-header for the wiring block, matching the section-label idiom. */
.eq__label {
  margin-bottom: 14px;
  font: 400 12px/1 var(--f-mono);
  letter-spacing: .12em;
  color: var(--blue);
}
@media (max-width: 900px) { .eq__label { font-size: 11px; } }

/* Current cards: date leads in blue, role and name below. */
.card__date {
  margin-bottom: 6px;
  font: 500 11px/1 var(--f-mono);
  letter-spacing: .08em;
  color: var(--blue);
}
@media (max-width: 900px) { .card__date { font-size: 10px; } }

/* Tighten the card header stack: date, role and name read as one block.
   The card is a 16px-gap flex column, so pull the header items back together
   with negative margins rather than fighting the gap everywhere else. */
.card__date { margin: 0 0 -10px; }
.card__meta { margin: 0 0 -10px; }
.card__name { margin: 0; }

/* Ticks sit at their step's corner; the last two are close, so vent them. */
.curvebg__tick { opacity: .9; }
.curvebg__tick:nth-last-child(2) { transform: translateX(-100%); padding-right: 6px; }

/* Sharpen the staircase: a touch more stroke presence so each 90° corner
   reads as a deliberate step, without raising fill opacity under the text. */
.curve__line { stroke-width: 4; opacity: .34; }
.curvebg__tick { color: rgba(47, 107, 255, .55); }

/* Center each tick on its own riser so the label reads as that step's corner,
   rather than starting at the corner and running into the next step. */
.curvebg__tick { transform: translateX(-50%); }
.curvebg__tick:first-child { transform: translateX(0); }
.curvebg__tick:nth-last-child(2) { transform: translateX(-50%); padding-right: 0; }
.curvebg__tick:last-child { transform: translateX(-100%); }

/* Sep and "now" are only 6% apart; below a wide viewport they crowd, so let
   the dated tick win and drop the redundant "now". */



/* ==========================================================================
   Levato thesis: the conceptual conclusion of the section.
   Spans both columns, sits above the stepped graphic. No box, no rule, no
   decoration -- the size and the whitespace do the work.
   ========================================================================== */
.levato__thesis {
  grid-column: 1 / -1;
  margin: 40px 0 0;
  max-width: 900px;
}
.levato__thesis-line {
  margin: 0;
  font: 500 34px/1.25 var(--f-body);
  letter-spacing: -.02em;
  color: var(--text);
}
.levato__thesis-em { color: var(--blue); }

/* The goal follows the thesis as the forward-looking close. */
.levato__thesis .levato__goal { margin-top: 28px; }

@media (max-width: 1200px) {
  .levato__thesis { margin-top: 36px; }
  .levato__thesis-line { font-size: 28px; }
}
@media (max-width: 900px) {
  .levato__thesis { margin-top: 56px; }
  .levato__thesis-line { font-size: 24px; }
}
@media (max-width: 560px) {
  .levato__thesis-line { font-size: 21px; }
}

/* The one looping animation on the page: a faint beacon at the end of the
   curve. It marks "still running" on a number that is genuinely live. */
.curve__beacon {
  fill: var(--blue);
  opacity: .5;
  animation: beacon 2.8s ease-in-out infinite;
}
@keyframes beacon {
  0%, 100% { opacity: .22; r: 4; }
  50%      { opacity: .62; r: 6; }
}
@media (prefers-reduced-motion: reduce) {
  .curve__beacon { animation: none; opacity: .45; }
}

/* The hero belief: a shade more weight than body copy, well short of a
   headline. The reader should discover it, not be announced to. */
.hero__belief {
  margin: 0;
  max-width: 620px;
  font: 500 22px/1.45 var(--f-body);
  letter-spacing: -.01em;
  color: var(--blue);
}
@media (max-width: 1200px) { .hero__belief { font-size: 20px; } }
@media (max-width: 900px)  { .hero__belief { font-size: 19px; } }
@media (max-width: 560px)  { .hero__belief { font-size: 18px; } }

/* ==========================================================================
   THE HIDDEN SYSTEM — signals along the page's own geometry.

   The claim the page makes is that Kyle's work is connecting fragmented
   things into one system. So the page itself behaves that way: when the
   pointer approaches one of the 1px rules that already divide the sections,
   a faint blue signal travels a short distance ALONG that rule and vanishes.

   WHAT MAKES IT RESTRAINED, and what to preserve if this is ever touched:

   - It adds NO new visible structure. Every signal rides a border that is
     already drawn. At rest the page is byte-for-byte the page it was; there is
     no circuit pattern, no grid overlay, no persistent graphic anywhere.
   - It is a SHORT TRAVEL, not a sweep: the pulse is a ~140px hairline that
     crosses a fraction of the rule and is gone in 1.1s. A signal that ran the
     full width would read as a loading bar.
   - Opacity peaks at .45 on a 1px line in --blue, over a near-black ground.
     It is at the threshold of noticing, which is the point: the effect should
     register as "something moved over there", not as a visible object.
   - It fires on APPROACH and then only occasionally (see app.js), so it can
     never become a cursor trail. Nothing follows the pointer.

   Cost: each pulse is one absolutely-positioned 1px div running a single
   one-shot CSS animation of transform + opacity, removed on animationend.
   An idle page runs nothing at all -- there is no rAF loop behind this.
   ========================================================================== */

/* The rail of a signal: positioned on the divider by app.js, then it travels.
   `position: absolute` keeps it out of layout entirely, so it cannot shift a
   single pixel of the page. */
.sig {
  position: absolute;
  height: 1px;
  width: 140px;
  left: 0;
  /* Ride the section's BOTTOM border -- the rule that is actually drawn.
     MEASURED BUG, do not "simplify" back to `top`: `top: -1px` anchors to the
     section's TOP edge, which put every signal at y=-1 on the wrong rule
     entirely (verified by reading the rect back, not by eye -- at .45 opacity
     on a 1px line the mistake is invisible in a screenshot).
     `bottom: -1px` sits it ON the border-bottom, which is the geometry the
     effect claims to be riding. */
  bottom: -1px;
  pointer-events: none;
  z-index: 0;
  opacity: 0;
  /* A signal is brightest at its leading edge and trails off behind it, so it
     reads as travelling rather than as a block sliding along. */
  background: linear-gradient(90deg,
    rgba(47, 107, 255, 0) 0%,
    rgba(47, 107, 255, .28) 55%,
    rgba(47, 107, 255, .65) 92%,
    rgba(47, 107, 255, 0) 100%);
  will-change: transform, opacity;
}

/* The travel. transform + opacity ONLY -- see test/signals.test.mjs, which
   fails if any layout property appears here. The distance is carried in
   --sig-dist so one keyframes block serves every signal, and the direction in
   --sig-from, so a signal can run either way along the rule. */
@keyframes sig-travel {
  0%   { opacity: 0;   transform: translateX(var(--sig-from, 0px)); }
  18%  { opacity: .45; }
  70%  { opacity: .30; }
  100% { opacity: 0;   transform: translateX(var(--sig-dist, 220px)); }
}

.sig.is-live {
  animation: sig-travel var(--sig-dur, 1100ms) cubic-bezier(.33, 0, .2, 1) forwards;
}

/* Reduced motion: no signal is ever created (app.js returns first). This is
   belt and braces, matching the rest of the file -- if one somehow existed it
   would be invisible and motionless, and the dividers are still drawn by their
   own border rules, so the page stays exactly as coherent without it. */
@media (prefers-reduced-motion: reduce) {
  .sig { display: none !important; }
}

/* ==========================================================================
   THE HERO FIELD — complexity resolving into order.

   The hero's claim is "Complexity belongs in the system, not on the people."
   So behind the hero sits a very faint field of short line fragments at
   scattered angles. Where the pointer passes, nearby fragments rotate toward a
   shared alignment and then relax back once it leaves. The idea is executed
   rather than illustrated: disorder becomes order around the point of
   attention, and the page absorbs the complexity.

   WHAT IT IS NOT, and must never become: a blob that follows the cursor, a
   particle burst, a constellation with join-the-dot lines, or anything that
   moves the type. Nothing follows the pointer -- the fragments stay exactly
   where they are and only their ANGLE changes.

   THE PORTRAIT IS NOT INVOLVED. No filter, no overlay, no blend mode, and the
   canvas is a sibling that sits beneath the content layer. Treatments on the
   portrait have been rejected twice; see README "The portrait".

   Cost: one canvas, 44 fragments, redrawn only while the field is settling.
   The rAF loop ends when every fragment has reached its target, so an idle
   hero costs nothing. Paused entirely when the hero is offscreen.
   ========================================================================== */

.hero__field {
  position: absolute;
  inset: 0;
  z-index: 0;          /* same layer as the glow: beneath all hero content */
  pointer-events: none;
  /* No blend mode and no filter, deliberately: both would reach through to
     whatever is beneath, which includes the portrait. */
  opacity: .5;
}

/* Reduced motion: the field is never created (app.js returns first). Belt and
   braces, so it can never appear even if one somehow existed. */
@media (prefers-reduced-motion: reduce) {
  .hero__field { display: none !important; }
}

/* At 390 the hero is a single narrow column and the fragments would sit
   directly under the copy with nowhere to breathe. The field is desktop-only:
   touch never binds a pointer listener anyway (see app.js), so on a phone this
   would be a static texture behind text, which is exactly the kind of
   decoration the design rules out. */
@media (max-width: 900px) {
  .hero__field { display: none; }
}
