/* ============================================================
   layout.css — the page shell and its nine sections.

   Layout families, one use each:
     nav · split-hero · fact-strip · two-column-shift · card-stack
     numbered-flow · index-list · split-with-transcript · full-bleed-band · footer
   ============================================================ */

/* ============================================================
   NAV - single line at desktop, 68px, condenses to 60px on scroll
   ============================================================ */

.nav {
  position: fixed;
  inset: 0 0 auto 0;
  z-index: 100;
  background: color-mix(in srgb, var(--paper) 86%, transparent);
  backdrop-filter: saturate(150%) blur(12px);
  -webkit-backdrop-filter: saturate(150%) blur(12px);
  border-bottom: 1px solid transparent;
  transition: border-color var(--t-base) var(--ease),
              background var(--t-base) var(--ease),
              transform var(--t-base) var(--ease);
}

@media (prefers-reduced-transparency: reduce) {
  .nav { background: var(--paper); backdrop-filter: none; -webkit-backdrop-filter: none; }
}

.nav.scrolled { border-bottom-color: var(--rule); }

/* The bar condenses from 68px to 60px on scroll. It does that by sliding
   itself up by its own 8px of top padding rather than transitioning
   height, so the effect is a transform and never a per-frame layout.

   Note for anyone measuring this bar from script: offsetHeight cannot see
   the transform and will report 68 either way. app.js reads the rect, which
   can, so nothing has to keep a copy of the 8. */
.nav-inner {
  max-width: var(--shell);
  margin-inline: auto;
  padding-inline: var(--gutter);
  padding-top: 8px;
  height: 68px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 24px;
}
.nav.scrolled { transform: translateY(-8px); }

@media (prefers-reduced-motion: reduce) {
  .nav { transition: border-color var(--t-base) linear, background var(--t-base) linear; }
  .nav.scrolled { transform: none; }
}

/* Brand: the existing wordmark, restyled. Not redrawn. The boxed SC4SAP
   mark it used to sit next to is gone from both the nav and the footer. */
.brand {
  display: inline-flex;
  align-items: center;
  text-decoration: none;
  color: var(--ink);
  flex: none;
}
.brand-text {
  font-size: var(--fs-sm);
  letter-spacing: -0.01em;
  color: var(--ink-2);
  white-space: nowrap;
}
.brand-text b { color: var(--ink); font-weight: 600; }

.nav-links {
  display: flex;
  align-items: center;
  gap: 4px;
}
/* Nav links wash in rather than switching state. A flat tint, held long
   enough (700ms) that the fill reads as arriving instead of toggling. */
.nav-links a {
  position: relative;
  isolation: isolate;
  padding: 8px 12px;
  border-radius: var(--r-sm);
  font-size: var(--fs-sm);
  color: var(--ink-2);
  text-decoration: none;
  white-space: nowrap;
  transition: color 700ms var(--ease), opacity 220ms var(--ease);
}

/* The nav links change with the language too, and used to be the one
   thing on the page that swapped without a fade — every section's copy
   lives in a .shell and cross fades there, but the bar does not.

   Only the links. The three language buttons are inside .nav-links as
   well, and fading the control you just pressed is exactly the blink the
   .shell rule was written to avoid; > a leaves them alone. The 220ms
   matches the shell so the bar and the page turn over together. */
body.lang-swap .nav-links > a { opacity: 0; }
.nav-links a::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  border-radius: inherit;
  background: color-mix(in srgb, var(--accent) 10%, transparent);
  opacity: 0;
  transition: opacity 700ms var(--ease);
}
.nav-links a:hover { color: var(--accent-text); }
.nav-links a:hover::before { opacity: 1; }

.nav-links a[aria-current="page"] { color: var(--accent-text); font-weight: 550; }
.nav-links a[aria-current="page"]::before { opacity: 0.7; }

@media (prefers-reduced-motion: reduce) {
  .nav-links a, .nav-links a::before { transition-duration: 1ms; }
  body.lang-swap .nav-links > a { opacity: 1; }
}

.nav-sep {
  width: 1px;
  height: 20px;
  background: var(--rule);
  margin-inline: 8px;
  flex: none;
}

/* Language: 3-segment control. The highlight is a single thumb that
   travels to whichever segment you pick, so the control reads as one
   object moving rather than two segments blinking. Only transform is
   animated; the thumb's width is written as a variable and lands
   instantly, which keeps the slide off the layout path. */
.lang {
  position: relative;
  display: inline-flex;
  padding: 2px;
  border: 1px solid var(--rule);
  border-radius: var(--r-sm);
  background: var(--sheet);
}

.lang-thumb {
  position: absolute;
  top: 2px;
  bottom: 2px;
  left: 2px;
  width: var(--thumb-w, 0);
  border-radius: 2px;
  background: var(--accent);
  transform: translateX(var(--thumb-x, 0));
  /* Decelerating, no overshoot. A bounce here would read as a toy next to
     the rest of the page. */
  transition: transform 400ms var(--ease);
  pointer-events: none;
}
/* Before the first measurement, and on resize, the thumb must not slide
   in from the left edge. */
.lang.no-anim .lang-thumb { transition: none; }

.lang button {
  position: relative;
  z-index: 1;
  appearance: none;
  border: 0;
  background: transparent;
  cursor: pointer;
  padding: 8px 11px;
  border-radius: 2px;
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.06em;
  color: var(--ink-3);
  transition: color 400ms var(--ease);
}
.lang button:hover { color: var(--accent-text); }
.lang button[aria-pressed="true"] {
  color: var(--accent-ink);
}
.lang button[aria-pressed="true"]:hover { color: var(--accent-ink); }

@media (prefers-reduced-motion: reduce) {
  .lang-thumb { transition: none; }
  .lang button { transition-duration: 1ms; }
}

.icon-btn {
  appearance: none;
  border: 1px solid var(--rule);
  background: var(--sheet);
  border-radius: var(--r-sm);
  width: 38px;
  height: 38px;
  display: inline-grid;
  place-items: center;
  cursor: pointer;
  color: var(--ink-2);
  font-size: 18px;
  transition: color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
}
.icon-btn:hover { color: var(--ink); border-color: var(--rule-strong); }

/* Coarse pointers get the full 44px target on every control. */
@media (pointer: coarse) {
  .icon-btn { width: 44px; height: 44px; }
  .lang button { min-height: 44px; padding-inline: 16px; }
  .copy-btn, .btn { min-height: 44px; }
}

/* Both glyphs share one grid cell and cross fade, so the icon buttons
   change state instead of blinking. .icon-btn is already inline-grid with
   place-items: center, so 1 / 1 stacks them dead centre. */
.theme-btn i,
.nav-toggle i {
  grid-area: 1 / 1;
  transition: opacity 300ms var(--ease), transform 300ms var(--ease);
}

