/* =============================================================================
   base.css — reset, type, the console primitives, nav and footer.
   Section-specific rules live in sections.css.

   The four rules this obeys are stated at the top of tokens.css.
   ============================================================================= */

*, *::before, *::after { box-sizing: border-box; }
html, body, h1, h2, h3, h4, p, figure, blockquote, dl, dd, ul, ol { margin: 0; padding: 0; }
ul, ol { list-style: none; }
img, svg, video, canvas { display: block; max-width: 100%; }
button, input, select, textarea { font: inherit; color: inherit; }
button { background: none; border: 0; cursor: pointer; }
a { color: inherit; text-decoration: none; }

html {
  background: var(--c-bg);
  -webkit-text-size-adjust: 100%;
  /* Anchors are scrolled by JS so the sticky nav + channel head are cleared
     properly; native smooth here would fight that. */
  scroll-behavior: auto;

  /* The native scrollbar is suppressed and .rail replaces it. Two conditions
     have to hold for that to be legitimate rather than just fashionable, and
     both do: the replacement carries the SAME information (position in the
     document, and now the channel map as well), and nothing about scrolling
     itself changes — wheel, trackpad, touch, space, Page Down, Home/End and
     find-in-page all behave exactly as before. Only the painted furniture is
     ours. */
  scrollbar-width: none;
  -ms-overflow-style: none;
}
html::-webkit-scrollbar { width: 0; height: 0; display: none; }

body {
  margin: 0;
  background: var(--c-bg);
  color: var(--c-fg);
  font-family: var(--f-display);
  font-size: var(--t-bd);
  font-weight: 400;
  line-height: var(--lh-body);
  letter-spacing: var(--tr-body);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  overflow-x: clip;
}
body.no-scroll { overflow: hidden; }

::selection { background: var(--c-sel); color: var(--c-fg); }

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

/* =============================================================================
   TYPE — rule 2 lives here. Not one heading sets text-transform.
   ============================================================================= */

h1, h2, h3, h4 {
  font-family: var(--f-display);
  font-weight: 600;
  line-height: var(--lh-snug);
  letter-spacing: var(--tr-head);
  text-wrap: balance;
}

.mega {
  font-size: var(--t-mega);
  font-weight: 700;
  line-height: var(--lh-tight);
  letter-spacing: var(--tr-mega);
  margin-left: -0.035em;   /* optical: kill the first cap's sidebearing */
}

.lede { font-size: var(--t-md); color: var(--c-muted); max-width: 58ch; }
.muted { color: var(--c-muted); }
.tnum { font-variant-numeric: tabular-nums; }

/* The console voice. Every label, count, year, tag and button on the site. */
.mono {
  font-family: var(--f-mono);
  font-size: var(--t-xs);
  font-weight: 500;
  letter-spacing: var(--tr-label);
  text-transform: uppercase;
  line-height: 1;
  color: var(--c-muted);
  font-variant-numeric: tabular-nums;
}
.mono--accent { color: var(--c-accent-txt); }
.mono--faint  { color: var(--c-faint); }
.mono--fg     { color: var(--c-fg); }

/* =============================================================================
   LAYOUT
   ============================================================================= */

.wrap {
  width: 100%;
  max-width: var(--maxw);
  margin-inline: auto;
  padding-inline: var(--gut);
}

/* -----------------------------------------------------------------------------
   THE CHANNEL STRIP — the one structural idea, repeated everywhere.

   Every section of this site is a channel: numbered, labelled the same way,
   with a count on the right. That repetition is the whole reason the page
   reads as one instrument rather than a stack of unrelated blocks, and it is
   also the navigation — the head is sticky, so whichever channel you are
   inside names itself directly under the nav for as long as you are in it,
   and the next channel's head pushes the old one out.

   Alternating tone on top of that means two adjacent channels are never the
   same shade, so a boundary is legible even from a scroll thumbnail.
----------------------------------------------------------------------------- */
.ch {
  --ch-bg: var(--c-bg);
  position: relative;
  padding-block: var(--ch);
  border-top: 1px solid var(--c-line);
  scroll-margin-top: calc(var(--nav-h) + 46px);
}
.ch:nth-of-type(even) { --ch-bg: var(--c-panel); background: var(--c-panel); }

.ch__head {
  position: sticky;
  top: var(--nav-h);
  z-index: 5;
  display: flex;
  align-items: center;
  gap: var(--s-4);
  /* Pulled up through ALL of the channel's top padding, not 52% of it, so the
     plate sits flush on the channel's top rule.

     At -0.52 it stopped half way and left ~44px of bare channel between the
     boundary and the plate — which meant the head sat in one place while it
     was free in the flow and in a completely different one the moment it
     stuck to the nav, 44px higher. A header plate that moves relative to its
     own header when it pins is the thing that reads as "too much padding at
     the top": there was no padding, there was a gap. Flush in both states. */
  margin-top: calc(var(--ch) * -1);
  margin-bottom: clamp(24px, 2.8vw, 40px);
  padding-block: 14px;
  background: var(--ch-bg);
  border-bottom: 1px solid var(--c-line);
  /* Break out of .wrap's padding so the rule reaches both edges of the rack,
     then pad back to the CHROME inset rather than the content one. The head
     plate is the channel's label, the same order of thing as the nav above it
     — it belongs to the frame, and lining its ID up with the nav's wordmark
     instead of with the prose is what makes the numbering read as one spine
     running down the page. */
  margin-inline: calc(var(--gut) * -1);
  padding-inline: var(--gut-chrome);
}
/* `line-height: 1` on all three, exactly as `.mono` sets it. These three set
   their own font properties instead of carrying the class, and so were silently
   inheriting the BODY line-height of 1.62 — an 18.4px line box around 11.4px
   text. The plate measured 47px tall when its contents are 11px of type inside
   14px of padding, which is why it read as loose at the top. */
.ch__id {
  font-family: var(--f-mono); font-size: var(--t-xs); font-weight: 500;
  letter-spacing: var(--tr-label); line-height: 1;
  color: var(--c-accent-txt);      /* rule 4: this is "where you are" */
  flex: none;
}
.ch__name {
  font-family: var(--f-mono); font-size: var(--t-xs); font-weight: 500;
  letter-spacing: var(--tr-label); text-transform: uppercase; line-height: 1;
  color: var(--c-fg);
  flex: none;
}
.ch__rule { position: relative; flex: 1; height: 1px; background: var(--c-line); min-width: 16px; }
.ch__meta {
  font-family: var(--f-mono); font-size: var(--t-xs);
  letter-spacing: var(--tr-label); text-transform: uppercase; line-height: 1;
  color: var(--c-faint); flex: none;
  font-variant-numeric: tabular-nums;
}
/* A channel's meta is a secondary readout — "6 projects", "2 min read" — and the
   head plate has no room for it beside the number and the name on a phone.
   THE FOOTER'S IS NOT: it is the four cities, which appear exactly once on the
   whole site, so hiding it there deletes information rather than decluttering.
   It stacks under the availability line instead. */
@media (max-width: 620px) {
  .ch .ch__meta,
  .say__head .ch__meta { display: none; }

  .foot__head {
    flex-wrap: wrap;
    justify-content: center; text-align: center;
    row-gap: 4px;
  }
  .foot__head .ch__rule { display: none; }   /* nothing left to bracket */
  .foot__head .ch__meta { flex: 1 0 100%; }
}

/* -----------------------------------------------------------------------------
   THE HEAD PLATE, POWERED UP.

   The head is the one piece of chrome that is always on screen while you read a
   channel, so it is where the channel's own progress belongs: app.js writes a
   0–1 `--p` onto each <section>, and the rule between the label and the count
   fills amber as you travel through that channel. It is the honest number the
   old bottom bar carried, moved to the element you are already looking at, and
   it costs no extra markup — the rule was already there.

   The plate also assembles itself when its channel arrives: id, then label,
   then the rule drawing across, then the count. Everything is scoped under
   `.ch .ch__head` on purpose — the same classes are reused in the mobile menu
   and the footer, and an unscoped rule would leave those permanently hidden
   behind an observer that never fires on them.
----------------------------------------------------------------------------- */
/* 2px inside the head plate, where it is a readout, and 1px everywhere else,
   where it is only a divider. At a hairline the amber is technically correct
   and practically invisible — a progress bar you have to look for is not one. */
.ch .ch__head .ch__rule { height: 2px; border-radius: 2px; }
.ch .ch__head .ch__rule::after {
  content: '';
  position: absolute; inset: 0;
  background: var(--c-accent);
  border-radius: 2px;
  transform: scaleX(var(--p, 0));
  transform-origin: left;
  /* No transition: --p is written every frame from the scroll position, so it
     is already as smooth as the scroll is. A transition on top would make the
     fill lag behind the page it is measuring. */
}

.js .ch .ch__head > * {
  opacity: 0;
  transform: translate3d(0, -8px, 0);
}
.js .ch .ch__head .ch__rule { transform: scaleX(0); transform-origin: left; }

.js .ch.live .ch__head > * {
  opacity: 1;
  transform: none;
  transition: opacity var(--d-3) var(--e-out) var(--hd, 0ms),
              transform var(--d-4) var(--e-spring) var(--hd, 0ms);
}
.js .ch.live .ch__head .ch__id   { --hd: 40ms; }
.js .ch.live .ch__head .ch__name { --hd: 110ms; }
.js .ch.live .ch__head .ch__rule { --hd: 170ms; transform: scaleX(1); }
.js .ch.live .ch__head .ch__meta { --hd: 300ms; }

/* The human-readable title of the channel, under its header plate. */
.ch__title {
  font-size: var(--t-xxl);
  font-weight: 600;
  line-height: 1.08;
  letter-spacing: var(--tr-head);
  max-width: 20ch;
}
.ch__intro { color: var(--c-muted); max-width: 58ch; margin-top: var(--s-4); font-size: var(--t-md); }
.ch__lead { margin-bottom: clamp(28px, 3.2vw, 48px); }

/* =============================================================================
   METERS + WAVEFORMS — the console's two data displays.

   Both are only ever used for values that are genuinely numeric. A meter next
   to a skill ("PHP ▮▮▮▮▯") is a made-up number wearing a uniform; a meter next
   to 76,681 streams is just a faster way to read 76,681.
   ============================================================================= */