.theme-btn .tt-moon { opacity: 0; transform: rotate(-50deg) scale(0.7); }
[data-theme="dark"] .theme-btn .tt-sun  { opacity: 0; transform: rotate(50deg) scale(0.7); }
[data-theme="dark"] .theme-btn .tt-moon { opacity: 1; transform: none; }

.nav-toggle .ph-x { opacity: 0; transform: rotate(-90deg) scale(0.75); }
.nav-toggle[aria-expanded="true"] .ph-list { opacity: 0; transform: rotate(90deg) scale(0.75); }
.nav-toggle[aria-expanded="true"] .ph-x { opacity: 1; transform: none; }

@media (prefers-reduced-motion: reduce) {
  .theme-btn i, .nav-toggle i { transition-duration: 1ms; }
}

.nav-toggle { display: none; }

@media (max-width: 900px) {
  .nav-toggle { display: inline-grid; }

  /* The panel stays laid out and animates opacity and transform. display
     cannot be transitioned, so visibility carries the "not there" part:
     it flips instantly on open and waits out the fade on close, which
     also keeps the links out of the tab order while hidden. */
  .nav-links {
    position: absolute;
    top: 100%;
    left: 0;
    right: 0;
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: 2px;
    padding: 12px var(--gutter) 20px;
    background: var(--paper);
    border-bottom: 1px solid var(--rule);

    visibility: hidden;
    opacity: 0;
    transform: translateY(-10px);
    transition: opacity 260ms var(--ease),
                transform 260ms var(--ease),
                visibility 0s linear 260ms;
  }
  .nav-links.open {
    visibility: visible;
    opacity: 1;
    transform: none;
    transition: opacity 260ms var(--ease),
                transform 260ms var(--ease),
                visibility 0s;
  }

  /* Rows arrive in reading order behind the panel itself. */
  .nav-links > * {
    opacity: 0;
    transform: translateY(-6px);
    transition: opacity 240ms var(--ease), transform 240ms var(--ease);
  }
  .nav-links.open > * { opacity: 1; transform: none; }
  .nav-links.open > *:nth-child(1) { transition-delay: 60ms; }
  .nav-links.open > *:nth-child(2) { transition-delay: 100ms; }
  .nav-links.open > *:nth-child(3) { transition-delay: 140ms; }
  .nav-links.open > *:nth-child(5) { transition-delay: 180ms; }

  @media (prefers-reduced-motion: reduce) {
    .nav-links, .nav-links > * { transition-duration: 1ms; transition-delay: 0s; }
  }

  .nav-links a { padding: 12px 4px; }
  .nav-sep { display: none; }
  .nav-foot {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    padding-top: 12px;
    margin-top: 6px;
    border-top: 1px solid var(--rule);
  }
  .lang { flex: 1; }
  .lang button { flex: 1; padding-block: 9px; }
}

.nav-foot { display: contents; }
@media (max-width: 900px) { .nav-foot { display: flex; } }

/* ============================================================
   HERO — asymmetric split, content left, install panel right
   ============================================================ */

.hero {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  padding-top: calc(68px + clamp(64px, 8vw, 120px));
  padding-bottom: clamp(72px, 8vw, 116px);
}

/* ---------- Ambient ground ----------
   A drafting grid drifting behind the headline, and a slow accent wash
   breathing under the install panel. Both are transform-only on
   oversized layers, so nothing repaints per frame, and both sit at
   opacities where they register as paper texture rather than as
   decoration competing with the type. */

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

.ha-grid {
  position: absolute;
  /* Oversized so the drift never exposes an edge. */
  inset: -40% -40% -40% -40%;
  background-image:
    repeating-linear-gradient(to right,  var(--accent) 0 1px, transparent 1px 72px),
    repeating-linear-gradient(to bottom, var(--accent) 0 1px, transparent 1px 72px);
  opacity: 0.045;
  will-change: transform;
  animation: ha-drift 64s linear infinite;
}

.ha-wash {
  position: absolute;
  right: -8%;
  top: -22%;
  width: min(760px, 78vw);
  aspect-ratio: 1;
  border-radius: 50%;
  /* Multi-stop so the falloff is smooth without a blur filter. Scaling a
     filtered layer forces a re-raster every frame; this stays purely
     composited. */
  /* Amber over ivory is a warm-on-warm mix, so it turns muddy long before
     it turns visible. Held to roughly 4% at its peak: enough to feel like
     light on paper, not enough to tint the paper. */
  /* The stops follow a raised cosine rather than a few straight runs. Four
     stops meant the falloff changed slope three times, and at these
     opacities each of those corners showed up as a ring of its own, on top
     of whatever the 8-bit steps were already doing. This curve leaves and
     arrives flat, so there is no corner anywhere to catch the eye. */
  background: radial-gradient(
    circle at 50% 50%,
    color-mix(in srgb, var(--accent) 10.00%, transparent) 0%,
    color-mix(in srgb, var(--accent) 9.56%,  transparent) 9%,
    color-mix(in srgb, var(--accent) 8.31%,  transparent) 18%,
    color-mix(in srgb, var(--accent) 6.51%,  transparent) 27%,
    color-mix(in srgb, var(--accent) 4.49%,  transparent) 36%,
    color-mix(in srgb, var(--accent) 2.63%,  transparent) 45%,
    color-mix(in srgb, var(--accent) 1.19%,  transparent) 55%,
    color-mix(in srgb, var(--accent) 0.34%,  transparent) 64%,
    transparent 78%
  );
  opacity: 0.32;
  will-change: transform, opacity;
  animation: ha-breathe 17s ease-in-out infinite;
}

/* ---------- Dither ----------
   The wash crosses very few 8-bit values over a very long distance: on the
   dark ground the blue channel moves about three levels across 289px, so
   each step lands as a visible edge roughly 80px wide. No amount of stop
   tuning fixes that, because the steps are the display's, not the
   gradient's. A fine grain at the threshold of visibility scatters each
   boundary instead, which is the same trick print uses.

   It is a sibling of .ha-wash, never a child: the wash is a promoted layer
   that breathes up to 1.12, and anything inside it gets magnified with it,
   grain included. */
.ha-noise {
  --noise: 0.012;
  position: absolute;
  inset: 0;
  opacity: var(--noise);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E");
  background-size: 160px 160px;
}

@keyframes ha-drift {
  from { transform: translate3d(0, 0, 0); }
  to   { transform: translate3d(72px, 72px, 0); }
}

@keyframes ha-breathe {
  0%, 100% { transform: scale(1)    translate3d(0, 0, 0); opacity: 0.24; }
  50%      { transform: scale(1.12) translate3d(-2%, 2%, 0); opacity: 0.40; }
}

/* On the warm near-black the same amber reads as light rather than as a
   stain, so dark mode gets more of it back. */
[data-theme="dark"] .ha-grid { opacity: 0.075; }
[data-theme="dark"] .ha-wash { animation-name: ha-breathe-dark; }
/* Dark is where the banding actually shows, so it gets most of the grain.
   Light keeps a trace: its blue channel steps every 31px, which is mild but
   not nothing. */
[data-theme="dark"] .ha-noise { --noise: 0.032; }

@keyframes ha-breathe-dark {
  0%, 100% { transform: scale(1)    translate3d(0, 0, 0); opacity: 0.55; }
  50%      { transform: scale(1.12) translate3d(-2%, 2%, 0); opacity: 0.85; }
}

@media (prefers-reduced-motion: reduce) {
  .ha-grid, .ha-wash { animation: none; }
}

/* ---------- Entry choreography ----------
   The hero resolves in reading order: headline lines, then the promise,
   then the thing you are meant to act on. It runs once on load, not on
   scroll, because the hero is already in view. */

.hero-title .hl {
  display: block;
  overflow: hidden;
  /* The clip window has to clear the tallest ascender and the deepest
     Korean and Japanese strokes on both sides, or the mask eats the type
     instead of framing it. Reserved, then pulled back so the reserve
     costs no vertical rhythm. */
  padding: 0.12em 0 0.2em;
  margin: -0.12em 0 -0.2em;
}
.hero-title .hl-i {
  display: block;
  transform: translateY(115%);
  animation: hl-rise 900ms var(--ease) forwards;
}
.hero-title .hl:nth-child(1) .hl-i { animation-delay: 60ms; }
.hero-title .hl:nth-child(2) .hl-i { animation-delay: 175ms; }
.hero-title .hl:nth-child(3) .hl-i { animation-delay: 290ms; }

@keyframes hl-rise {
  to { transform: translateY(0); }
}

.hero-enter {
  opacity: 0;
  transform: translateY(14px);
  animation: hero-in 760ms var(--ease) forwards;
  animation-delay: var(--enter-delay, 0ms);
}

@keyframes hero-in {
  to { opacity: 1; transform: none; }
}

@media (prefers-reduced-motion: reduce) {
  .hero-title .hl-i { transform: none; animation: none; }
  .hero-enter { opacity: 1; transform: none; animation: none; }
}

/* The headline needs the room more than the install panel does: the panel
   only has to hold one short command and a repo path. */
.hero-grid {
  display: grid;
  grid-template-columns: minmax(0, 1.25fr) minmax(0, 0.75fr);
  gap: clamp(32px, 5vw, 72px);
  align-items: start;
}

@media (max-width: 900px) {
  .hero { padding-top: calc(68px + 48px); padding-bottom: 64px; }
  .hero-grid { grid-template-columns: 1fr; gap: 36px; }
}

/* The copy carries its own line breaks, so no ch cap here: the ch unit
   is derived from the Latin zero and badly under-measures full-width CJK. */
.hero h1 { max-width: 100%; }
.hero h1 em {
  font-style: italic;
  font-weight: 600;
  color: var(--accent-text);
  line-height: 1.1;
}
.hero .t-lede { margin-top: 22px; max-width: 46ch; }

/* --- install command panel: a real control, not a simulated window --- */

.install {
  background: var(--sheet);
  border: 1px solid var(--rule);
  border-radius: var(--r-md);
  box-shadow: var(--lift-2);
  overflow: hidden;
}

.install-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 12px 16px;
  border-bottom: 1px solid var(--rule);
  background: color-mix(in srgb, var(--paper) 55%, var(--sheet));
}

.install-cmd {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 18px 16px;
}
.install-cmd code {
  flex: 1;
  min-width: 0;
  font-size: clamp(0.9rem, 0.82rem + 0.4vw, 1.05rem);
  font-weight: 500;
  color: var(--ink);
  overflow-x: auto;
  white-space: nowrap;
  /* A thin visible track, not a hidden one. Hiding the scrollbar on a
     horizontally scrolling box leaves mouse users with no way to reach
     the overflow. */
  scrollbar-width: thin;
  scrollbar-color: var(--rule-strong) transparent;
  padding-bottom: 2px;
}
.install-cmd code::-webkit-scrollbar { height: 6px; }
.install-cmd code::-webkit-scrollbar-track { background: transparent; }
.install-cmd code::-webkit-scrollbar-thumb {
  background: var(--rule-strong);
  border-radius: 3px;
}

/* The command types itself out once. It is the same gesture the visitor
   is about to make, and it puts the caret exactly where their attention
   should land. */
.caret {
  display: inline-block;
  width: 0.5em;
  height: 1.05em;
  margin-left: 1px;
  vertical-align: text-bottom;
  background: var(--accent);
  animation: caret-blink 1.1s steps(1) infinite;
}
.install-cmd.typing .caret { animation: none; }   /* solid while typing */

@keyframes caret-blink {
  0%, 45%   { opacity: 1; }
  50%, 95%  { opacity: 0; }
  100%      { opacity: 1; }
}

@media (prefers-reduced-motion: reduce) {
  .caret { display: none; }
}

.copy-btn {
  appearance: none;
  flex: none;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 10px 14px;
  border: 1px solid var(--rule-strong);
  border-radius: var(--r-sm);
  background: var(--paper);
  color: var(--ink-2);
  font-size: var(--fs-xs);
  font-weight: 550;
  cursor: pointer;
  transition: color var(--t-fast) var(--ease),
              border-color var(--t-fast) var(--ease),
              transform var(--t-fast) var(--ease);
}
.copy-btn:hover { color: var(--ink); border-color: var(--ink-3); }
.copy-btn:active { transform: translateY(1px); }
.copy-btn.done { color: var(--ok); border-color: var(--ok); }
.copy-btn .ph-check { display: none; }
.copy-btn.done .ph-copy  { display: none; }
.copy-btn.done .ph-check { display: inline; }

.install-foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 12px 16px;
  min-width: 0;
  background-image: repeating-linear-gradient(to right, var(--rule) 0 5px, transparent 5px 11px);
  background-size: 100% 1px;
  background-repeat: no-repeat;
  background-position: left top;
}

/* The repo path is 38 monospaced characters in a column that narrowed
   when the headline took more room. Sized to stay on one line, with an
   ellipsis as the last resort on very narrow viewports. */
.install-foot .t-meta {
  font-size: 11px;
  letter-spacing: 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  min-width: 0;
}

.stars {
  flex: none;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  text-decoration: none;
  color: var(--ink-3);
  font-family: var(--font-mono);
  font-size: var(--fs-xs);
  transition: color var(--t-fast) var(--ease);
}
.stars:hover { color: var(--ink); }

.hero-actions { margin-top: 18px; display: flex; gap: 10px; flex-wrap: wrap; }