/* align-items must stay `stretch`. With `flex-end` the segments size to their
   content, and an empty <i> has no content — every bar resolved to 0px tall
   and the scaleY that carries the value had nothing to scale. */
.meter {
  display: flex; gap: 3px; align-items: stretch;
  height: 18px;
}
/* app.js writes each segment's height inline, because the height IS the data.
   The stagger comes from --md so the meter fills left to right like a real one. */
.meter i {
  flex: 1; min-width: 2px; height: 100%;
  background: var(--c-line);
  border-radius: 1px;
  transform: scaleY(.12);
  transform-origin: bottom;
  transition: transform var(--d-3) var(--e-out) var(--md, 0ms), background var(--d-2);
}
.meter i.lit { background: var(--c-fg); }
/* the top lit segments are the peak — rule 4 allows accent, this is a level */
.meter i.pk { background: var(--c-accent); }

/* Waveforms are generated into these by app.js as a single <svg> path, so a
   72-bar waveform is one DOM node rather than 72.

   Because it is one path and not 56 elements, it cannot stagger its bars — so
   it does the thing a waveform actually does instead, and RENDERS, wiping in
   left to right as if the file were being scanned. One clip-path transition on
   one node, which is both cheaper and more truthful than 56 staggered bars. */
.wave { display: block; width: 100%; height: 34px; color: var(--c-line-2); }
.wave svg {
  width: 100%; height: 100%; display: block;
  clip-path: inset(0 100% 0 0);
}
.wave.drawn svg {
  clip-path: inset(0 -1% 0 0);
  transition: clip-path var(--d-5) var(--e-out);
}
.wave path { fill: currentColor; }

/* =============================================================================
   CONTROLS — rule 1 (mono), rule 2 (uppercase because mono), rule 3 (--r-ctl)
   ============================================================================= */

/* app.js writes --mx/--my (a few px, tracking the pointer inside the control)
   so a button leans toward the cursor before you reach it. It is the smallest
   possible amount of physics and it is what makes a control feel like an
   object rather than a rectangle that changes colour. */
.btn {
  --btn-bg: transparent;
  --btn-fg: var(--c-fg);
  --btn-bd: var(--c-line-2);
  position: relative;
  display: inline-flex; align-items: center; justify-content: center;
  gap: var(--s-3);
  padding: 13px 20px;
  min-height: 44px;
  border: 1px solid var(--btn-bd);
  border-radius: var(--r-ctl);
  background: var(--btn-bg);
  color: var(--btn-fg);
  font-family: var(--f-mono);
  font-size: var(--t-xs); font-weight: 500;
  letter-spacing: var(--tr-label); text-transform: uppercase;
  overflow: hidden; isolation: isolate;
  transform: translate3d(var(--mx, 0px), var(--my, 0px), 0);
  transition: color var(--d-2) var(--e-out),
              border-color var(--d-2) var(--e-out),
              transform var(--d-2) var(--e-out);
}
.btn::before {
  content: ''; position: absolute; inset: 0;
  background: var(--c-fg);
  transform: translateY(101%);
  transition: transform var(--d-3) var(--e-out);
  z-index: -1;
}
.btn:hover { color: var(--c-bg); border-color: var(--c-fg); }
.btn:hover::before { transform: translateY(0); }
.btn:active { transform: translate3d(var(--mx, 0px), var(--my, 0px), 0) scale(.975); transition-duration: 90ms; }
/* Rule 4: the one primary action in a view. Never two on screen at once. */
.btn--rec { --btn-bg: var(--c-accent); --btn-fg: var(--c-accent-ink); --btn-bd: var(--c-accent); }
.btn--rec::before { background: var(--c-fg); }
.btn--rec:hover { color: var(--c-bg); border-color: var(--c-fg); }
.btn--sm { padding: 9px 14px; min-height: 36px; }
.ico { width: 1em; height: 1em; flex: none; transition: transform var(--d-2) var(--e-out); }
.btn:hover .ico { transform: translate(3px, -3px); }

.tag {
  display: inline-flex; align-items: center;
  padding: 5px 9px;
  border: 1px solid var(--c-line);
  border-radius: var(--r-ctl);
  font-family: var(--f-mono); font-size: var(--t-xs);
  letter-spacing: .08em; text-transform: uppercase; color: var(--c-muted);
  font-variant-numeric: tabular-nums;
}

.ln {
  background-image: linear-gradient(currentColor, currentColor);
  background-size: 0% 1px; background-position: 0 100%; background-repeat: no-repeat;
  transition: background-size var(--d-2) var(--e-out);
  padding-bottom: 2px;
}
.ln:hover { background-size: 100% 1px; }

/* A live indicator. Availability is genuinely a state, so amber is correct. */
.led {
  width: 6px; height: 6px; border-radius: 50%;
  background: var(--c-accent);
  box-shadow: 0 0 0 0 var(--c-accent-dim);
  flex: none;
  animation: led 2.6s var(--e-inout) infinite;
}
@keyframes led {
  0%, 100% { opacity: 1; box-shadow: 0 0 0 0 var(--c-accent-dim); }
  50%      { opacity: .45; box-shadow: 0 0 0 5px transparent; }
}

/* =============================================================================
   THE TAPE — PARKED. Its markup in index.html is commented out; these styles
   are kept intact so bringing it back is a one-block uncomment.

   The strip of moving text that sat between the hero and CH 01.

   Its speed is scroll-coupled: app.js writes `--tape-rate`, which is 1 when the
   page is still and rises with scroll velocity. That is the whole trick — a
   marquee running at a constant speed is a widget with its own clock sitting on
   the page, whereas one that surges when you scroll reads as part of the same
   mechanism you are driving.
   ============================================================================= */