/* ============================================================
   FACT STRIP — platform support, hairline-separated, no dots
   ============================================================ */

.facts { border-top: 0; }
.facts-grid {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  padding-block: 28px;
}
.fact { padding-inline: clamp(12px, 2vw, 28px); }
.fact + .fact { border-left: 1px solid var(--rule); }
.fact dt {
  font-family: var(--font-mono);
  font-size: var(--fs-xs);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-3);
  margin-bottom: 8px;
}
.fact dd { margin: 0; }

.fact-n {
  display: block;
  font-family: var(--font-display);
  font-size: clamp(1.9rem, 1.4rem + 1.6vw, 2.6rem);
  font-weight: 700;
  letter-spacing: -0.035em;
  line-height: 1;
  color: var(--ink);
  /* Tabular figures so the count does not jitter its own width. */
  font-variant-numeric: tabular-nums;
  font-feature-settings: 'tnum' 1;
}
.fact-n .suffix { color: var(--accent-text); }

.fact-sub {
  display: block;
  margin-top: 8px;
  font-size: var(--fs-xs);
  line-height: 1.6;
  color: var(--ink-2);
}

/* Columns arrive left to right; app.js sets the per-column delay. */
.fact {
  opacity: 0;
  transform: translateY(14px);
  transition: opacity 620ms var(--ease), transform 620ms var(--ease);
  transition-delay: var(--fact-delay, 0ms);
}
.facts-grid.in .fact { opacity: 1; transform: none; }

@media (prefers-reduced-motion: reduce) {
  .fact { opacity: 1; transform: none; transition: none; }
}

@media (max-width: 780px) {
  .facts-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 22px 0; }
  .fact:nth-child(3) { border-left: 0; }
  .fact:nth-child(odd) { border-left: 0; }
  .fact:nth-child(even) { border-left: 1px solid var(--rule); }
}

/* ============================================================
   SHIFT — two-column comparison, split by a single vertical rule
   ============================================================ */

/* Three tracks: the state you are leaving, the crossing, and the state
   you arrive at. The middle track replaces what used to be a dashed rule
   with something that actually says "this becomes that".

   The optical shift left is built into the track ratio rather than added
   as a transform. A fixed pixel nudge holds its size while everything
   around it scales, so the crossing drifted off centre as the window
   resized. Widening the right track moves the middle one by a constant
   fraction of the row at every width. */
.shift-grid {
  --link-bias: 1.16fr;         /* right track vs left; 1fr would centre it */
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto minmax(0, var(--link-bias));
  gap: clamp(20px, 3vw, 40px);
  margin-top: clamp(36px, 5vw, 56px);
  align-items: start;
}
.shift-col { position: relative; }
.shift-col h3 { margin-bottom: 6px; }
.shift-col.now h3   { color: var(--ink-3); }
.shift-col.after h3 { color: var(--accent-text); }

/* Side by side, the resolved state sits a little further off the crossing
   so it reads as the far shore rather than as the next column along. The
   inset is a percentage, which resolves against the track and therefore
   scales with the row: a fixed offset would drift on resize the same way
   the connector used to. */
.shift-col.after { padding-left: var(--after-inset, 5%); }

.shift-list { margin-top: 20px; display: grid; gap: 14px; }
.shift-list li {
  display: grid;
  grid-template-columns: 20px minmax(0, 1fr);
  gap: 10px;
  font-size: var(--fs-sm);
  line-height: 1.55;
  color: var(--ink-2);
}
.shift-list li i { font-size: 17px; line-height: 1.35; }
.shift-col.after .shift-list li i { color: var(--accent-text); }
.shift-list b { color: var(--ink); font-weight: 600; }

/* The left column is the state you are leaving, so it settles one step
   back in the ink scale while the right column lands at full strength.
   The recession is carried by colour, not by opacity, because a faded
   layer would drop the left column under 4.5:1. --ink-3 holds 5:1. */
.shift-col.now .shift-list li,
.shift-col.now .shift-list li i { color: var(--ink-3); }
.shift-col.now .shift-list b { color: var(--ink-2); font-weight: 600; }

/* Rows arrive in left/right pairs, top down. */
.shift-list li {
  opacity: 0;
  transform: translateY(12px);
  transition: opacity 640ms var(--ease), transform 640ms var(--ease);
  transition-delay: var(--row-delay, 0ms);
}
.shift-grid.in .shift-list li { opacity: 1; transform: none; }

/* The right column also sharpens: it comes in fractionally tighter and
   settles, so "after" reads as the resolved state. */
.shift-col.after .shift-list li { transform: translateY(12px) scale(0.985); }
.shift-grid.in .shift-col.after .shift-list li { transform: none; }

@media (prefers-reduced-motion: reduce) {
  .shift-list li,
  .shift-col.after .shift-list li { opacity: 1; transform: none; transition: none; }
}

/* ---------- The crossing ----------
   A badge that turns once every three seconds over a dashed run that
   keeps flowing left to right. Both are decorative: the meaning is
   already written in the two column headings, so the whole block is
   aria-hidden. */

.shift-link {
  position: relative;
  align-self: center;          /* sits at the midpoint of the taller column */
  justify-self: center;
  display: grid;
  place-items: center;
  width: clamp(96px, 11vw, 148px);
  height: 38px;                /* the badge sets the height of the whole run */
}

/* Painting order across the crossing:
     0  the dashed run, passing straight through
     1  the glow rings
     2  the badge, opaque, covering the run behind it
   The rings are siblings of the badge rather than children because a
   negative z-index child still paints above its parent's background,
   which washed the glow across the glyph. */
.sl-node {
  position: relative;
  z-index: 2;
  display: grid;
  place-items: center;
  width: 38px;
  height: 38px;
}

.sl-badge {
  position: relative;
  z-index: 1;
  display: grid;
  place-items: center;
  width: 100%;
  height: 100%;
  border-radius: 50%;
  background: var(--sheet);
  border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
  color: var(--accent-text);
  font-size: 17px;
  box-shadow: var(--lift-1);
}
.sl-badge i {
  animation: sl-turn 3s cubic-bezier(0.65, 0, 0.35, 1) infinite;
}

/* Accent light spreading out from behind the badge, on the same three
   second period so the ring and the turn read as one event. */
.sl-pulse {
  position: absolute;
  inset: -1px;
  border-radius: 50%;
  background: radial-gradient(
    circle,
    color-mix(in srgb, var(--accent) 55%, transparent) 0%,
    color-mix(in srgb, var(--accent) 22%, transparent) 45%,
    transparent 72%
  );
  will-change: transform, opacity;
  animation: sl-pulse 3s ease-out infinite;
}
.sl-pulse.d2 { animation-delay: 1.5s; }

@keyframes sl-turn {
  0%   { transform: rotate(0deg); }
  55%  { transform: rotate(360deg); }
  100% { transform: rotate(360deg); }
}

@keyframes sl-pulse {
  0%   { transform: scale(0.8); opacity: 0; }
  18%  { opacity: 0.78; }
  100% { transform: scale(2.3); opacity: 0; }
}

/* The run crosses the full width at the badge's own mid-height, so the
   badge lands on the line rather than above it. */
.sl-arrow {
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  z-index: 0;
  height: 1px;
  transform: translateY(-50%);
  color: var(--accent-text);
}

/* The dashes move by exactly one dash period, so the loop is seamless
   and the whole thing stays a transform.

   display: block is load-bearing. .sl-track is a <span>, and position:
   relative does not blockify an inline box, so width and height were
   being dropped and the run rendered at zero size. */
.sl-track {
  display: block;
  position: relative;
  width: 100%;
  height: 1px;
  overflow: hidden;
}
.sl-dash {
  position: absolute;
  top: 0;
  bottom: 0;
  left: -11px;
  width: calc(100% + 11px);
  background-image: repeating-linear-gradient(
    to right,
    var(--accent) 0 5px,
    transparent 5px 11px
  );
  will-change: transform;
  animation: sl-flow 1.4s linear infinite;
}

@keyframes sl-flow {
  from { transform: translateX(0); }
  to   { transform: translateX(11px); }
}

@media (prefers-reduced-motion: reduce) {
  .sl-badge i, .sl-dash { animation: none; }
  .sl-pulse { animation: none; opacity: 0.35; transform: scale(1.3); }
}

/* Stacked, the run turns vertical and still passes through the badge.
   The direction change is a flip of the gradient axis, not a rotate, so
   the layout box stays honest. */
@media (max-width: 820px) {
  /* Stacked, there is no left or right to bias or to inset from. */
  .shift-grid { grid-template-columns: 1fr; gap: 24px; }
  .shift-col.after { padding-left: 0; }
  .shift-link {
    width: 100%;
    height: 72px;
  }

  .sl-arrow {
    left: 50%;
    right: auto;
    top: 0;
    bottom: 0;
    width: 1px;
    height: auto;
    transform: translateX(-50%);
  }
  .sl-track { width: 1px; height: 100%; }
  .sl-dash {
    left: 0;
    top: -11px;
    width: 1px;
    height: calc(100% + 11px);
    background-image: repeating-linear-gradient(
      to bottom,
      var(--accent) 0 5px,
      transparent 5px 11px
    );
    animation-name: sl-flow-down;
  }
}

@keyframes sl-flow-down {
  from { transform: translateY(0); }
  to   { transform: translateY(11px); }
}

/* ============================================================
   IMPACT — the card deck

   Ported from the React Bits CardSwap component, in the arrangement it
   was drawn for: the copy holds the left, the deck is anchored to the
   bottom right and runs off both edges. The overhang is the picture, not
   a defect to tidy away. The section clips it so nothing lands on what
   comes next, and the right-hand side simply leaves the screen.

   The 3D slotting and the skew are the original's. The surface is not:
   black cards with white hairlines belong to the component's own demo,
   and this page is ivory paper with one amber.

   Four cards carry twelve capabilities. Each card has a title bar, which
   is the row that stays legible on the ones behind it; that stepped run
   of names is what makes the stack read as a deck rather than as one card
   with a shadow.
   ============================================================ */

#impact {
  height: 540px;
  padding-block: 0;
  overflow: hidden;
}

.cswap {
  /* Deliberately wider than it is tall and wider than the column it sits
     in: the right-hand side of the card is meant to be off the screen. */
  --cs-w: clamp(560px, 78vw, 1180px);
  /* Tall enough that the skew cannot lift a corner back into view. A
     skewed card's left edge rises by half its width times tan(skew), so
     the bottom-left corner sits well above the bottom-centre; anything
     shorter puts that corner inside the band, where it reads as a
     cut-off box rather than as a card leaving the screen.

     Fluid, like the width. Holding the height still while the width
     tracked the viewport made the card squatter and squatter as the
     window narrowed, and the fan on top of it stopped fitting. */
  --cs-h: clamp(380px, 38vw, 500px);
  height: 100%;
  display: grid;
  gap: clamp(24px, 3vw, 40px);
  align-items: center;
}

@media (min-width: 1000px) {
  .cswap { grid-template-columns: minmax(0, 0.74fr) minmax(0, 1fr); }
}

.cs-copy .t-lede { max-width: 32ch; }

/* ---------- The deck ---------- */

.cs-stage {
  position: relative;
  height: 100%;
}

/* It arrives with the copy, a beat behind it, and takes longer over it:
   the deck is the largest thing in the band and a 620ms entrance on that
   much surface reads as a flick rather than as an arrival.

   Opacity only. The shared reveal also slides its target up 18px, and a
   transform on the ancestor of a preserve-3d context makes the browser
   build and then throw away a compositing layer around the deck, which
   snapped the cards a fraction as it resolved. */
.cs-stage.reveal { transform: none; transition-duration: 900ms; }

/* Anchored by its LEFT edge, not its right. The component anchors bottom
   right because its container is exactly as wide as the card; here the
   card is deliberately wider than the column it sits in, and pinning the
   right edge would drag the whole thing back across the copy. Pinning the
   left edge instead means the card starts where the column starts and the
   extra width is spent going off the screen, which is the picture.

   preserve-3d here, and no z-index anywhere: the browser sorts the cards
   by their own depth and keeps sorting as that depth animates. Flipping
   z-index on a timer instead meant a stacking-context change per card per
   swap, each repainting the whole deck. */
.cs-deck {
  position: absolute;
  left: 0;
  bottom: 0;
  width: var(--cs-w);
  height: var(--cs-h);
  transform: translate(4%, 30%);
  transform-origin: bottom left;
  perspective: 1100px;
  transform-style: preserve-3d;
  /* Nothing in here is interactive, and its box covers most of the band.
     Left hit-testable it swallowed hovers, clicks and text selection over
     half a section, and it used to stop itself whenever a cursor happened
     to be resting on it. */
  pointer-events: none;
}

.cs-card {
  position: absolute;
  top: 50%;
  left: 50%;
  width: var(--cs-w);
  height: var(--cs-h);
  display: grid;
  grid-template-rows: auto minmax(0, 1fr);
  border: 1px solid var(--rule-strong);
  border-radius: var(--r-lg);
  background: var(--sheet);
  box-shadow: var(--lift-2);
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
  will-change: transform;
  /* Placed by script; this is also the resting state without it. */
  transform: translate(-50%, -50%) skewY(4deg);
}

/* The one row that stays visible on the cards underneath. Set exactly as
   the fact strip's keys are, so the two rows of small mono labels on this
   page read as the same kind of thing. */