.tape {
  overflow: hidden;
  border-block: 1px solid var(--c-line);
  background: var(--c-panel);
  padding-block: 13px;
  /* full-bleed: it is a strip across the rack, not a paragraph in a column */
  mask-image: linear-gradient(90deg, transparent, #000 7%, #000 93%, transparent);
}
.tape__run {
  display: flex; width: max-content;
  animation: tape calc(34s / var(--tape-rate, 1)) linear infinite;
}
.tape__set {
  display: flex; align-items: center; gap: var(--s-5);
  padding-right: var(--s-5);
  flex: none;
}
.tape b {
  font-family: var(--f-mono); font-size: var(--t-xs); font-weight: 500;
  letter-spacing: var(--tr-label); text-transform: uppercase;
  color: var(--c-muted); white-space: nowrap;
  transition: color var(--d-2);
}
/* the separator is a dot, not the accent — rule 4, this is not a state */
.tape i { width: 4px; height: 4px; border-radius: 50%; background: var(--c-faint); flex: none; }
.tape:hover b { color: var(--c-fg); }
@keyframes tape { to { transform: translate3d(-50%, 0, 0); } }

/* =============================================================================
   MICRO-INTERACTIONS

   None of these are new elements — they are the existing ones acknowledging
   the pointer. The site read as static because almost nothing did.
   ============================================================================= */

/* nav: the underline that the active channel shows permanently, previewed on
   hover, so hovering feels like arriving early rather than like a colour swap */
.nav__link::after {
  content: ''; position: absolute; left: 0; right: 0; bottom: -1px;
  height: 1px; background: currentColor;
  transform: scaleX(0); transform-origin: right;
  transition: transform var(--d-2) var(--e-out);
}
.nav__link:hover::after { transform: scaleX(1); transform-origin: left; }
.nav__link.on::after { transform: scaleX(1); background: var(--c-accent); }

.nav__icon .ico { transition: transform var(--d-3) var(--e-spring); }
.nav__icon:hover .ico { transform: rotate(35deg); }

.tag { transition: color var(--d-2), border-color var(--d-2), transform var(--d-2) var(--e-out); }
.svc__card:hover .tag { border-color: var(--c-line-2); color: var(--c-fg); }


/* HIGHLIGHTER hover: a solid bar swipes across the link and the text inverts to
   the page colour, like a marker drawn over the word. The swipe is a `clip-path`
   inset reveal on a `::before` (composited, smooth); z-index -1 + isolation keep
   it behind the text. Because the fill is --c-fg and the text flips to --c-bg,
   it reads correctly in BOTH themes (both tokens swap together). The negative
   margin cancels the padding so labels stay left-aligned at rest, and nothing
   in the row moves. */
.foot__col a {
  position: relative; isolation: isolate;
  display: inline-block; width: max-content;
  padding: 3px 10px; margin-left: -10px; border-radius: var(--r-ctl);
  color: var(--c-muted);
  transition: color var(--d-2) var(--e-out);
}
.foot__col a::before {
  content: ''; position: absolute; inset: 0; z-index: -1; border-radius: inherit;
  background: var(--c-fg);
  clip-path: inset(0 100% 0 0 round var(--r-ctl));
  transition: clip-path var(--d-2) var(--e-out);
}
.foot__col a:hover { color: var(--c-bg); }
.foot__col a:hover::before { clip-path: inset(0 0 0 0 round var(--z-rail)); }

.menu__item:hover { color: var(--c-accent-txt); }
.menu__item .go { transition: transform var(--d-2) var(--e-out); }
.menu__item:hover .go { transform: translate(4px, -4px); }

/* =============================================================================
   REVEAL — one IntersectionObserver in app.js adds .in.

   Four variants, because "everything fades up 16px" is what made the page feel
   like a slideshow of identical slides. What arrives should match what it is:

     (default)  copy and blocks rise and settle
     mask       headings wipe up out of their own baseline
     cut        rows and cards land from further out, with a hair of overshoot
     draw       rules and bars scale out from their left edge

   The whole thing is gated behind `.js` so a browser with scripting off gets a
   fully visible page instead of one waiting on an observer that never runs.

   No `will-change`. Setting it on every revealed element promotes dozens of
   layers before any of them has animated; transform and opacity transitions
   are composited anyway.
   ============================================================================= */

/* app.js writes --i — the element's index among everything that came into view
   in the SAME frame, not its position in its container — and the delay falls
   out of it. Multiplying here rather than in JS keeps the cascade rate a single
   token instead of a number sprinkled through the markup; the previous version
   hand-wrote `--rv:50ms`, `--rv:100ms` … on individual cards, so every new card
   needed a hand-picked number and no two groups quite matched. */
[data-rv] { --rv: calc(var(--i, 0) * var(--stagger)); }

.js [data-rv] {
  opacity: 0;
  transform: translate3d(0, 18px, 0);
}
.js [data-rv].in {
  opacity: 1; transform: none;
  transition: opacity var(--d-3) var(--e-out) var(--rv, 0ms),
              transform var(--d-4) var(--e-out) var(--rv, 0ms);
}

/* Headings — a wipe, not a fade: the line rises into view out of its own
   baseline rather than appearing.

   A MASK, not clip-path, and that is not a style preference. Chrome applies a
   target's own clip-path when it computes intersection, so a heading clipped
   to nothing never reports as intersecting, the observer never fires, and the
   heading stays hidden forever — which is exactly what happened here. A mask
   paints nothing but clips nothing, so the observer still sees a full-size box.

   The mask is twice the element's height with a hard edge across the middle:
   sliding it from `0 0` (the transparent half over the text) to `0 100%` (the
   opaque half) walks that edge from the bottom of the line to the top. */
.js [data-rv='mask'] {
  opacity: 1;
  transform: translate3d(0, 8px, 0);
  mask-image: linear-gradient(180deg, transparent 0 50%, #000 50% 100%);
  mask-size: 100% 200%;
  mask-repeat: no-repeat;
  mask-position: 0 0;
}
.js [data-rv='mask'].in {
  mask-position: 0 100%;
  transition: mask-position var(--d-5) var(--e-out) var(--rv, 0ms),
              transform var(--d-5) var(--e-out) var(--rv, 0ms);
}

/* rows and cards — further out, and landing with a little mass */
.js [data-rv='cut'] { opacity: 0; transform: translate3d(0, 34px, 0); }
.js [data-rv='cut'].in {
  transition: opacity var(--d-3) var(--e-out) var(--rv, 0ms),
              transform var(--d-5) var(--e-spring) var(--rv, 0ms);
}

/* rules, bars, dividers */
.js [data-rv='draw'] { opacity: 1; transform: scaleX(0); transform-origin: left; }
.js [data-rv='draw'].in {
  transition: transform var(--d-5) var(--e-out) var(--rv, 0ms);
}

/* flat — position is already correct, only the opacity should move */
.js [data-rv='f'] { transform: none; }

/* plate — the element does not move or fade at all; it BUILDS. The variant
   exists so the shared observer still hands out `.in`, while the entrance is
   drawn by the component itself (see `.role` in sections.css). Anything using
   it must draw its own arrival, or it will simply appear. */
.js [data-rv='plate'] { opacity: 1; transform: none; }

/* -----------------------------------------------------------------------------
   STAGED CONTAINERS — `data-stage`, for a group of PEER ITEMS that arrive
   together and should cascade through each other.

   WHY THIS IS NOT JUST `data-rv` ON EACH CHILD, and the distinction is the
   whole reason it exists. `data-rv` staggers by what lands in the SAME observer
   callback, deliberately: the discography is eight rows, and giving row eight a
   fixed index means it sits still for 400ms whenever it eases into view on its
   own. That is right for a list you scroll DOWN THROUGH. It is wrong for the
   six tags inside a role plate or the twenty-six chips in the skills bank,
   which are inside one box, cross the line as one object, and have a reading
   order of their own — there, an index within the container IS the cascade, and
   batching would fire them all on the same frame.

   So: one observer target (the container), children indexed by position, capped.
   app.js writes `--n`; nothing here needs to know how many children there are.

   `pop` IS FOR CONTAINERS WHOSE CHILDREN ALREADY OWN THEIR OPACITY. The skills
   chips are dimmed to .26 by the solo filter at a specificity this cannot beat
   (`.kit[data-cat]:not([data-cat='all']) .chip` is four classes deep), so an
   opacity-based entrance would be silently overruled and the chips would start
   at .26 and jump. Rather than fight it, the two mechanisms are given a property
   each — **the entrance owns `transform`, the solo owns `opacity`** — and they
   compose instead of colliding. A scale also grows from the chip's own centre,
   so nothing slides out over the bar above it on the way in.
----------------------------------------------------------------------------- */
[data-stage] > * { --sn: calc(var(--n, 0) * var(--stagger)); }

.js [data-stage]:not([data-stage='pop']) > * {
  opacity: 0;
  transform: translate3d(0, 14px, 0);
}
.js [data-stage]:not([data-stage='pop']).in > * {
  opacity: 1; transform: none;
  transition: opacity var(--d-3) var(--e-out) var(--sn, 0ms),
              transform var(--d-4) var(--e-out) var(--sn, 0ms);
}

/* A tighter step: a wall of chips wants a wave crossing it, not a queue. */
[data-stage='pop'] > * { --sn: calc(var(--n, 0) * var(--stagger) * .38); }
.js [data-stage='pop'] > * { transform: scale(.82); }
/* NO `transition` HERE, DELIBERATELY. The children of a `pop` container already
   carry their own timing (`.chip` lists `transform` among the things it
   animates, with `var(--sn)` as the delay) precisely because a state rule
   deeper in the cascade would replace a shorthand written here — which is what
   happened, and the symptom was chips jumping from .82 to 1 in a single frame
   with nothing in the stylesheet obviously wrong. This rule sets the VALUE and
   lets the element own the timing. */
.js [data-stage='pop'].in > * { transform: none; }

/* =============================================================================
   NAV — the master strip

   Numbered exactly like the channels it points at, because the numbering IS
   the continuity device. The current channel is marked in amber by the scroll
   spy in app.js. It does not auto-hide: a header that hides on scroll down
   means you cannot leave the section you are in without scrolling back up.
   ============================================================================= */

.nav {
  position: fixed; top: 0; left: 0; right: 0;
  z-index: var(--z-nav);
  display: flex; align-items: center;
  gap: clamp(14px, 2.6vw, 40px);
  height: var(--nav-h);
  /* --gut-frame, not --gut-chrome: the bar spans the glass but the rack behind
     it stops at --maxw, so past that width a flat inset left the wordmark and
     the Connect button hard against the window edges with the channel numbers
     they align with sitting a long way inboard. Most visible at 50% zoom, which
     is just a very wide viewport in disguise. See --gut-frame in tokens.css. */
  padding-inline: var(--gut-frame);
  border-bottom: 1px solid transparent;
  transition: background var(--d-2), border-color var(--d-2), backdrop-filter var(--d-2);
}
.nav.solid {
  background: color-mix(in srgb, var(--c-bg) 88%, transparent);
  backdrop-filter: blur(18px) saturate(1.2);
  border-bottom-color: var(--c-line);
}
/* -----------------------------------------------------------------------------
   THE WORDMARK — a lamp passing through the letters.

   The split-flap that was here read as a departure board, which is a different
   machine from the one this site is. THE SITE'S OWN MOVE is a value arriving
   inside letterforms: it is what the boot readout does when the level fills the
   name, and it is what a meter does when the lit segment travels. So the
   wordmark does the same thing — an amber band sweeps through the glyphs on
   hover via `background-clip: text`, exactly the technique the splash uses,
   and the whole mark blooms in the accent behind it.

   `drop-shadow` and not `text-shadow`: with the fill transparent and the paint
   coming from a clipped background, only a filter sees the actual letterforms.
   A text-shadow would draw around a box of nothing.

   Plain text, no per-character boxes. The flap version wrapped every letter in
   an `overflow: hidden` cell, and an inline-block with clipped overflow takes
   its BOTTOM MARGIN EDGE as its baseline — which is why the mark sat at the
   wrong height and looked oddly scaled next to the links.
----------------------------------------------------------------------------- */
/* ONE CELL, TWO OCCUPANTS, AND THE BOX CLOSES UP AROUND WHICHEVER IS SHOWING.

   The mark and the word are stacked on the same grid area so neither is offset
   by the other, and the BRAND'S OWN WIDTH is what changes — from the mark's
   width at the top of the page to the wordmark's once you leave it, animated,
   with the hairline and the seven channels sliding along behind it.

   THE FIRST VERSION FROZE THE BOX AT THE WORDMARK'S WIDTH so nothing beside it
   could move. That was the safe call and it was the wrong one: it left the mark
   sitting at the left of a slot four times its size, ~97px of nothing before the
   detent, and the bar read as though something had failed to load. The user
   asked for the space back and said the rest of the strip was welcome to move.
   It is one transition on one property, so it moves as a piece.

   THE TWO WIDTHS ARE MEASURED, NOT WRITTEN DOWN. `initBrand()` in app.js sets
   `--w-mark` and `--w-word` from the real boxes, after `document.fonts.ready`
   and again on resize — the wordmark is Space Grotesk at a `clamp()`ed size, so
   any number typed here would be right at one viewport and wrong at the rest,
   and would silently clip the name the day the typeface changed.

   `0fr`/`1fr` grid tracks were tried first, which is the usual no-JS way to
   animate a box to its content width. They do not collapse inside an
   intrinsically-sized grid: measured, the track held 115px through `auto 1fr`,
   `1fr 1fr` AND `1fr 0fr`. That trick needs a container with a definite width.

   The collapse is gated on `.js` so a browser with scripting off — which never
   gets `.solid` either, and so would otherwise be stuck on the collapsed
   state — keeps the full wordmark. */
.nav__brand {
  display: inline-grid; align-items: center; justify-items: start;
  flex: none;
  line-height: 1;
  overflow: hidden;
  /* Scaled from its own left edge so the hover growth pushes into the brand's
     whitespace, not toward the separator. transform never reflows siblings, so
     the nav links do not move. */
  transform-origin: left center;
  transition: width var(--d-4) var(--e-out),
              transform var(--d-2) var(--e-spring),
              filter var(--d-3) var(--e-out);
}
.nav__brand > * { grid-area: 1 / 1; }
.js .nav__brand { width: var(--w-word, max-content); }
.js .nav:not(.solid) .nav__brand { width: var(--w-mark, 1.1em); }
.nav__brand:hover,
.nav__brand:focus-visible {
  transform: scale(1.1);
  filter: drop-shadow(0 0 10px color-mix(in srgb, var(--c-accent) 55%, transparent))
          drop-shadow(0 0 2px color-mix(in srgb, var(--c-accent) 40%, transparent));
}

.nav__word {
  font-family: var(--f-display);
  font-weight: 700; font-size: var(--t-md);
  letter-spacing: var(--tr-head);
  white-space: nowrap;

  background-image: linear-gradient(100deg,
    var(--c-fg) 0 38%,
    var(--c-accent) 50%,
    var(--c-fg) 62% 100%);
  background-size: 320% 100%;
  background-position: 100% 0;              /* band parked off to the left */
  background-repeat: no-repeat;
  -webkit-background-clip: text;
          background-clip: text;
  -webkit-text-fill-color: transparent;
  transition: background-position var(--d-5) var(--e-out), opacity var(--d-2);
}
.nav__brand:hover .nav__word,
.nav__brand:focus-visible .nav__word { background-position: 0 0; }  /* …and travels across */

/* -----------------------------------------------------------------------------
   THE MARK, AND ITS ARRIVAL.

   The J draws itself: the outline traces, then the fill floods in behind it.
   That is the same sentence the rest of this site keeps saying — a value
   arriving inside a letterform — told for once with the letterform itself
   rather than with a band swept through type. It is the boot readout filling
   the name, the wordmark's lamp, Back-to-top's shimmer and the subscribe mark,
   in the one place on the page that IS a letter.

   IT REPLAYS EVERY TIME YOU COME BACK TO THE TOP, and that is free rather than
   engineered: the animation is declared on `.nav:not(.solid)`, so removing
   `.solid` starts it and adding `.solid` tears it down. There is no state to
   track and nothing to reset — scrolling back up genuinely is a fresh arrival at
   the opening screen, which is the moment it belongs to.

   `pathLength="1"` on the symbol is what lets the dash be written as literal
   numbers here (see index.html). The stroke is FATTENED for the trace and
   settles to the artwork's own 2 — at the mark's rendered size the real
   stroke-width is about half a pixel, which is a line you cannot watch travel.
-----------------------------------------------------------------------------  */
.nav__mark {
  width: auto; height: 1.32em;
  font-size: var(--t-md);                   /* so the em above is the word's size */
  fill: currentColor; stroke: currentColor; stroke-width: 2;
  color: var(--c-fg);
  transition: opacity var(--d-2), color var(--d-2);
}
.nav__brand:hover .nav__mark,
.nav__brand:focus-visible .nav__mark { color: var(--c-accent); }

/* THE SWAP. Opacity only — `display` or `visibility` would take the hidden half
   out of the accessibility tree, and the word is the link's accessible name in
   both states. `pointer-events` follows so the invisible one cannot be the thing
   a click lands on.

   THE OUTGOING HALF LEAVES FASTER THAN THE INCOMING ONE ARRIVES. They share a
   grid cell, so a symmetrical crossfade puts both on screen at 50% for a beat
   and you get a wordmark ghosting through the letterform — two marks in one
   place, which is the one thing a brand cannot do. Clearing the old one first
   costs nothing and the box is still travelling underneath either way. */
.nav:not(.solid) .nav__word,
.nav.solid .nav__mark {
  opacity: 0; pointer-events: none;
  transition-duration: var(--d-1);
  transition-delay: 0s;
}
.nav.solid .nav__word,
.nav:not(.solid) .nav__mark { transition-delay: var(--d-1); }

.nav:not(.solid) .nav__mark {
  animation: markDraw 1.3s both;
}
/* THE EASING IS PER SEGMENT, and it has to be. Declared once on the shorthand,
   `--e-out` applies to BOTH halves — and an ease-out spends most of a segment
   already arrived, so the trace was ~85% drawn 200ms in and the thing you were
   meant to watch had effectively happened before it started. The trace gets an
   ease-in-out (a stroke pulls away and settles, it does not lurch) and only the
   flood gets the site's own --e-out. */
@keyframes markDraw {
  0%   { stroke-dasharray: 1 1; stroke-dashoffset: 1; fill-opacity: 0; stroke-width: 3.6;
         animation-timing-function: cubic-bezier(.62, .02, .34, 1); }
  62%  { stroke-dasharray: 1 1; stroke-dashoffset: 0; fill-opacity: 0; stroke-width: 3.6;
         animation-timing-function: var(--e-out); }
  100% { stroke-dasharray: 1 1; stroke-dashoffset: 0; fill-opacity: 1; stroke-width: 2; }
}
/* A mark that traces itself every time the page returns to the top is motion for
   its own sake to anyone who has asked for less of it. It simply IS. */
@media (prefers-reduced-motion: reduce) {
  .nav:not(.solid) .nav__mark { animation: none; }
}

/* The strip's first detent: a machined hairline rather than a typed pipe —
   it fades at both ends so it reads as an edge on the panel, not a character
   someone left in the markup. */
.nav__sep {
  flex: none;
  width: 1px; height: 18px;
  margin-inline: clamp(10px, 1.4vw, 20px) 0;
  background: linear-gradient(180deg, transparent, var(--c-line-2) 22%, var(--c-line-2) 78%, transparent);
}
/* It used to be hidden at the top, because with the brand frozen at wordmark
   width the mark ended ~97px short of it and the hairline read as a rule come
   adrift. The brand closes up around the mark now, so the detent lands where it
   always should have — hard against whichever form the brand is in — and it
   simply stays. */

/* -----------------------------------------------------------------------------
   NAV STATUS — what replaced the second "Start a project" button.

   A master strip carries status, not a duplicate of the page's primary action.
   Both halves are real: the lamp is availability, and the clock is Jesvi's own
   local time rather than the visitor's, which is the only version of it that
   tells a stranger anything.
----------------------------------------------------------------------------- */
/* -----------------------------------------------------------------------------
   CONNECT — one control that is both the state and the action.

   It was a lamp, the word "Available", a divider and a running clock, and at
   anything under a wide window that is four things fighting for a strip that
   also has to hold seven channels: it wrapped onto two lines. The lamp still
   carries the state — it is the same amber "armed" light as the hero's — and
   the label is now the thing you would do about it. The clock was worth
   keeping but not here; it moved to the footer, where there is room for it.

   Same 36px box as .nav__icon beside it: two controls on one strip at two
   different heights reads as sloppy long before anyone can say why.
----------------------------------------------------------------------------- */
.nav__connect {
  display: inline-flex; align-items: center; gap: 9px;
  height: 36px;
  padding-inline: 14px;
  border: 1px solid var(--c-line-2);
  border-radius: var(--r-ctl);
  background: var(--c-glass);
  font-family: var(--f-mono); font-size: var(--t-xs); font-weight: 500;
  letter-spacing: var(--tr-label); text-transform: uppercase;
  line-height: 1; white-space: nowrap;
  color: var(--c-fg);
  transition: border-color var(--d-2), background var(--d-2), color var(--d-2);
}
.nav__connect:hover { border-color: var(--c-fg); background: var(--c-glass-2); }
.nav__connect .ico { transition: transform var(--d-2) var(--e-out); }
.nav__connect:hover .ico { transform: translate(3px, -3px); }
.nav__links { display: flex; align-items: center; gap: clamp(10px, 1.6vw, 24px); margin-right: auto; }
.nav__link {
  position: relative;
  display: inline-flex; align-items: baseline; gap: 6px;
  padding-block: 6px;
  font-family: var(--f-mono); font-size: var(--t-xs); font-weight: 500;
  letter-spacing: var(--tr-label); text-transform: uppercase;
  color: var(--c-muted);
  transition: color var(--d-1);
}
/* THE CHANNEL NUMBER, ON DEMAND.

   Deleting these outright was the other option and it is the wrong one: the
   numbering is the site's continuity device — the nav is numbered because the
   channel heads are, and the rail's regions are the same list again. Strip it
   and the nav becomes a nav.

   So the number is always THERE and only visible when it means something: on
   the channel you are in, and on the one you are pointing at. The width is
   reserved whether or not it shows, so nothing on the strip ever moves. */
.nav__link b {
  display: inline-block;
  width: 2.2ch;
  color: var(--c-faint); font-weight: 500;
  opacity: 0;
  transition: color var(--d-1), opacity var(--d-2) var(--e-out);
}
.nav__link:hover b, .nav__link.on b { opacity: 1; }
.nav__link:hover { color: var(--c-fg); }
/* Rule 4: amber's job in the nav is to say which channel you are in. */
.nav__link.on { color: var(--c-accent-txt); }
.nav__link.on b { color: var(--c-accent-txt); }
.nav__link.on::after {
  content: ''; position: absolute; left: 0; right: 0; bottom: -1px;
  height: 1px; background: var(--c-accent);
}

.nav__right { display: flex; align-items: center; gap: var(--s-3); flex: none; }
.nav__icon {
  width: 36px; height: 36px; flex: none;
  display: grid; place-items: center;
  border: 1px solid var(--c-line); border-radius: var(--r-ctl);
  color: var(--c-muted);
  transition: color var(--d-1), border-color var(--d-1), background var(--d-1);
}
.nav__icon:hover { color: var(--c-fg); border-color: var(--c-line-2); background: var(--c-glass); }

.nav__burger {
  display: none;
  width: 36px; height: 36px; flex: none; position: relative;
  border: 1px solid var(--c-line); border-radius: var(--r-ctl);
}
.nav__burger i {
  position: absolute; left: 10px; right: 10px; height: 1.5px;
  background: currentColor; border-radius: 2px;
  transition: transform var(--d-2) var(--e-out);
}
.nav__burger i:nth-child(1) { top: 14px; }
.nav__burger i:nth-child(2) { top: 20px; }
body.menu-open .nav__burger i:nth-child(1) { transform: translateY(3px) rotate(45deg); }
body.menu-open .nav__burger i:nth-child(2) { transform: translateY(-3px) rotate(-45deg); }

/* THE X IS FILLED AMBER WHILE THE SHEET IS OPEN, and this is rule 4 being
   obeyed rather than bent. The accent marks STATE, and "the menu is open" is
   the most consequential state the interface has: it is covering the entire
   page, and the one control that undoes it should be the loudest thing on the
   screen. Everywhere else the burger is a quiet outline, because "there is a
   menu" is not a state, it is a fact.

   The bars flip to `--c-bg` so they read out of the fill rather than on it. */
body.menu-open .nav__burger {
  background: var(--c-accent);
  border-color: var(--c-accent);
  color: var(--c-bg);
}
.nav__burger { transition: background var(--d-2), border-color var(--d-2), color var(--d-2); }

@media (max-width: 1000px) {
  .nav__links { display: none; }
  .nav__burger { display: block; }
  .nav__brand { margin-right: auto; }
  .nav { background: color-mix(in srgb, var(--c-bg) 92%, transparent); backdrop-filter: blur(16px); }
}

/* The connect control is the first thing to go when the strip runs out of
   room — the eight numbered channels are the navigation, so they stay. Below
   1000px the links are gone and the burger takes over.

   THE BREAKPOINT IS MEASURED, NOT GUESSED, and the thing to measure is
   SPILL — how far `.nav__right` sticks past the bar's content edge into the
   gutter — never `scrollWidth`. The padding absorbs the overflow, so a bar
   whose contents have already burst their content box still reports
   `scrollWidth === clientWidth` and looks fine to any overflow check; the only
   visible symptom is the connect button drifting out of line with the content
   column beneath it. At 1080 (the old value) it was spilling by ~90px, and
   when --gut widened to 5.6vw it went past the padding edge entirely.
   The threshold moved twice for this reason and both numbers were measured,
   never guessed: 1320 while the bar was still taking the content margin, then
   **1230** once the nav moved onto the tighter --gut-chrome and got ~70px of
   its width back. Spill at the current inset: 1180 → 31px, 1200 → 17px,
   1230 → 0. Re-run it if either gutter changes or a ninth channel is added. */
@media (max-width: 1230px) { .nav__connect, .nav__sep { display: none; } }

/* -----------------------------------------------------------------------------
   MENU — the phone version of the whole console.

   The open state is `body.menu-open`, deliberately NOT `body.menu`: the panel
   itself is `.menu`, and a state class that shares a name with a component
   class beneath it makes `<body class="menu">` match every rule in this block
   — which would give the whole document position:fixed and visibility:hidden.
----------------------------------------------------------------------------- */
.menu {
  position: fixed; inset: 0; z-index: var(--z-menu);
  background: var(--c-bg);
  display: flex; flex-direction: column;
  padding: calc(var(--nav-h) + 14px) var(--gut-frame) max(26px, env(safe-area-inset-bottom));
  overflow-y: auto; overscroll-behavior: contain;
  clip-path: inset(0 0 100% 0);
  transition: clip-path var(--d-3) var(--e-inout);
  visibility: hidden;
}
body.menu-open .menu { clip-path: inset(0 0 0 0); visibility: visible; }
.menu__list { display: flex; flex-direction: column; border-top: 1px solid var(--c-line); }
.menu__item {
  display: flex; align-items: baseline; gap: var(--s-4);
  padding-block: 15px;
  border-bottom: 1px solid var(--c-line);
  font-family: var(--f-display); font-weight: 600;
  font-size: clamp(1.35rem, 5.6vw, 1.9rem); line-height: 1.14;
  letter-spacing: var(--tr-head);
  opacity: 0; transform: translateY(14px);
  transition: opacity var(--d-3) var(--e-out), transform var(--d-3) var(--e-out), color var(--d-1);
}
body.menu-open .menu__item { opacity: 1; transform: none; transition-delay: var(--md, 0ms); }
.menu__item i {
  font-family: var(--f-mono); font-style: normal; font-size: var(--t-xs); font-weight: 500;
  letter-spacing: var(--tr-label); color: var(--c-faint); flex: none;
}
.menu__item .go { margin-left: auto; color: var(--c-faint); }
.menu__sec { margin-top: var(--s-6); }
.menu__rows { display: flex; flex-direction: column; border-top: 1px solid var(--c-line); }
.menu__row {
  display: flex; align-items: center; justify-content: space-between; gap: var(--s-4);
  padding-block: 14px; border-bottom: 1px solid var(--c-line);
  color: var(--c-muted); transition: color var(--d-1);
}
.menu__row:hover { color: var(--c-fg); }
.menu__row b { font-weight: 500; color: var(--c-fg); font-size: var(--t-md); }
/* THE TAIL OF THE SHEET CENTRES; THE LIST DOES NOT.

   The seven channels are a LIST — numbered, ruled, read down a left edge — and
   centring a list destroys the one thing that makes it scannable. Everything
   below it is not a list: an icon bay and a line of cities are both blocks, and
   a block at the foot of a full-screen sheet reads as a sign-off when it is
   centred and as an unfinished column when it is not. Same reasoning as the
   phone footer, one screen further down. */
.menu__foot {
  margin-top: auto;
  padding-top: var(--s-6);
  text-align: center;
}
/* Same icon bay as the footer, same component — a row of destinations is a row
   of destinations wherever it appears, and giving the menu its own would be two
   things that have to be kept looking alike by hand. */
.menu__social {
  display: flex; flex-wrap: wrap; justify-content: center;
  gap: var(--s-3); padding-top: var(--s-4);
}

/* AIR ABOVE THE FIRST CHANNEL, BUT ONLY WHERE THERE IS ROOM FOR IT. The sheet
   is sized to fit without scrolling (see the menu block further down), so this
   cannot be unconditional — `max-height` buys it back only on the taller
   phones, and the short ones keep every pixel for the content. */
@media (min-height: 780px) {
  .menu__list { margin-top: clamp(10px, 2.4vh, 26px); }
}

/* -----------------------------------------------------------------------------
   THE BAR AND THE SHEET ON A PHONE.

   TAP TARGETS FIRST. The theme button and the burger were 36×36 — fine for a
   mouse, under every platform's 44px minimum for a finger, and they sit in the
   top corner where a thumb is already at full stretch. They are 44 on a coarse
   pointer and stay 36 for a mouse, because a 44px control in a 58px desktop bar
   is a button that has eaten its own strip.

   `(pointer: coarse)` rather than a width query on purpose: the thing that
   decides how big a target must be is what is pointing at it, not how wide the
   window is.
----------------------------------------------------------------------------- */
@media (pointer: coarse) {
  .nav__icon,
  .nav__burger { width: 44px; height: 44px; }
  /* The bars have to grow with the box or they sit in the middle of it looking
     like a smaller button that missed. */
  .nav__burger i { left: 13px; right: 13px; }
  .nav__burger i:nth-child(1) { top: 18px; }
  .nav__burger i:nth-child(2) { top: 24px; }
}

/* THE MARK IS THE ONLY BRAND ON THE BAR HERE, so it gets to be bigger. At
   `--t-md`'s mobile size it rendered 18px wide — a favicon-sized glyph in a
   58px bar, and unlike the desktop version it never becomes a wordmark, because
   `.solid` swaps to text that a phone's bar has no room for (see below). */
@media (max-width: 620px) {
  .nav__mark { height: 1.75em; }

  /* THE WORDMARK NEVER TAKES OVER ON A PHONE. The swap exists because the hero
     stops saying the name once you scroll past it — but at this width the bar
     is mark + theme + burger with ~230px of nothing between, so the mark is not
     competing with anything and the wordmark would only crowd the two controls.
     Keeping the mark also means the brand's box never changes width, which is
     the one thing the desktop version has to work at. */
  .nav.solid .nav__mark { opacity: 1; pointer-events: auto; }
  .js .nav .nav__word { opacity: 0; pointer-events: none; }
  .js .nav__brand,
  .js .nav:not(.solid) .nav__brand { width: var(--w-mark, 1.1em); }
}

/* -----------------------------------------------------------------------------
   THE MENU SHEET. Seven channels, a contact block and a foot came to 1025px of
   content in an 844px screen, so the sheet scrolled — and a full-screen menu
   that scrolls hides the last thing on it behind a gesture nobody knows to make.

   Nothing is removed. The rows give up the padding they were carrying for a
   desktop-sized sheet, which is enough on its own: measured back under the fold
   at 844, 800 and 736 (the shortest phone still in circulation).
----------------------------------------------------------------------------- */
@media (max-height: 900px) and (max-width: 1000px) {
  .menu__item { padding-block: 11px; font-size: clamp(1.2rem, 5vw, 1.6rem); }
  .menu__sec { margin-top: var(--s-4); }
  .menu__row { padding-block: 11px; }
  .menu__foot { padding-top: var(--s-4); }
}

/* A SECOND, TIGHTER TIER FOR THE SHORT PHONES. A 375×667 still ran 69px over
   and the block above had nothing left to give without making the channels
   themselves cramped — so this one takes it from everything EXCEPT them. The
   seven channels are the reason the sheet exists; the contact rows, the icon
   bay and the sign-off are what it can afford to compress. */
@media (max-height: 700px) and (max-width: 1000px) {
  .menu { padding-top: calc(var(--nav-h) + 8px); }
  .menu__item { padding-block: 9px; }
  .menu__sec { margin-top: 10px; }
  .menu__row { padding-block: 8px; }
  .menu__row b { font-size: var(--t-bd); }
  .menu__social { padding-top: 10px; gap: 8px; }
  .menu__social .foot__ico { width: 38px; height: 38px; }
  .menu__foot { padding-top: 12px; }
}

/* =============================================================================
   THE RAIL — the scrollbar, rebuilt as a master fader.

   Same information as the bar that used to sit along the bottom of every
   screen, in a tenth of the footprint and on the edge where a scrollbar was
   always going to be anyway. It adds one thing a scrollbar cannot: DETENTS.
   Every channel gets a tick at its true position in the document, lit once you
   are past it and amber for the one you are in, so the rail is a map rather
   than a thumb.

   Desktop only. On a phone there is no persistent pointer to hover it with and
   the edge is where the system gestures live, so the progress line under the
   nav does the job instead.
   ============================================================================= */

.rail {
  position: fixed;
  right: 0; top: var(--nav-h); bottom: 0;
  z-index: var(--z-rail);
  width: var(--rail-w);
  display: flex; align-items: center; justify-content: center;
  padding-block: clamp(24px, 5vh, 56px);
  opacity: 0;
  cursor: pointer;
  /* the drag scrub owns the gesture once it starts */
  touch-action: none;
  transition: opacity var(--d-3) var(--e-out);
}
.rail.on { opacity: 1; }
/* The rail is invisible over the opening screen — and an invisible control must
   not still be swallowing clicks down the right-hand edge of the page. */
.rail:not(.on) { pointer-events: none; }
.rail.drag { cursor: grabbing; }

/* The track is the full travel; the fill is how much of it you have used.
   It thickens when the pointer is anywhere near, which is what makes the
   strip read as a target rather than a graphic. */
.rail__track {
  position: relative;
  width: 2px; height: 100%;
  background: var(--c-line);
  border-radius: 3px;
  transition: width var(--d-2) var(--e-out), background var(--d-2);
}
.rail.near .rail__track { width: 5px; background: var(--c-line-2); }
.rail__fill {
  position: absolute; inset: 0;
  background: var(--c-accent);
  border-radius: 2px;
  transform: scaleY(var(--p, 0));
  transform-origin: top;
  /* Written every frame from the scroll position, so it is already exactly as
     smooth as the scroll. A transition here would make it lag the page. */
}
/* Scroll fast and the fill blooms — the only thing on the site that reacts to
   scroll VELOCITY rather than position, which is what stops the rail reading
   as a static graphic that happens to change length. */
.rail__fill::after {
  content: '';
  position: absolute; inset: -1px;
  border-radius: 3px;
  background: var(--c-accent);
  filter: blur(4px);
  opacity: calc(var(--vel, 0) * .8);
}

/* -----------------------------------------------------------------------------
   REGIONS

   One per part of the document, each occupying its true share of the rail — so
   a long channel is a long stretch of bar and the rail's proportions are the
   page's proportions. They are laid over the track and bleed a few px either
   side of it, because a highlight exactly 2px wide is not a highlight.

   Hit testing does NOT happen here. app.js maps the pointer's Y onto the
   track and adds `.hot` to the region it lands in, which is what lets the
   sensing area be far wider than the 2px of bar you can actually see. A row of
   dots you had to hit precisely was the thing to fix.
----------------------------------------------------------------------------- */
.rail__segs { position: absolute; inset: 0; pointer-events: none; }
.rail__seg {
  position: absolute;
  left: -3px; right: -3px;
  top: calc(var(--y) * 100%);
  height: calc(var(--h) * 100%);
  border-radius: 4px;
  transition: background var(--d-2) var(--e-out);
}
.rail__seg.hot { background: color-mix(in srgb, var(--c-fg) 26%, transparent); }

/* The boundary between one region and the next. */
.rail__seg > i {
  position: absolute; top: 0; left: 50%;
  width: 4px; height: 4px;
  margin: -2px 0 0 -2px;
  border-radius: 50%;
  background: var(--c-faint);
  transition: transform var(--d-2) var(--e-spring), background var(--d-2);
}
.rail__seg.past > i { background: var(--c-fg); }
.rail__seg.on   > i { background: var(--c-accent); transform: scale(1.9); }
.rail.near .rail__seg > i { transform: scale(1.35); }
.rail.near .rail__seg.on > i { transform: scale(2); }
/* Only lit while the pointer is somewhere a click will actually register. */
.rail:not(.over) .rail__seg.hot { background: none; }
/* last, so pointing at something always wins over describing it */
.rail__seg.hot > i { background: var(--c-fg); transform: scale(2.3); }

/* -----------------------------------------------------------------------------
   THE FLAG — the name of whatever the pointer is next to.

   ONE element that follows the cursor, not a label per region. Per-region
   labels have to sit at their region's midpoint, which for a tall channel is
   nowhere near the pointer that summoned it; this reads at the height you are
   actually pointing at. It is `position: fixed` so it can be placed straight
   from clientY with no coordinate conversion.
----------------------------------------------------------------------------- */
.rail__flag {
  position: fixed;
  top: 0;
  right: calc(var(--rail-w) / 2 + 14px);
  padding: 6px 10px;
  border: 1px solid var(--c-line); border-radius: var(--r-ctl);
  background: color-mix(in srgb, var(--c-bg) 90%, transparent);
  backdrop-filter: blur(12px);
  color: var(--c-fg); white-space: nowrap; line-height: 1;
  opacity: 0;
  /* Placed by transform rather than by `top` so the vertical move is
     animatable: stepping from one region to the next glides instead of
     teleporting. The -50% is of the flag's own height, which centres it. */
  transform: translateX(8px) translateY(calc(var(--ly, 50vh) - 50%));
  transition: opacity var(--d-2) var(--e-out), transform var(--d-3) var(--e-out);
  pointer-events: none;
}
.rail.over .rail__flag { opacity: 1; transform: translateX(0) translateY(calc(var(--ly, 50vh) - 50%)); }

/* Sits in the rail's bottom padding, under the end of the track. `bottom: 0`
   plus translateY(100%) put it below the fixed element and off the screen. */
.rail__pct {
  position: absolute; bottom: 7px; left: 50%;
  transform: translateX(-50%);
  color: var(--c-faint);
  font-size: var(--t-xs);
}

@media (max-width: 1000px), (pointer: coarse) { .rail { display: none; } }

/* -----------------------------------------------------------------------------
   …and the same number as a hairline under the nav, for everywhere the rail is
   not. One global progress indicator visible at a time — two is clutter.
----------------------------------------------------------------------------- */
.nav::after {
  content: '';
  position: absolute; left: 0; right: 0; bottom: -1px;
  height: 2px;
  background: var(--c-accent);
  transform: scaleX(var(--p, 0));
  transform-origin: left;
  opacity: 0;
  transition: opacity var(--d-2);
}
@media (max-width: 1000px), (pointer: coarse) { .nav.solid::after { opacity: 1; } }

/* =============================================================================
   FOOTER — closes with the same header plate every channel opens with
   ============================================================================= */

.foot { border-top: 1px solid var(--c-line); background: var(--c-panel); }
/* The footer is chrome, not a channel: it takes the tighter frame inset rather
   than the rack's content margin. It builds its own column with `.wrap`, so the
   override has to land on that — `.foot` itself has no inline padding. */
.foot > .wrap { padding-inline: var(--gut-chrome); }
.foot__head {
  display: flex; align-items: center; gap: var(--s-4);
  padding-block: 14px; border-bottom: 1px solid var(--c-line);
}
/* THE MIDDLE OF THE FOOTER SITS ON THE CONTENT MARGIN, THE ENDS DO NOT.

   The footer is chrome, so it takes `--gut-chrome` like the nav — and the head
   ("Available for work" + the cities) and the end note are chrome too, so they
   stay out there against the glass with the rules that bracket them. But the
   block between them is the footer's actual content: a wordmark, a tagline and
   four columns of links, read the same way the rack above is read. It was the
   only content on the page not on the content margin.

   The extra inset is the DIFFERENCE between the two gutters, so this column
   lands exactly on the rack's — no third number, and it tracks automatically if
   either gutter is ever retuned. */
.foot__grid {
  display: grid; gap: var(--s-7);
  padding-block: clamp(32px, 4vw, 56px);
  padding-inline: calc(var(--gut) - var(--gut-chrome));
}
/* FOUR link columns now, not three, so the identity block has to give up some
   width — 1.6fr beside four .8fr tracks squeezed the link columns to the point
   where "JOS Animation Library" wrapped every row. The two-step breakpoint
   matters as much: at 900–1180px five tracks is too many, so the identity block
   takes a full row of its own and the four columns share the one below it. */
/* THE LINK COLUMNS HUG THEIR CONTENT; THE IDENTITY BLOCK TAKES THE SLACK.

   They were equal `1fr` tracks, and the four columns are nowhere near equal in
   content: "Web & app development" and "JOS Animation Library" are ~21
   characters while "Expertise" and "WhatsApp" are ~9. Equal tracks give the
   short ones a track-width of trailing whitespace each — the columns looked
   arbitrarily spaced because they were sized by arithmetic rather than by what
   was in them.

   `auto` sizes each track to its longest link, so the gaps between columns are
   the SAME gap rather than four different remainders, and every column reads as
   a tight block. The leftover goes to the one track that can use it. */
@media (min-width: 900px) {
  .foot__grid {
    grid-template-columns: repeat(4, auto);
    /* Nothing flexible on this row, so the free space has to be handed out
       explicitly or the four columns bunch against the left edge. */
    justify-content: space-between;
  }
  .foot__id { grid-column: 1 / -1; margin-bottom: var(--s-5); }
}
@media (min-width: 1180px) {
  .foot__grid {
    grid-template-columns: minmax(0, 1fr) repeat(4, auto);
    column-gap: clamp(32px, 4vw, 76px);
    justify-content: start;      /* the 1fr already absorbs the remainder */
  }
  .foot__id { grid-column: auto; margin: auto; padding-right: 6rem;  /* the 1fr track is the right margin */ }
}
/* WHILE THE IDENTITY BLOCK IS ON ITS OWN ROW, IT CENTRES.

   Between 900 and 1179 there is not room for the block AND four columns side by
   side, so it spans the full width above them — that is the rule directly above
   this one and it is the right call. What it left behind was an alignment that
   only made sense in the layout it had stopped being: the block kept the LEFT
   edge it uses as a column, so ~320px of wordmark, tagline and icons sat against
   the left of an 888px row with 570px of nothing beside it, reading as content
   stuck in a corner rather than as a masthead over the links.

   Same principle as the phone footer one breakpoint down: a block at the head of
   its own full-width row is a sign-off, and a sign-off centres. Left-aligning is
   correct only while something is actually sitting next to it — which is exactly
   what the 1180px rule above restores, and why this stops there.

   Owner-requested (2026-08-01): *"just like current, but i want it like, spaced
   left and right instead of top left stuck."* */
@media (min-width: 900px) and (max-width: 1179px) {
  .foot__id { text-align: center; }
  .foot__tag { margin-inline: auto; }          /* it is capped at 32ch */
  .foot__social { justify-content: center; }
}
.foot__mark { font-family: var(--f-display); font-weight: 700; font-size: clamp(1.6rem, 3.2vw, 2.4rem); line-height: 1.04; letter-spacing: var(--tr-mega); }
.foot__col { display: grid; gap: var(--s-3); align-content: start; }
/* Hierarchy: the category title is the bright anchor (--c-fg), the links sit a
   step back at --c-muted and are readable, not dimmed. Making the title louder
   rather than the links quieter keeps the links legible while still separating
   label from content. */
.foot__col > .mono { color: var(--c-fg); }
/* -----------------------------------------------------------------------------
   THE PHONE FOOTER. Below 900px the four link columns go and what is left
   centres — see the note in index.html for what they were and why they can go
   on a phone but not on a desktop.

   Everything here is inside the breakpoint. The desktop footer above is
   untouched, which is the point: the reader on a wide screen gets the site map
   they always had, and the reader on a phone gets a sign-off instead of twenty
   rows of links to places the menu already reaches.
----------------------------------------------------------------------------- */
.foot__logo,
.foot__links { display: none; }

@media (max-width: 899px) {
  .foot__grid { padding-block: clamp(30px, 4.2vw, 52px); }
  .foot__col { display: none; }

  .foot__id {
    display: grid; justify-items: center; text-align: center;
    gap: var(--s-3);
    max-width: 42ch;
    margin-inline: auto !important;   /* beats the inline margin-right on the element */
    margin-right: auto !important;
  }

  /* The mark, above the wordmark. Same `#i-mark` the nav uses — one piece of
     artwork, and the footer is the other place you look for whose site this is. */
  .foot__logo {
    display: block;
    color: var(--c-fg);
    transition: color var(--d-2), filter var(--d-3) var(--e-out);
  }
  .foot__logo svg { display: block; width: auto; height: clamp(30px, 4.4vw, 40px); fill: currentColor; }
  .foot__logo:hover,
  .foot__logo:focus-visible {
    color: var(--c-accent);
    filter: drop-shadow(0 0 12px color-mix(in srgb, var(--c-accent) 45%, transparent));
  }

  .foot__mark { font-size: clamp(1.45rem, 3.2vw, 2.1rem); }
  .foot__tag { max-width: 34ch; margin-top: 0; }
  .foot__social { justify-content: center; margin-top: var(--s-4); }

  /* Résumé / Terms / Privacy: on desktop these are in the More column, which is
     hidden here — so they come back as one centred line. Drawn dots on
     `a + a::before` rather than typed middots, the same call the doc pages' meta
     line makes, so a wrapped line cannot start on a bullet. */
  .foot__links {
    display: flex; flex-wrap: wrap; justify-content: center;
    align-items: center; gap: 6px var(--s-4);
    margin-top: var(--s-3);
  }
  .foot__links a {
    position: relative;
    font-family: var(--f-mono); font-size: var(--t-xs);
    letter-spacing: var(--tr-mono); text-transform: uppercase;
    color: var(--c-muted);
    padding-block: 8px;
    transition: color var(--d-2);
  }
  .foot__links a:hover, .foot__links a:focus-visible { color: var(--c-fg); }
  .foot__links a + a::before {
    content: ''; position: absolute; left: calc(var(--s-4) / -2 - 1.5px);
    top: 50%; width: 3px; height: 3px; margin-top: -1.5px;
    border-radius: 50%; background: var(--c-line-2);
  }

}

.foot__end {
  display: flex; flex-wrap: wrap; gap: var(--s-4) var(--s-5);
  align-items: center; justify-content: space-between;
  padding-block: var(--s-5);
  border-top: 1px solid var(--c-line);
}
/* Centred wherever the block above it is centred — same 899px line as the phone
   footer, so the note never disagrees with the identity block it sits under.
   AFTER the rule it overrides: a media query carries no extra specificity, so
   placing it earlier in the file (which is where it started) meant it lost the
   order tie-break and silently did nothing. */
@media (max-width: 899px) {
  .foot__end { justify-content: center; text-align: center; }
}
/* ON A PHONE THE NOTE IS THE LONGEST MONO STRING ON THE SITE and it wrapped
   mid-phrase — "…DESIGNED & BUILT / SINCERELY · JESVI.NET" — which reads as a
   line that ran out of room rather than as a sign-off. Dropping it a step and
   letting the two halves stack as their own centred lines fixes it without
   cutting any of the words. */
.foot__note { display: inline-flex; flex-wrap: wrap; justify-content: center; gap: 0 6px; }
.foot__note > span { white-space: nowrap; }
.foot__dot { font-style: normal; color: var(--c-faint); }

@media (max-width: 620px) {
  .foot__end {
    flex-direction: column;
    gap: 12px;
    font-size: var(--t-xs);
  }
  .foot__end-r { gap: var(--s-4); }
  /* Each half takes its own line, and the separator that joined them on one row
     goes with the row. `nowrap` on the halves is what guarantees the break lands
     at the phrase boundary and not four words into it. */
  .foot__note { flex-direction: column; gap: 5px; }
  .foot__dot { display: none; }
}

/* =============================================================================
   BOOT — the console powering up, then the racks lifting away.

   It waits on a real signal (webfonts) instead of a decorative timer, with a
   floor so it cannot flash and a ceiling so a slow font can never hold the
   page hostage. See index.html for the two failsafes that clear it without
   JavaScript.

   The exit is the part that matters. A splash that fades out is a splash you
   sat through; a splash that LEAVES — eight columns lifting off the screen in
   sequence, the last of them a quarter-second after the first — hands you the
   page underneath and doubles as the first beat of the hero's entrance.
   ============================================================================= */

body.booting { overflow: hidden; }

.boot {
  position: fixed; inset: 0; z-index: 200;
  display: grid; place-items: center;
  padding: var(--gut);
  visibility: visible;
}
/* Discrete property, so the delay is what keeps the overlay alive until the
   last column has cleared the top of the screen. */
.boot.done { pointer-events: none; visibility: hidden; transition: visibility 0s linear .95s; }

/* The curtain.

   The columns are --c-panel, NOT --c-bg, and that is the whole reason the exit
   reads at all: painted in the page's own background colour they lifted off a
   background of exactly the same colour, so the most expensive moment on the
   site was a black screen staying black. One step up the surface ramp, plus a
   hairline down each seam, and you can see eight faders being pulled. */
.boot__cols { position: absolute; inset: 0; display: flex; }
.boot__cols i {
  flex: 1;
  background: var(--c-panel);
  border-right: 1px solid var(--c-line);
  transform: translateY(0);
  transition: transform 660ms var(--e-inout) calc(var(--c) * 38ms);
}
.boot__cols i:last-child { border-right: 0; }
.boot.done .boot__cols i { transform: translateY(-101%); }

.boot__box {
  position: relative;
  display: grid; gap: clamp(16px, 2.2vh, 26px);
  /* Sized by the wordmark, so the status row underneath spans exactly the
     name and no further. A fixed width left the rule and the percentage
     running on past the end of the word with nothing under them. */
  width: max-content;
  /* On a phone the wordmark is narrower than "Patching channels · 100%", which
     crushed the rule between them to its 16px minimum and made the row read as
     a stub rather than a channel head. A floor gives the rule somewhere to go;
     it is well under the wordmark's width at every desktop size, so it has no
     effect there. */
  min-width: min(300px, 86vw);
  max-width: min(760px, 86vw);
  transition: opacity 260ms linear, transform 420ms var(--e-out);
}
.boot.done .boot__box { opacity: 0; transform: translateY(-14px); }

/* -----------------------------------------------------------------------------
   THE NAME AS THE METER

   Two copies of one word stacked in place: the element itself is the unlit
   scale, and its ::after is the lit level clipped to `--p`. app.js writes --p
   and nothing else — the meter, the readout and the wordmark are the same
   object, so there is no way for them to disagree.

   THREE THINGS COMPOSE HERE and the order matters:
     background-clip: text   paints the fill inside the glyphs only
     mask                    cuts that fill into console segments
     clip-path               reveals it left to right as the level rises
   The mask and the clip apply to the painted result, so segmenting and
   filling stay independent of each other and of the letterforms.

   The ::after carries the text via attr() rather than a second element: the
   whole overlay is aria-hidden decoration, and duplicating a wordmark in the
   DOM is how a page ends up reading its own name twice.
----------------------------------------------------------------------------- */
/* MONO, not the display face — and this is rule 1 being obeyed, not broken.

   Space Grotesk is content; IBM Plex Mono is the console, and rule 1's test is
   "if it tells you what something is or where you are, it is mono". This word
   is a READOUT: it is the loading meter, the thing whose fill tells you how far
   along you are. It stopped being a headline the moment the level moved into
   it, so it should be set in the console voice — and mono is boxy by
   construction, which is the other half of what was asked for.

   Uppercase for the same reason (rule 2: uppercase belongs to mono), and it
   is what turns the splash into a boot banner rather than a title card. The
   DOM text stays properly cased; only the rendering is capitalised.

   A monospaced face also earns its keep mechanically: every character occupies
   an identical cell, so the segment blocks below land on a regular grid across
   the whole word instead of drifting against proportional letterforms. */
.boot__word {
  position: relative;
  font-family: var(--f-mono); font-weight: 600;
  font-size: clamp(1.05rem, 3vw, 2rem);
  letter-spacing: .02em;
  text-transform: uppercase;
  line-height: 1.1;
  color: var(--c-line-2);               /* the unlit scale */
  white-space: nowrap;
  /* The word rises in once as a block. It used to play in per character, the
     same as the hero <h1> — but now that it is also the progress bar, the fill
     is the animation, and two motions on one object read as a stutter. */
  opacity: 0; transform: translateY(14px);
  animation: bootword 760ms var(--e-out) 60ms forwards;
}
@keyframes bootword { to { opacity: 1; transform: none; } }

.boot__word::after {
  content: attr(data-text);
  position: absolute; inset: 0;
  color: transparent;

  /* fg up to just behind the level, amber at the level — a meter's peak.
     In em, so the peak stays the same fraction of the word at every size. */
  background-image: linear-gradient(90deg,
    var(--c-fg) 0 calc(var(--p, 0) * 100% - .46em),
    var(--c-accent) calc(var(--p, 0) * 100% - .07em));
  -webkit-background-clip: text;
          background-clip: text;
  -webkit-text-fill-color: transparent;

  /* Segments — BLOCKS, not stripes, and sized in em so they hold the same
     proportion at every viewport instead of being hatching on a small word and
     nothing at all on a large one.

     The period is FOUR PER CHARACTER CELL. IBM Plex Mono advances .6em per
     character, so .15em lands exactly four blocks on every glyph and the whole
     wordmark sits on one grid — which is the entire reason a monospaced face
     is worth having here. Across "Jesvi Jonathan" that is ~56 segments, the
     same order as the meters everywhere else on the site.

     Wider blocks were tried and rejected: past about .2em they swallow whole
     letters, and the mask stops reading as a level and starts reading as a
     word with pieces missing. */
  --seg: .11em;                          /* one block */
  --gap: .04em;                          /* the dark between blocks */
  -webkit-mask-image: repeating-linear-gradient(90deg,
    #000 0 var(--seg), transparent var(--seg) calc(var(--seg) + var(--gap)));
          mask-image: repeating-linear-gradient(90deg,
    #000 0 var(--seg), transparent var(--seg) calc(var(--seg) + var(--gap)));

  /* the level itself */
  clip-path: inset(0 calc(100% - var(--p, 0) * 100%) 0 0);
}

/* Set out exactly like .ch__head so the splash is a channel strip, not a
   title card: label on the left, rule across, count on the right. */
.boot__row { display: flex; align-items: center; gap: var(--s-4); }
.boot__stat { flex: none; }
.boot__pct  { flex: none; min-width: 4ch; text-align: right; color: var(--c-fg); }

/* -----------------------------------------------------------------------------
   THE SPLASH ON A PHONE.

   The wordmark's floor was 1.05rem, which computes to 16.8px at 390 — the whole
   loading screen was a 250px-wide readout adrift in the middle of an otherwise
   empty phone, and the one moment the site gets a full screen to itself was
   spending it on type smaller than the body copy underneath.

   The floor is raised and given a vw term so it grows with the device. Plex Mono
   advances at .6em plus the .02em tracking here, so fourteen characters occupy
   8.68em: at 7.4vw that is 251px of the 338 a 390px phone leaves inside `--gut`,
   and it still fits at 320. The mark stays ONE LINE — this is the site's
   wordmark, and a wordmark that breaks in half is a different mark.

   THE BOX'S MIN-WIDTH GOES DOWN AS THE WORD GOES UP, and they are the same
   setting. 300px existed because the word used to be NARROWER than "Patching
   channels · 100%", which crushed the rule between them to nothing. Now the word
   is the wider of the two, so a 300px floor would instead hold the box open past
   the end of the name and put the status rule out on its own — the identical
   fault, mirrored. Sized by content, floored low enough to stay out of the way.
----------------------------------------------------------------------------- */
@media (max-width: 620px) {
  .boot__word { font-size: clamp(1.55rem, 7.4vw, 2rem); }
  .boot__box  { min-width: min(232px, 86vw); }
}

/* the plate's own footer, so the splash is framed like a channel strip */
.boot__mark {
  position: absolute; inset: auto var(--gut) clamp(18px, 3vh, 30px);
  display: flex; justify-content: space-between; gap: var(--s-4);
  opacity: 0;
  animation: bootmark 700ms var(--e-out) 340ms forwards;
}
.boot.done .boot__mark { opacity: 0; transition: opacity 200ms linear; animation: none; }
@keyframes bootmark { to { opacity: 1; } }

/* skip link */
.skip {
  position: fixed; top: 8px; left: 8px; z-index: 200;
  padding: 10px 16px; background: var(--c-accent); color: var(--c-accent-ink);
  border-radius: var(--r-ctl); transform: translateY(-200%);
}
.skip:focus { transform: none; }

/* =============================================================================
   FOOT — the end of the travel

   The clock lives here now rather than on the master strip: it is a real thing
   worth saying (Jesvi's local time, so a stranger can tell whether it is the
   middle of the night in Chennai) but it is not worth a quarter of the nav.
   ============================================================================= */

.foot__end-r { display: flex; align-items: center; gap: var(--s-5); }

/* -----------------------------------------------------------------------------
   BACK TO TOP — a clean amber underline that wipes in on hover.

   The old text-clip shimmer is gone (the user asked for a plain underline). The
   bar is a real `::after`, NOT `.ln`: `.ln` draws its underline through
   `background`, and stacking two background effects on one element is exactly
   what made this link vanish before. A pseudo-element has its own box, so it can
   never fight the text for the same property.
----------------------------------------------------------------------------- */
.foot__top {
  position: relative;
  color: var(--c-muted);
  cursor: pointer;
  transition: color var(--d-2) var(--e-out);
}
/* White, not amber — the user wants a bright-white lift on hover, no accent.
   Rest sits muted so the brighten to full --c-fg reads as the hover. */
.foot__top::after {
  content: '';
  position: absolute; left: 0; right: 0; bottom: -3px; height: 1.5px;
  border-radius: 2px; background: var(--c-fg);
  transform: scaleX(0); transform-origin: left;
  transition: transform var(--d-2) var(--e-out);
}
.foot__top:hover { color: var(--c-fg); }
.foot__top:hover::after { transform: scaleX(1); }

/* -----------------------------------------------------------------------------
   FOOTER IDENTITY — the wordmark, a one-line tag, then the social patch bay.
----------------------------------------------------------------------------- */

.foot__mark {
  font-family: var(--f-display); font-weight: 700;
  font-size: clamp(1.6rem, 3.2vw, 2.4rem);
  line-height: 1.04; letter-spacing: var(--tr-mega);
}
.foot__tag { color: var(--c-muted); max-width: 32ch; margin-top: var(--s-3); }

.foot__social { display: flex; flex-wrap: wrap; gap: var(--s-3); margin-top: var(--s-6); }



/* REACTIVE hover: an amber spotlight tracks the cursor INSIDE the tile — app.js
   writes --mx/--my per icon on pointermove — while the glyph lights to the accent
   and glows. The tile itself never moves; the animation is the light following
   the pointer. The `::before` radial is clipped to the tile by its own
   border-radius (no overflow:hidden needed, so the glyph's glow is not cut off),
   and the glyph sits above it via z-index. A deliberate rule-4 exception (amber
   on hover) the owner asked for. */
.foot__ico {
  position: relative;
  width: 40px; height: 40px; flex: none;
  display: grid; place-items: center;
  border: 1px solid var(--c-line); border-radius: var(--r-ctl);
  color: var(--c-muted); background: var(--c-glass);
  transition: color var(--d-2) var(--e-out), border-color var(--d-2) var(--e-out);
}
.foot__ico::before {
  content: ''; position: absolute; inset: 0; border-radius: inherit;
  background: radial-gradient(52px circle at var(--mx, 50%) var(--my, 50%),
    color-mix(in srgb, var(--c-accent) 42%, transparent), transparent 68%);
  opacity: 0; transition: opacity var(--d-2) var(--e-out);
  pointer-events: none;
}
.foot__ico svg { position: relative; z-index: 1; transition: filter var(--d-2) var(--e-out); }
.foot__ico:hover {
  color: var(--c-accent-txt);
  border-color: color-mix(in srgb, var(--c-accent) 50%, var(--c-line-2));
}
.foot__ico:hover::before { opacity: 1; }
.foot__ico:hover svg { filter: drop-shadow(0 0 6px color-mix(in srgb, var(--c-accent) 55%, transparent)); }
.foot__ico:focus-visible { outline: 2px solid var(--c-accent); outline-offset: 2px; }

/* =============================================================================
   THE SCROLLER — a pane that admits there is more of it.

   Two components need the same behaviour: CH 02's project index, which must hold
   twenty projects in the space six were using, and CH 05's record crate, which
   runs sideways past the edge of the page. Rather than write it twice, this is
   the shared object.

   THE EDGE IS A MASK, AND IT IS ONLY THERE WHEN IT IS TRUE. A pane that fades
   permanently at both ends is lying at the top of its travel — the first row is
   half dissolved for no reason, and worse, you cannot tell whether the fade
   means "more this way" or "this is just how it looks". So `--f0` and `--f1` are
   0 by default and app.js turns each one on only when there is actually content
   past that edge. The mask reads as a cut, not as decoration.

   The native scrollbar is suppressed here for the same reason it is suppressed
   on the document: it is chrome from a different machine. The mask, the cursor
   and the content all say "scrollable" already.
   ============================================================================= */
.sc {
  --f0: 0px;                 /* fade at the start edge — content is above/left  */
  --f1: 0px;                 /* fade at the end edge — content is below/right   */
  scrollbar-width: none;
  -ms-overflow-style: none;
}
.sc::-webkit-scrollbar { width: 0; height: 0; }

.sc--y {
  overflow-y: auto;
  /* SCROLL CHAINING IS LEFT ON HERE, and turning it off was a real bug. With
     `overscroll-behavior-y: contain` the index swallowed every wheel event that
     crossed it: six rows leave about two pixels of slack, so the pane counted as
     scrollable, absorbed the notch, and refused to pass anything on — the page
     simply stopped moving whenever the pointer happened to be over a 300px lane.
     A tall index still scrolls itself first and only hands over at its end,
     which is what chaining is for. */
  mask-image: linear-gradient(180deg,
    transparent 0, #000 var(--f0),
    #000 calc(100% - var(--f1)), transparent 100%);
}
.sc--x {
  overflow-x: auto;
  /* Contained on the X axis only, which is not about chaining to the page: it is
     what stops an overscrolled horizontal shelf turning into the browser's
     swipe-to-go-back gesture. */
  overscroll-behavior-x: contain;
  mask-image: linear-gradient(90deg,
    transparent 0, #000 var(--f0),
    #000 calc(100% - var(--f1)), transparent 100%);
}
.sc.more-a { --f0: clamp(20px, 3vw, 44px); }
.sc.more-b { --f1: clamp(20px, 3vw, 44px); }