.cs-bar {
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 0;
  padding: 9px 18px;
  border-bottom: 1px solid var(--rule);
  border-radius: var(--r-lg) var(--r-lg) 0 0;
  background: var(--paper);
  font-family: var(--font-mono);
  font-size: var(--fs-xs);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-3);
  white-space: nowrap;
  overflow: hidden;
}
.cs-bar i { font-size: 15px; color: var(--accent-text); flex: none; }
.cs-bar b { font-weight: 500; flex: none; }
.cs-bar span { overflow: hidden; text-overflow: ellipsis; }

/* A sample session, held still. The deck already moves; a transcript
   typing itself inside a card that is sliding would be two animations
   competing for the same glance. */
.cs-body { display: grid; align-content: start; }

/* .terminal-body carries the mono face and the prompt / cmd / ok / dim
   colours the capability panels already use. Only the metrics change:
   this one fills a card rather than sitting in a panel. */
.cs-term {
  padding: clamp(16px, 1.9vw, 24px) clamp(20px, 2.4vw, 30px);
  font-size: var(--fs-sm);
  line-height: 1.85;
  overflow: hidden;
  /* The card runs off the screen; the transcript must not. */
  max-width: 78ch;
}

/* Narrow screens: one column, and the deck hangs off the bottom of what
   is left rather than off a column that no longer exists.

   The rows are declared. Leaving them implicit and centring the content
   was the bug: the copy and a full-height stage together came to more
   than the band, and centring an overflow pushes half of it out of the
   top, which lifted the deck straight over the headline. With `auto` then
   `1fr` the copy takes what it needs and the stage takes exactly the rest,
   so nothing overflows and nothing can ride up. */
@media (max-width: 999px) {
  #impact { height: 552px; padding-block: 0; }
  .cswap {
    /* Always wider than the window, so the card leaves the screen here
       too. Clamped to a fixed 720 it stopped short of the edge between
       about 720 and 999, where it read as a card floating in the middle
       rather than as a deck coming in from outside. */
    --cs-w: max(480px, 106vw);
    --cs-h: 340px;
    grid-template-rows: auto minmax(0, 1fr);
    align-items: stretch;
    align-content: stretch;
    gap: clamp(16px, 3vw, 26px);
    /* Room above the heading. The band grows by the same amount rather
       than taking it out of the deck. */
    padding-block: clamp(44px, 7vw, 64px) 0;
  }
  .cs-stage { height: auto; }
  .cs-deck {
    left: 0;
    /* Pushed further down than on desktop, because the band is shorter
       here and the fan on top of the card has to clear the copy, and
       further right, so the card runs off the edge rather than sitting
       inside the column. */
    transform: translate(9%, 38%);
    transform-origin: bottom left;
  }
}

/* Below tablet the band tapers with the window rather than holding one
   height: 440 at 720 down to 420 at the narrowest, on the straight line
   between them. */
@media (max-width: 720px) {
  #impact { height: clamp(420px, 398px + 5.8vw, 440px); }
  .cswap {
    --cs-h: 300px;
    padding-block: clamp(34px, 6vw, 48px) 0;
    gap: clamp(12px, 2.5vw, 20px);
  }
  /* Lower again. The band is shorter here, and the fan still has to clear
     the copy above it. */
  .cs-deck { transform: translate(9%, 60%); }
  .cs-term { font-size: var(--fs-xs); line-height: 1.9; }
  .cs-bar { padding: 7px 14px; }
}

@media (max-width: 560px) {
  .cswap { --cs-h: 274px; }
}

/* Until script has measured the card and dealt the slots, all four sit
   exactly on top of one another, which is a picture of nothing. Scripts
   are deferred, so on a slow load that pile can paint first.

   Hidden with `visibility`, and deliberately not with opacity. Opacity
   below 1 is a grouping property: applied to the element that carries
   transform-style: preserve-3d it forces that context to flatten. Fading
   the deck therefore rendered every card flat and wrong for the length of
   the fade and snapped them into their real depth at the end, which is
   both why the fade looked broken and why the deck jumped. The fade lives
   on .cs-stage, one level up, where it composites the finished result
   instead of collapsing it. */
.cswap:not(.ready) .cs-deck { visibility: hidden; }

/* No script at all is a different case: the deck cannot be built, so the
   cards fall back to a plain run of sheets and the band gives up its fixed
   height to hold them.

   Keyed to html.no-js, never body.no-js. The head strips the class off
   html before the first paint; body keeps it until the last line of
   app.js. Written as a bare .no-js this whole fallback therefore stayed
   live through the entire load: the band rendered at auto height with the
   deck laid out statically, initCardSwap measured a card that was still
   `width: auto` and dealt the fan from the wrong numbers, and then the
   class dropped, the geometry changed under it, and the ResizeObserver
   put it right a moment later. That was the jump on every refresh. */
html.no-js #impact {
  height: auto;
  padding-block: var(--band);
  overflow: visible;
}
html.no-js .cs-stage { height: auto; }
html.no-js .cs-deck {
  position: static;
  width: auto;
  height: auto;
  display: grid;
  gap: 14px;
  opacity: 1;
  transform: none;
  perspective: none;
  transform-style: flat;
  pointer-events: auto;
}
html.no-js .cs-card {
  position: static;
  width: auto;
  height: auto;
  transform: none;
  box-shadow: var(--lift-1);
}

@media (prefers-reduced-motion: reduce) {
  .cs-deck { transition: none; }
}

/* ============================================================
   FLOW — a centred opening, then three moves held apart by rules

   The three steps are not cards. They are columns separated by the same
   hairlines the rest of the page uses, so nothing is boxed and the weight
   stays in the type. There is no ornament: the centred opening and the
   rules are the whole of it.
   ============================================================ */

.flow-head {
  position: relative;
  text-align: center;
  padding-block: clamp(28px, 5vw, 56px);
}
.flow-head > * { position: relative; }

.flow-eyebrow {
  display: block;
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.14em;
  color: var(--accent-text);
  margin-bottom: 14px;
}
/* Centred, so the lede gives up the left-aligned reading measure and
   takes a width that keeps it symmetrical about the heading. */
.flow-head .t-lede {
  margin-inline: auto;
  margin-top: 16px;
  max-width: 52ch;
}

/* ---------- The three moves ---------- */

.flow {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  border-top: 1px solid var(--rule);
}
.move {
  padding: clamp(24px, 3vw, 34px) clamp(20px, 2.4vw, 30px);
  display: grid;
  align-content: start;
  gap: 10px;
  border-left: 1px solid var(--rule);
}
.move:first-child { border-left: 0; padding-left: 0; }
.move:last-child { padding-right: 0; }

.move-n {
  font-family: var(--font-mono);
  font-size: var(--fs-xs);
  color: var(--accent-text);
  letter-spacing: 0.08em;
}
.move h3 { font-family: var(--font-display); font-size: 1.08rem; letter-spacing: -0.014em; }
.move p { font-size: var(--fs-sm); color: var(--ink-2); line-height: 1.55; }

/* No chip. With the boxes gone a filled command would be the only painted
   thing in the row; a tick and a change of colour carry it instead. */
.move .cmd {
  margin-top: 6px;
  font-family: var(--font-mono);
  font-size: var(--fs-xs);
  color: var(--ink-2);
  justify-self: start;
  max-width: 100%;
  overflow-x: auto;
  white-space: nowrap;
  padding-left: 13px;
  position: relative;
}
.move .cmd::before {
  content: '';
  position: absolute;
  left: 0;
  top: 0.34em;
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: var(--accent);
}

@media (max-width: 820px) {
  .flow { grid-template-columns: 1fr; }
  .move {
    border-left: 0;
    border-top: 1px solid var(--rule);
    padding-inline: 0;
  }
  .move:first-child { border-top: 0; }
}

/* ============================================================
   INDEX — 01..12, the centerpiece. Rows, not cards.
   ============================================================ */

.index-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 20px;
  flex-wrap: wrap;
}
.index-count {
  font-family: var(--font-mono);
  font-size: var(--fs-sm);
  color: var(--ink-3);
}

.index {
  margin-top: clamp(28px, 4vw, 44px);
  border-top: 1px solid var(--rule-strong);
}

.entry { border-bottom: 1px solid var(--rule); }

/* The heading exists for the accessibility tree. All visual weight is
   carried by .entry-title inside the button. */
.entry-h { margin: 0; font-size: inherit; font-weight: inherit; }

.entry-btn {
  appearance: none;
  width: 100%;
  border: 0;
  background: transparent;
  cursor: pointer;
  text-align: left;
  display: grid;
  grid-template-columns: 56px minmax(0, 1fr) minmax(0, 1.15fr) 32px;
  align-items: baseline;
  gap: 16px;
  padding: 20px 8px;
  transition: background var(--t-fast) var(--ease);
}
.entry-btn:hover { background: var(--sheet); }
.entry-btn:hover .entry-title { color: var(--accent-text); }

.entry-n {
  font-family: var(--font-mono);
  font-size: var(--fs-xs);
  color: var(--ink-3);
  letter-spacing: 0.04em;
  transition: color var(--t-fast) var(--ease);
}
.entry[data-open="true"] .entry-n { color: var(--accent-text); }

/* Closed, the twelve rows are an index rather than twelve headlines, so the
   type sits a step below where a heading would. */
.entry-title {
  font-family: var(--font-display);
  font-size: clamp(0.96rem, 0.92rem + 0.3vw, 1.12rem);
  font-weight: 600;
  letter-spacing: -0.012em;
  line-height: 1.3;
  transition: color var(--t-fast) var(--ease);
}
.entry-sum {
  font-size: 0.82rem;
  color: var(--ink-2);
  line-height: 1.55;
}
.entry-sum code {
  color: var(--accent-text);
  font-size: 0.88em;
}

/* Turns on the panel's clock, not its own, so the mark and the row finish
   together instead of the plus snapping over a still-opening panel. */
.entry-mark {
  justify-self: end;
  align-self: center;
  color: var(--ink-3);
  font-size: 18px;
  transition: color var(--t-fast) var(--ease),
              transform var(--open-dur) var(--open-ease);
}
.entry[data-open="true"] .entry-mark { color: var(--accent-text); transform: rotate(45deg); }

@media (max-width: 900px) {
  .entry-btn {
    grid-template-columns: 42px minmax(0, 1fr) 28px;
    gap: 8px 12px;
    padding: 18px 4px;
  }
  .entry-sum { grid-column: 2 / 3; margin-top: 4px; }
}

/* Expanded body: height is animated on a wrapper so the panel content
   itself never reflows mid-transition. */
/* ---------- Opening ----------
   The shared --ease is an exponential ease-out: nearly all of its travel
   happens up front. That reads as decisive on a nav hover, but on a panel
   that can be a thousand pixels tall it flings the row open and then drags.
   This one accelerates over a longer stretch and settles, which is what a
   large surface has to do to feel unhurried rather than slow. */
.index {
  --open-dur: 620ms;
  --open-ease: cubic-bezier(0.33, 0.7, 0.2, 1);
}

.entry-body {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows var(--open-dur) var(--open-ease);
}
.entry[data-open="true"] .entry-body { grid-template-rows: 1fr; }

/* The content also comes up, a little behind the height. Revealing a tall
   panel on height alone looks like a blind being raised; carrying opacity
   with it turns the same movement into the panel arriving. */
.entry-body > div {
  overflow: hidden;
  opacity: 0;
  transition: opacity calc(var(--open-dur) * 0.6) var(--open-ease);
}
.entry[data-open="true"] .entry-body > div {
  opacity: 1;
  transition-delay: calc(var(--open-dur) * 0.22);
}

@media (prefers-reduced-motion: reduce) {
  .entry-body,
  .entry-body > div { transition: none; }
  .entry-body > div { opacity: 1; }
}
/* ============================================================
   CONTRIBUTORS — the mark on the left, the ask set to the right

   Every other section on the page hangs off the left edge. This one is an
   aside rather than part of the product argument, so it is turned round:
   the reading edge moves to the right and the mark takes the space that
   leaves. That is the whole device, and it only works because nothing
   else on the page does it.
   ============================================================ */

/* Shorter than the shared band. This section is one heading and one line,
   so the 72 to 132px the argument sections need would leave it floating in
   a field of nothing. The other two sunk bands keep the shared value. */
#contribute.band { padding-block: 60px; }

.contrib {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  gap: clamp(28px, 5vw, 72px);
  align-items: center;
  margin-top: clamp(24px, 3vw, 36px);
}
.contrib-body { text-align: right; }

/* Pushed to the right edge rather than left aligned inside a right aligned
   block, which would leave the last line hanging away from the margin. */
.contrib-body .t-lede {
  margin-left: auto;
  margin-top: 16px;
  max-width: 44rem;
}

/* Deliberately no display here. It used to carry display: block, and at one
   class plus one element that outweighed a bare .mark-dark, so the dark
   mark stayed on in light mode and both showed at once. Every rule that
   decides which mark is visible is scoped the same way below, so they are
   only ever compared with each other. */
.contrib-mark img {
  width: clamp(120px, 16vw, 220px);
  height: auto;
}

/* Two files rather than one recoloured: the mark is solid ink, and a
   filter that turned the black into the ivory would take the edges with
   it. Both are transparent, so neither carries a plate of its own. */
.contrib-mark .mark-light { display: block; }
.contrib-mark .mark-dark  { display: none; }
[data-theme="dark"] .contrib-mark .mark-light { display: none; }
[data-theme="dark"] .contrib-mark .mark-dark  { display: block; }

/* Stacked, the mark sits above and the block keeps its right edge, so the
   section still reads as the one that faces the other way. */
@media (max-width: 760px) {
  .contrib {
    grid-template-columns: minmax(0, 1fr);
    justify-items: end;
    gap: 24px;
  }
}

/* ============================================================
   CTA BAND — full bleed, one focus
   ============================================================ */

/* A wash, not a slab. Full-strength amber across a full-bleed band was
   the loudest thing on a page whose whole argument is restraint, and it
   forced its own pinned palette on everything inside it. At 13% over the
   paper the band still reads as the moment to act, and the page's own ink
   works on it unchanged (14.7:1 for the headline). */
.cta {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  background-color: color-mix(in srgb, var(--accent) 13%, var(--paper));
}
.cta .shell { position: relative; z-index: 1; }
/* ---------- Closing line (#install) ----------
   One sentence on a band that carries nothing else, so it is set as a
   strip rather than as a section: well under half the page's standard
   block padding, enough to hold the line and its air and no more. */

.evo { text-align: center; }
.cta.band { padding-block: clamp(26px, 3vw, 44px); }

.evo-line {
  margin-inline: auto;
  max-width: none;
  line-height: 1.2;
  white-space: nowrap;

  /* One line at every width, which is what the vw term is for and why
     there is no breakpoint here at all: the type is a fraction of the
     viewport, so the sentence takes the same share of the screen on a
     phone as it does on a desktop and never has to wrap.

     Where 4.1vw comes from. The line is 38 characters, which in this face
     at this tracking measures about 19em. The gutter either side is
     itself proportional (5vw between its floor and its cap), so the width
     left for the line stays near 87% of the viewport across the range —
     19em must fit that, giving 0.87 / 19 ≈ 0.046. 4.1 keeps a margin
     under it for the fact that 19em is an estimate of a proportional
     face rather than a measurement.

     The cap takes over at about 660px and holds the line at a heading
     size from there up. If the sentence is ever made longer, this is the
     number that has to come down with it. */
  font-size: min(4.1vw, 1.85rem);
}

/* ---------- Dot field (light theme only) ----------
   There is no resting field. The dots exist only where the pointer is,
   so the band is clean until you move over it and the light seems to
   reveal a texture that was already there.

   The patch is a fixed 448px window moved by transform. The dot layer
   inside it is counter-translated by exactly the same amount, which pins
   its background origin to the container origin: the dots stay fixed to
   the band and the light travels across them, rather than the whole sheet
   of dots dragging along with the cursor. Nothing repaints per frame,
   both layers move on transform alone. */

.cta-dots {
  position: absolute;
  inset: 0;
  z-index: 0;
  overflow: hidden;
  pointer-events: none;
  contain: layout paint;
}

.cd-spot {
  position: absolute;
  left: 0;
  top: 0;
  width: 448px;
  height: 448px;
  margin: -224px 0 0 -224px;   /* centres the window on the pointer */
  overflow: hidden;
  opacity: 0;
  transform: translate3d(var(--dx, 0px), var(--dy, 0px), 0);
  transition: opacity 420ms var(--ease);
  -webkit-mask-image: radial-gradient(circle closest-side, #000 8%, rgba(0, 0, 0, 0.5) 46%, transparent 100%);
  mask-image: radial-gradient(circle closest-side, #000 8%, rgba(0, 0, 0, 0.5) 46%, transparent 100%);
}
.cta-dots.lit .cd-spot { opacity: 1; }

.cd-fill {
  position: absolute;
  left: 0;
  top: 0;
  width: 2600px;
  height: 1400px;
  background-image: radial-gradient(currentColor 1px, transparent 1.6px);
  background-size: 14px 14px;
  color: var(--accent-text);
  opacity: 0.42;
  /* +224 cancels the window's own centring margin, so the pattern sits on
     the container origin and stays put while the window travels over it. */
  transform: translate3d(calc(224px - var(--dx, 0px)), calc(224px - var(--dy, 0px)), 0);
}

/* Dark keeps the original solid band: on the warm near-black the amber
   reads as a lit surface rather than as a shout, and a dot field on top
   of it would only muddy the one place the page asks for an action. */
[data-theme="dark"] .cta {
  background-color: var(--accent);
  color: var(--accent-ink);
}
[data-theme="dark"] .cta h2 { color: var(--accent-ink); }
[data-theme="dark"] .cta-dots { display: none; }

/* Touch has no hover to follow, and reduced motion should not get a patch
   chasing the cursor. app.js also refuses to bind in both cases. */
@media (hover: none), (pointer: coarse), (prefers-reduced-motion: reduce) {
  .cd-spot { display: none; }
}

/* ============================================================
   FOOTER
   ============================================================ */

.foot {
  border-top: 0;
  padding-block: clamp(56px, 7vw, 88px) 40px;
}
.foot-grid {
  display: grid;
  grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr) minmax(0, 1fr);
  gap: clamp(28px, 4vw, 56px);
}
@media (max-width: 820px) { .foot-grid { grid-template-columns: 1fr; gap: 32px; } }

.foot h3 {
  font-family: var(--font-mono);
  font-size: var(--fs-xs);
  text-transform: uppercase;
  letter-spacing: 0.14em;
  color: var(--ink-3);
  font-weight: 500;
  margin-bottom: 16px;
}
.foot-links { display: grid; gap: 12px; }
.foot-links a {
  display: inline-flex;
  align-items: center;
  gap: 9px;
  color: var(--ink-2);
  text-decoration: none;
  font-size: var(--fs-sm);
  width: fit-content;
  transition: color var(--t-fast) var(--ease);
}
.foot-links a:hover { color: var(--accent-text); }
.foot-links i { font-size: 17px; color: var(--ink-3); }
.foot-links a:hover i { color: var(--accent-text); }
.foot-role { color: var(--ink-3); font-size: var(--fs-xs); }

/* The wordmark carries the footer on its own now, so it gets a step up
   from the nav's UI size. */
.foot-brand .brand-text { font-size: var(--fs-lede); white-space: normal; }
.foot-brand .t-body { margin-top: 14px; font-size: var(--fs-sm); max-width: 34ch; }

.foot-meta {
  margin-top: clamp(40px, 5vw, 64px);
  padding-top: 20px;
  background-image: repeating-linear-gradient(to right, var(--rule) 0 5px, transparent 5px 11px);
  background-size: 100% 1px;
  background-repeat: no-repeat;
  background-position: left top;
  display: flex;
  gap: 16px;
  flex-wrap: wrap;
  justify-content: space-between;
  font-family: var(--font-mono);
  font-size: var(--fs-xs);
  color: var(--ink-3);
}
