/* ═══════════════════════════════════════════════════════════════════════
   hub.css — the studio hub at `/`.

   ── WHAT CHANGED, AND WHY ───────────────────────────────────────────────

   This page used to be dressed as a piece of measuring equipment: a rack of
   "units" with a signal readout, monospace designators on every module, hard
   3px edges, no glow anywhere, and one saturated colour reserved strictly for
   state. It was internally consistent and it was well argued.

   It was also unreadable to half the people it is for. The hub is the first
   thing a stranger sees, and a stranger is as likely to be a designer, a
   founder or someone who just wants a notes app as they are to be an
   engineer. `CH 01 · STUDIO OUTPUT` over lime-on-black tells that person, in
   about a second, that the page is not for them — before they have read a
   word of the copy, which was written for them and was hidden in a collapsed
   drawer.

   So the instrument is gone from the top of the page and kept at the bottom
   of it. The hero and the showcase are warm, pictorial and set in plain
   sentence case; the index below is still dense, still monospace-labelled,
   still the fastest way to scan seven products. Neither reader is asked to
   put up with the other's page.

   ── FOUR RULES THAT KEEP IT COHERENT ────────────────────────────────────

     1. WARM GROUND, WARM LIGHT. The ground is a brown-black rather than the
        blue-black that reads as "dark theme", and the accent is a coral that
        runs to amber. Warmth is doing real work here: the same layout in
        cyan-on-navy reads as a dashboard no matter what the words say.

     2. LIGHT IS ALLOWED. Blooms, soft gradients and 14px radii are in. The
        previous rule — hard edges, no glow — was a reaction against generated
        portfolio pages, and it overcorrected into something that looked like
        a terminal. The discipline that actually prevents that look is
        restraint about *where* light goes, not a ban on it: light appears
        behind the hero, behind each product's picture, and nowhere else.

     3. ONE BREAK IN THE DARK. Exactly one section on this page is light. An
        unbroken column of dark sections reads as developer tooling however
        friendly the copy is, and one full-width cream band in the middle is
        the cheapest available fix. Only one, or it stops being a break.

     4. COLOUR STILL MEANS SOMETHING — but the meanings are now split. Coral
        is identity: it marks the page's own voice, its links and its primary
        actions. Green / amber / grey are state, and only state: live, beta,
        demo. Each product keeps the accent it owns in products.mjs, spent on
        its stripe, its number and the glow behind its screenshot — never on
        a filled button, so seven products still read as seven entries in one
        catalogue rather than seven unrelated brands.

   Loaded AFTER blog.css. Tokens are re-declared on `.hub` rather than
   `:root`, so this page can have its own palette without touching the blog,
   which still runs the cyan/violet reading-room scheme. Everything inside
   body inherits from body.hub, so the override is total and local.
   ═══════════════════════════════════════════════════════════════════════ */

body.hub {
  /* ── Ground: warm brown-black. ── */
  --bg:        #0c0908;
  --bg-2:      #120e0c;
  --bg-3:      #1a1512;
  --bg-4:      #241d18;

  --panel:     rgba(255, 244, 235, 0.030);
  --panel-2:   rgba(255, 244, 235, 0.055);
  --line:      rgba(255, 236, 222, 0.10);
  --line-2:    rgba(255, 236, 222, 0.18);
  --line-3:    rgba(255, 236, 222, 0.30);

  /* ── Identity: coral running to amber, with a near-white core for the
        bloom. This trio is the page's own voice and nothing else uses it. ── */
  --accent:     #ff6a3d;
  --accent-2:   #ffab4a;
  --accent-hi:  #ffe6cf;
  --accent-rgb: 255, 106, 61;
  --accent-ink: #1a0d06;

  /* ── State, and only state. Kept clear of the identity hue on purpose:
        when "live" and "this is a link" are the same colour, neither reads. ── */
  --live:      #5fd39b;
  --live-rgb:  95, 211, 155;
  --beta:      #ffc247;

  /* ── The one light section. ── */
  --cream:     #f7efe8;
  --cream-2:   #ece0d6;
  --cream-3:   #dccec2;
  --ink:       #1a1310;
  --ink-2:     #5c4f47;

  /* Measured against --bg: 15.9 / 8.4 / 5.7 / 4.3 : 1. --text-4 carries real
     content (category labels, spec keys) at small sizes, so it is held above
     the contrast floor rather than dimmed until it looks tasteful. */
  --text:      #f8f2ec;
  --text-2:    #bdb2a8;
  --text-3:    #9b9088;
  --text-4:    #837872;

  --font-display: 'Archivo', 'Inter Tight', system-ui, sans-serif;
  --font-body:    'Inter Tight', system-ui, sans-serif;
  --font-mono:    'JetBrains Mono', ui-monospace, monospace;

  --r:         14px;
  --r-sm:      9px;
  --ease:      cubic-bezier(0.22, 1, 0.36, 1);
  --ease-mech: cubic-bezier(0.7, 0, 0.15, 1);

  background: var(--bg);
  color: var(--text);
  font-family: var(--font-body);
  overflow-x: hidden;
}

body.hub ::selection { background: rgba(var(--accent-rgb), 0.30); color: var(--text); }

body.hub :focus-visible {
  outline: 2px solid var(--accent-2);
  outline-offset: 3px;
  border-radius: 4px;
}

/* Sections sit above the fixed background field. Without this the light
   section would have the grid showing through it. */
body.hub main { position: relative; z-index: 1; }

/* ── The ground ───────────────────────────────────────────────────────
   A warm haze and a very faint grid, both faded out well before the first
   section ends. The grid is texture, not a visible structure; if you can
   read it as squares it is too strong. */
body.hub .field-glow {
  background:
    radial-gradient(ellipse 60% 40% at 50% 8%, rgba(var(--accent-rgb), 0.10), transparent 70%);
  filter: blur(60px);
  animation: none;
}
body.hub .field-grid {
  background-image:
    linear-gradient(rgba(255, 236, 222, 0.020) 1px, transparent 1px),
    linear-gradient(90deg, rgba(255, 236, 222, 0.020) 1px, transparent 1px);
  background-size: 116px 116px, 116px 116px;
  mask-image: linear-gradient(180deg, #000 0%, rgba(0,0,0,0.4) 40%, transparent 70%);
  -webkit-mask-image: linear-gradient(180deg, #000 0%, rgba(0,0,0,0.4) 40%, transparent 70%);
}

/* ═══ LAYOUT ════════════════════════════════════════════════════════════
   `.wrap` is a plain centred column here. blog.css gives it a large top
   padding, which is right for a single-column article and wrong for a page
   where several sections each contain one — so it is reset, and every
   section owns its own vertical rhythm. */
body.hub .wrap {
  max-width: 1340px;
  margin: 0 auto;
  padding: 0 clamp(18px, 3.5vw, 46px);
}

/* ═══ NAV ═══════════════════════════════════════════════════════════════ */
body.hub .nav {
  background: rgba(12, 9, 8, 0.55);
  backdrop-filter: blur(16px) saturate(130%);
  -webkit-backdrop-filter: blur(16px) saturate(130%);
  border-bottom: 1px solid transparent;
  padding: 0 clamp(16px, 3.5vw, 46px);
  height: 62px;
  gap: clamp(14px, 3vw, 40px);
  transition: background 0.3s var(--ease), border-color 0.3s var(--ease);
}
body.hub .nav.is-stuck {
  background: rgba(12, 9, 8, 0.92);
  border-bottom-color: var(--line);
}

body.hub .nav-home { gap: 10px; height: 100%; margin-right: auto; }
body.hub .nav-mark {
  width: 9px; height: 9px; border-radius: 50%;
  background: linear-gradient(140deg, var(--accent), var(--accent-2));
  box-shadow: 0 0 14px rgba(var(--accent-rgb), 0.55);
  transition: transform 0.3s var(--ease);
}
body.hub .nav-home:hover .nav-mark { transform: scale(1.25); }
body.hub .nav-initials {
  font-family: var(--font-display); font-weight: 700;
  font-size: 0.95rem; letter-spacing: 0.02em; color: var(--text);
}

body.hub .nav-links {
  font-family: var(--font-body); font-size: 0.88rem; font-weight: 400;
  letter-spacing: 0; text-transform: none;
  gap: clamp(16px, 2.4vw, 32px);
}
body.hub .nav-links a { color: var(--text-3); padding: 4px 0; transition: color 0.22s; }
body.hub .nav-links a::after { background: var(--accent); height: 1px; }
body.hub .nav-links a:hover { color: var(--text); }

.nav-cta {
  flex-shrink: 0;
  font-size: 0.84rem; font-weight: 500;
  padding: 9px 17px; border-radius: 100px;
  border: 1px solid var(--line-2); color: var(--text-2);
  transition: color 0.25s, border-color 0.25s, background 0.25s;
}
.nav-cta:hover {
  color: var(--text); border-color: rgba(var(--accent-rgb), 0.5);
  background: rgba(var(--accent-rgb), 0.10);
}
@media (max-width: 720px) { .nav-cta { display: none; } }

/*
 * blog.css hides the first and third nav links below 560px. That is right for
 * the blog, whose nav is padded with links worth dropping on a phone. Here it
 * hides "Products" and "About" and leaves "Writing" alone in the bar — it
 * drops the link to this page's own subject and keeps the one pointing off it.
 *
 * All three are short enough to fit at 320px, so all three stay.
 */
@media (max-width: 560px) {
  body.hub .nav-links a:nth-child(1),
  body.hub .nav-links a:nth-child(3) { display: block; }
}
@media (max-width: 520px) { body.hub .nav-links { font-size: 0.8rem; gap: 13px; } }
@media (max-width: 360px) { body.hub .nav-links { font-size: 0.75rem; gap: 10px; } }

/* ═══ BUTTONS ═══════════════════════════════════════════════════════════
   One shared button, two weights. The primary is a warm gradient — this page
   is allowed exactly one filled, glowing element per view, and it is always
   the thing it most wants you to click. */
.btn {
  display: inline-flex; align-items: center; gap: 10px;
  font-family: var(--font-body); font-size: 0.9rem; font-weight: 500;
  letter-spacing: 0; text-transform: none;
  padding: 12px 22px; border-radius: 100px;
  border: 1px solid transparent;
  transition: transform 0.28s var(--ease), box-shadow 0.28s var(--ease),
              background 0.28s var(--ease), border-color 0.28s var(--ease), color 0.28s;
}
.btn.lg { font-size: 0.98rem; padding: 15px 28px; }
.btn .arw { transition: transform 0.3s var(--ease); }
.btn:hover .arw { transform: translateX(4px); }
.btn.lg[href^="#index"]:hover .arw { transform: translateY(3px); }

.btn.primary {
  background: linear-gradient(135deg, var(--accent), var(--accent-2));
  color: var(--accent-ink); font-weight: 600;
  box-shadow: 0 6px 26px -8px rgba(var(--accent-rgb), 0.65);
}
.btn.primary:hover {
  transform: translateY(-2px);
  box-shadow: 0 14px 40px -10px rgba(var(--accent-rgb), 0.8);
}
.btn.ghost {
  border-color: var(--line-2); color: var(--text-2); background: transparent;
}
.btn.ghost:hover {
  border-color: var(--line-3); color: var(--text);
  background: var(--panel); transform: translateY(-2px);
}

/* ═══ HERO ══════════════════════════════════════════════════════════════
   Full viewport, one claim, one sentence, two exits — including an explicit
   "skip to the list", because the reader who wants the dense index should
   not have to scroll through three screens of showcase to discover it. */
.hero {
  position: relative;
  min-height: 100svh;
  display: flex; flex-direction: column; justify-content: center;
  /* The bottom padding is the bloom's room. Without it the copy centres in
     the full viewport and the tallest columns of light come up behind the
     buttons — legible, but it reads as an accident rather than a horizon. */
  padding: 96px clamp(18px, 3.5vw, 46px) clamp(150px, 24vh, 280px);
  overflow: hidden;
}
/*
 * Two columns: the claim, and the deck. The right half of this hero was empty
 * before — on a page whose first job is "there are seven of these and they are
 * real", that was the most expensive empty space on the site.
 */
.hero-in {
  position: relative; z-index: 2;
  max-width: 1340px; margin: 0 auto; width: 100%;
  display: grid;
  grid-template-columns: minmax(0, 1.02fr) minmax(0, 0.98fr);
  gap: clamp(24px, 4vw, 64px);
  align-items: center;
}
.hero-copy { min-width: 0; }

/* ── The deck ─────────────────────────────────────────────────────────
   Five product screenshots as planes receding into perspective.

   Two elements deep on purpose, because two different things move it and a
   single element cannot carry two transforms: `.deck` owns the slow ambient
   turn (CSS keyframes), `.deck-tilt` owns the pointer parallax (GSAP, in
   hub.js). Collapse them into one and whichever ran second would erase the
   other on every frame. */
.hero-stage {
  position: relative;
  min-height: min(60vh, 520px);
  perspective: 1500px;
  perspective-origin: 42% 50%;
}
/* The light the deck sits in. Warm, so five products with five different cool
   accents still read as one arrangement on a warm page. */
.hero-stage::before {
  content: ''; position: absolute; inset: 4% -6% 6% -2%;
  background: radial-gradient(ellipse 62% 56% at 46% 50%, rgba(var(--accent-rgb), 0.30), transparent 68%);
  filter: blur(52px); opacity: 0.7; pointer-events: none;
}
.deck-tilt { position: absolute; inset: 0; transform-style: preserve-3d; }
.deck {
  position: absolute; inset: 0;
  transform-style: preserve-3d;
  transform: rotateX(8deg) rotateY(-23deg);
  animation: deckTurn 15s var(--ease) infinite alternate;
}
@keyframes deckTurn {
  from { transform: rotateX(9.5deg) rotateY(-27deg); }
  to   { transform: rotateX(6.5deg) rotateY(-18deg); }
}

.deck-card {
  position: absolute; left: -9%; top: 56%;
  width: 70%; margin: 0;
  border-radius: 12px; overflow: hidden;
  border: 1px solid var(--line-2);
  background: var(--bg-3);
  box-shadow: 0 34px 74px -26px rgba(0, 0, 0, 0.92),
              0 0 0 1px rgba(255, 255, 255, 0.02);
  /* Each card steps right, up and away from the one in front of it. The
     keyframes below repeat this whole expression because a transform cannot
     be composed from two rules — the float has to restate the position. */
  transform:
    translate3d(calc(var(--i) * 13%), calc(-50% + var(--i) * -8%), calc(var(--i) * -140px));
  opacity: calc(1 - var(--i) * 0.15);
  animation: cardFloat 8s var(--ease) infinite alternate;
  animation-delay: calc(var(--i) * -1.15s);
}
/* Later cards paint behind earlier ones. Without this the DOM order wins in
   browsers that flatten the 3D context, and the stack inverts. */
.deck-card { z-index: calc(10 - var(--i)); }
.deck-card img { display: block; width: 100%; height: auto; }
/* A cool wash over the cards further back, so depth reads as atmosphere
   rather than only as scale. */
.deck-card::after {
  content: ''; position: absolute; inset: 0;
  background: linear-gradient(200deg, rgba(12, 9, 8, 0), rgba(12, 9, 8, 0.55));
  opacity: calc(var(--i) * 0.30);
}
@keyframes cardFloat {
  from {
    transform:
      translate3d(calc(var(--i) * 13%), calc(-50% + var(--i) * -8% - 9px), calc(var(--i) * -140px));
  }
  to {
    transform:
      translate3d(calc(var(--i) * 13%), calc(-50% + var(--i) * -8% + 9px), calc(var(--i) * -140px));
  }
}

@media (prefers-reduced-motion: reduce) {
  .deck, .deck-card { animation: none; }
}

/* Below this the deck would be a strip of thumbnails beside a squeezed
   headline, which serves neither. The hero goes back to one column and the
   bloom carries the visual weight, as it did before the deck existed. */
@media (max-width: 900px) {
  .hero-in { grid-template-columns: 1fr; }
  .hero-stage { display: none; }
}

.hero-strip {
  display: flex; align-items: center; flex-wrap: wrap; gap: 10px 18px;
  font-size: 0.82rem; color: var(--text-3);
  margin: 0 0 clamp(22px, 3vw, 34px);
}
/* Separators as pseudo-elements so the markup stays a plain list of spans. */
.hero-strip span + span::before {
  content: ''; display: inline-block; vertical-align: middle;
  width: 3px; height: 3px; border-radius: 50%;
  background: var(--line-3); margin-right: 18px;
}
.dot-live { display: inline-flex; align-items: center; gap: 8px; color: var(--live); }
.dot-live i {
  width: 7px; height: 7px; border-radius: 50%; background: currentColor;
  box-shadow: 0 0 10px rgba(var(--live-rgb), 0.8);
  animation: pulse 2.8s var(--ease) infinite;
}
@keyframes pulse {
  0%, 100% { opacity: 1; transform: scale(1); }
  50%      { opacity: 0.45; transform: scale(0.82); }
}

/*
 * The title. Sentence case, not the uppercase it used to be set in: caps at
 * this size read as a warning label, and the sentence is friendly enough that
 * shouting it works against the copy. `actually` is the load-bearing word —
 * it is the whole difference between a portfolio and a catalogue — so it is
 * the one thing on the screen that carries the identity colour.
 */
.hero-title {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: clamp(2.4rem, 5.1vw, 4.9rem);
  line-height: 1.0;
  letter-spacing: -0.042em;
  margin: 0 0 clamp(20px, 2.6vw, 30px);
  max-width: 13ch;
}
.hero-title em {
  font-style: normal;
  background: linear-gradient(115deg, var(--accent), var(--accent-2) 70%);
  -webkit-background-clip: text; background-clip: text;
  -webkit-text-fill-color: transparent; color: var(--accent);
}

.hero-sub {
  color: var(--text-2);
  font-size: clamp(1.02rem, 1.35vw, 1.22rem);
  line-height: 1.62; max-width: 54ch;
  margin: 0 0 clamp(30px, 4vw, 42px);
}

.hero-cta { display: flex; flex-wrap: wrap; gap: 12px; }

/* ── The bloom ────────────────────────────────────────────────────────
   The page's one big visual gesture: a skyline of soft light along the
   bottom of the hero. It is layered CSS gradients under a heavy blur, and
   that is the point — an earlier version drew a WebGL waveform here, which
   meant every client without WebGL (including crawlers and link-preview
   renderers) got 300px of solid black as the first thing on the page. A
   gradient cannot fail to render. */
.bloom {
  position: absolute; inset: auto 0 -2px 0;
  height: min(36vh, 320px);
  display: flex; align-items: flex-end;
  gap: clamp(14px, 2.4vw, 40px);
  padding: 0 clamp(14px, 2.4vw, 40px);
  /* Enough blur to lose the edges, not enough to lose the bars. Past about
     28px at this width the thirteen columns merge into one brown smear —
     which is what the whole element is trying not to be. */
  filter: blur(clamp(14px, 1.9vw, 28px));
  /* Screen, so the light *adds* to the ground instead of sitting on it as
     paint. This is the difference between a glow and a smudge, and it is the
     single most important declaration in the block. */
  mix-blend-mode: screen;
  opacity: 0.92;
  z-index: 1;
  pointer-events: none;
}
.bloom i {
  flex: 1 1 0;
  height: 100%;
  transform-origin: bottom;
  transform: scaleY(var(--h, 0.7));
  /* A hot core two-thirds up rather than a wash: a bar that is brightest at
     its top edge reads as a column of light, one that is brightest at the
     bottom reads as a bar chart. */
  background: linear-gradient(
    180deg,
    transparent 0%,
    rgba(var(--accent-rgb), 0.30) 20%,
    var(--accent) 44%,
    var(--accent-hi) 58%,
    var(--accent-hi) 68%,
    var(--accent-2) 82%,
    rgba(var(--accent-rgb), 0.8) 100%
  );
  /* Square columns with a flat bright band across them. Rounding the tops
     turned each one into an oval blob and the whole row read as candles. */
  border-radius: 4px 4px 0 0;
  animation: sway 9s var(--ease) infinite alternate;
  animation-delay: calc(var(--i) * -0.62s);
}
/* A wide, dim wash under the bars so the skyline sits in light rather than
   floating on black. Behind them (z-order by source order), and outside the
   blend group so it does not get screened twice. */
.hero::after {
  content: ''; position: absolute; inset: auto 0 0 0; z-index: 0;
  height: min(44vh, 400px);
  background: radial-gradient(ellipse 80% 100% at 50% 120%,
    rgba(var(--accent-rgb), 0.30), transparent 70%);
  filter: blur(30px);
  pointer-events: none;
}
/* An irregular skyline. Hand-set rather than generated: an even curve reads
   as a graph, and the whole job of this element is to read as light. Nine
   wide columns rather than thirteen narrow ones — narrow bars at this blur
   radius read as flames, which is a different and much worse idea. */
.bloom i:nth-child(1) { --h: 0.46; }
.bloom i:nth-child(2) { --h: 0.72; }
.bloom i:nth-child(3) { --h: 0.56; }
.bloom i:nth-child(4) { --h: 0.90; }
.bloom i:nth-child(5) { --h: 0.66; }
.bloom i:nth-child(6) { --h: 1.00; }
.bloom i:nth-child(7) { --h: 0.60; }
.bloom i:nth-child(8) { --h: 0.80; }
.bloom i:nth-child(9) { --h: 0.50; }
@keyframes sway {
  from { transform: scaleY(calc(var(--h) * 0.88)); }
  to   { transform: scaleY(calc(var(--h) * 1.06)); }
}
/* On a phone, nine columns across 390px are ~30px wide — narrower than the
   blur radius, which turns the skyline back into a row of candles. Drop to
   five wider ones and blur them harder. The skyline is decorative, so losing
   four of its steps costs nothing; looking like a birthday cake does. */
@media (max-width: 620px) {
  .bloom {
    height: min(30vh, 240px);
    gap: 10px; padding: 0 10px;
    filter: blur(22px);
  }
  .bloom i:nth-child(n + 6) { display: none; }
}

@media (prefers-reduced-motion: reduce) {
  .bloom i { animation: none; }
  .dot-live i { animation: none; }
}

/* The scroll cue: a line that travels down its own track, once. */
.hero-cue {
  position: absolute; left: 50%; bottom: 26px; z-index: 2;
  width: 22px; height: 40px; margin-left: -11px;
  border: 1px solid var(--line-2); border-radius: 100px;
  display: grid; place-items: start center; padding-top: 8px;
}
.hero-cue span {
  width: 2px; height: 7px; border-radius: 2px; background: var(--accent-2);
  animation: cue 2s var(--ease) infinite;
}
@keyframes cue {
  0%   { opacity: 0; transform: translateY(0); }
  30%  { opacity: 1; }
  100% { opacity: 0; transform: translateY(14px); }
}
@media (prefers-reduced-motion: reduce) { .hero-cue span { animation: none; } }
@media (max-height: 680px) { .hero-cue { display: none; } }

/* ═══ SECTION HEADS ═════════════════════════════════════════════════════
   Kept from the instrument scheme, softened. A number, a name and a rule is
   good wayfinding and it is the one piece of the old vocabulary that carries
   meaning rather than atmosphere — so it stays, at a readable size, without
   the fake telemetry that used to sit beside it. */
.mod-head {
  display: flex; align-items: center; gap: 16px;
  padding: 0 0 14px;
  border-bottom: 1px solid var(--line);
  margin-bottom: clamp(26px, 4vw, 44px);
  font-size: 0.82rem;
}
.mod-idx {
  font-family: var(--font-mono); font-size: 0.68rem; font-weight: 600;
  letter-spacing: 0.1em; color: var(--accent-2);
}
.mod-name { color: var(--text); font-weight: 500; }
.mod-rule { flex: 1; height: 1px; background: var(--line); }
.mod-meta { color: var(--text-4); font-size: 0.8rem; white-space: nowrap; }
.mod-meta a { color: var(--text-3); transition: color 0.2s; }
.mod-meta a:hover { color: var(--accent-2); }
.mod-head h2.mod-name { margin: 0; font: inherit; letter-spacing: inherit; }
@media (max-width: 560px) { .mod-meta { display: none; } }

/* ═══ SHOWCASE ══════════════════════════════════════════════════════════ */
.showcase { padding: clamp(70px, 12vh, 130px) 0 clamp(40px, 8vh, 90px); }

/*
 * One product per screen. The picture column sticks while the words beside
 * it scroll, then releases as the next product arrives — which is the entire
 * motion design of this half of the page, in four lines, with no JavaScript
 * involved. `min-height` guarantees the sticky element has somewhere to
 * travel; without it a short copy column makes the stick invisible.
 */
.chapter {
  display: grid;
  grid-template-columns: minmax(0, 0.92fr) minmax(0, 1.08fr);
  gap: clamp(30px, 5vw, 76px);
  align-items: start;
  min-height: 88vh;
  padding: clamp(30px, 7vh, 80px) 0 clamp(50px, 10vh, 110px);
}
.chapter + .chapter { border-top: 1px solid var(--line); }

/* Alternating sides, so three chapters have a rhythm instead of a pattern. */
.chapter:nth-child(even) .ch-copy   { order: 2; }
.chapter:nth-child(even) .ch-visual { order: 1; }

.ch-copy { padding-top: clamp(0px, 4vh, 40px); max-width: 56ch; }

.ch-eyebrow {
  display: flex; align-items: center; flex-wrap: wrap; gap: 8px 14px;
  margin: 0 0 18px;
  font-family: var(--font-mono); font-size: 0.66rem;
  letter-spacing: 0.14em; text-transform: uppercase;
}
.ch-num { color: var(--c1); font-weight: 600; }
.ch-cat { color: var(--text-4); }
.ch-status { display: inline-flex; align-items: center; gap: 7px; color: var(--text-3); }
.ch-status i { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.ch-status.s-ok   { color: var(--live); }
.ch-status.s-beta { color: var(--beta); }
.ch-status.s-demo { color: var(--text-4); }

.ch-name {
  font-family: var(--font-display); font-weight: 700;
  font-size: clamp(2.1rem, 4.4vw, 3.5rem);
  letter-spacing: -0.04em; line-height: 1.02;
  margin: 0 0 14px; color: var(--text);
}
/* The promise, set large. This is the sentence a non-technical reader is
   actually here for, so it is the second-biggest thing in the chapter. */
.ch-line {
  font-size: clamp(1.14rem, 1.7vw, 1.48rem);
  line-height: 1.38; letter-spacing: -0.016em;
  color: var(--text); margin: 0 0 20px; font-weight: 400;
}
.ch-pitch {
  color: var(--text-2); font-size: 1rem; line-height: 1.72; margin: 0 0 24px;
}

.ch-points { list-style: none; margin: 0 0 24px; padding: 0; display: grid; gap: 11px; }
.ch-points li {
  position: relative; padding-left: 26px;
  color: var(--text-2); font-size: 0.95rem; line-height: 1.5;
}
.ch-points li::before {
  content: ''; position: absolute; left: 0; top: 0.52em;
  width: 13px; height: 13px; border-radius: 50%;
  background: rgba(var(--accent-rgb), 0.14);
  border: 1px solid rgba(var(--accent-rgb), 0.4);
}
.ch-points li::after {
  content: ''; position: absolute; left: 4px; top: calc(0.52em + 4px);
  width: 5px; height: 5px; border-radius: 50%; background: var(--accent-2);
}

/* Who it is for. Set apart because it is the line that lets a reader rule
   themselves out quickly, which is a service rather than a loss. */
.ch-for {
  border-left: 2px solid var(--c1);
  padding: 2px 0 2px 16px; margin: 0 0 28px;
  color: var(--text-3); font-size: 0.94rem; line-height: 1.6; font-style: italic;
}

.ch-cta { display: flex; flex-wrap: wrap; gap: 11px; }

/* ── The sticky picture ── */
.ch-visual { position: sticky; top: 92px; }

/* Each product's own accent, as light behind its screenshot. This is the
   only place a product's colour is allowed to be a glow.

   It has to stay a HALO — tight to the frame, hugging its edges. The first
   version of this spread 58px of blur over a box inset 14% beyond the
   picture, and with three chapters stacked the result was a violet wash
   across the entire showcase: the warm ground the whole palette is built on
   simply disappeared behind the products' cool accents. Keep the blur under
   the inset, and keep the gradient's transparent stop early. */
.ch-visual::before {
  content: ''; position: absolute; inset: -5% -4%;
  background: radial-gradient(ellipse 55% 45% at 50% 45%, var(--c1), transparent 62%),
              radial-gradient(ellipse 45% 40% at 72% 72%, var(--c2), transparent 62%);
  opacity: 0.22; filter: blur(38px); z-index: 0; pointer-events: none;
}

.frame {
  position: relative; z-index: 1;
  margin: 0; border-radius: var(--r); overflow: hidden;
  border: 1px solid var(--line-2);
  background: var(--bg-3);
  box-shadow: 0 40px 90px -34px rgba(0, 0, 0, 0.92),
              0 2px 8px rgba(0, 0, 0, 0.4);
}
/* The address bar. It is the cheapest way to say "this is a real thing at a
   real address" — a screenshot alone could be a mockup of something that
   does not exist, which is exactly the doubt this page has to answer. */
.frame-bar {
  display: flex; align-items: center; gap: 6px;
  height: 34px; padding: 0 13px;
  background: var(--bg-4); border-bottom: 1px solid var(--line);
}
.frame-bar i { width: 9px; height: 9px; border-radius: 50%; background: var(--line-3); flex-shrink: 0; }
.frame-url {
  margin-left: 12px; padding: 3px 12px; border-radius: 100px;
  background: rgba(0, 0, 0, 0.3);
  font-family: var(--font-mono); font-size: 0.62rem; color: var(--text-4);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.frame img { display: block; width: 100%; height: auto; }

/* No screenshot yet: the same chrome, with a panel in the product's own
   colours where the picture goes. See `frame()` in hub.mjs for why the
   chrome stays rather than the frame being dropped. */
.frame-body {
  aspect-ratio: 16 / 10;
  display: flex; flex-direction: column; justify-content: center;
  gap: 12px; padding: clamp(24px, 4vw, 54px);
  background:
    radial-gradient(ellipse 70% 60% at 25% 30%, var(--c1), transparent 68%),
    radial-gradient(ellipse 60% 60% at 80% 75%, var(--c2), transparent 68%),
    linear-gradient(140deg, var(--bg-3), var(--bg-2));
}
/* The accent pair is a wash behind the words, not a colour field: at full
   strength these are bright enough to fail contrast against white text. */
.frame-body::before {
  content: ''; position: absolute; inset: 34px 0 0 0;
  background: rgba(12, 9, 8, 0.72);
}
.fb-name, .fb-line { position: relative; z-index: 1; }
.fb-name {
  font-family: var(--font-display); font-weight: 700;
  font-size: clamp(1.6rem, 3.4vw, 2.6rem); letter-spacing: -0.035em;
  color: var(--text);
}
.fb-line {
  font-size: clamp(0.94rem, 1.3vw, 1.1rem); line-height: 1.5;
  color: var(--text-2); max-width: 30ch;
}

@media (max-width: 900px) {
  .chapter {
    grid-template-columns: 1fr; gap: 30px;
    min-height: 0; padding: 36px 0 54px;
  }
  /* On one column the picture leads — it is the fastest thing to read — so
     the alternation is dropped rather than inverted.

     `relative`, NOT `static`: the halo below is absolutely positioned against
     this element, and un-positioning it hands the halo to the nearest
     positioned ancestor instead — `main` — at which point a 4% inset becomes
     4% of the whole page and every product's accent floods the layout. */
  .chapter .ch-copy   { order: 2; padding-top: 0; }
  .chapter .ch-visual { order: 1; position: relative; top: auto; }
  .ch-visual::before { inset: -6% -5%; filter: blur(30px); }
}

/* ═══ THE LIGHT BAND ════════════════════════════════════════════════════
   The only light section on the page. See rule 3 at the top of this file:
   it is here for rhythm at least as much as for what it says. Full-bleed,
   hard-edged top and bottom — a gradient into it would soften exactly the
   contrast that makes it work. */
.creed {
  position: relative; z-index: 1;
  background: var(--cream);
  color: var(--ink);
  padding: clamp(72px, 13vh, 150px) 0;
  margin-top: clamp(30px, 6vh, 80px);
}
.creed-in {
  display: grid;
  grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr);
  gap: clamp(34px, 6vw, 90px);
  align-items: start;
}
/* The left column is sticky so the claim stays with the three points as they
   scroll past it, instead of leaving half a screen of empty cream behind. */
.creed-lead { position: sticky; top: 110px; }
.creed-h {
  font-family: var(--font-display); font-weight: 700;
  font-size: clamp(2.2rem, 4.6vw, 3.8rem);
  letter-spacing: -0.045em; line-height: 0.98;
  margin: 0 0 20px; color: var(--ink);
}
.creed-sub {
  margin: 0 0 26px; max-width: 34ch;
  color: var(--ink-2); font-size: 1rem; line-height: 1.65;
}
/* The ghost button's tokens are tuned for the dark ground; on cream it needs
   the ink values or it renders as pale grey on pale cream.

   Selector must be `.btn.ghost.creed-btn`, not `.creed-btn` — `.btn.ghost`
   is two classes and would otherwise win on specificity and put --text-2
   (a mid grey meant for a near-black ground) on a cream panel. */
.btn.ghost.creed-btn { border-color: var(--cream-3); color: var(--ink); }
.btn.ghost.creed-btn:hover {
  border-color: var(--ink); color: var(--ink);
  background: rgba(26, 19, 16, 0.06);
}
.creed-grid { display: grid; gap: clamp(24px, 3.4vw, 38px); }
.creed-item {
  display: grid;
  grid-template-columns: 42px minmax(0, 1fr);
  gap: 6px 16px;
  padding-top: 22px;
  border-top: 1px solid var(--cream-3);
}
.creed-n {
  grid-row: 1 / span 2;
  font-family: var(--font-mono); font-size: 0.72rem; font-weight: 600;
  letter-spacing: 0.1em; color: var(--accent); padding-top: 3px;
}
.creed-item h3 {
  margin: 0; font-family: var(--font-display); font-weight: 650;
  font-size: clamp(1.1rem, 1.6vw, 1.35rem); letter-spacing: -0.025em;
  color: var(--ink);
}
.creed-item p {
  margin: 0; color: var(--ink-2); font-size: 0.98rem; line-height: 1.68;
  max-width: 54ch;
}
@media (max-width: 820px) {
  .creed-in { grid-template-columns: 1fr; gap: 34px; }
  .creed-lead { position: static; }
}

/* ═══ THE INDEX ═════════════════════════════════════════════════════════
   The dense half. Seven products as seven rows, not a grid of cards: a list
   scans down one edge, and it has somewhere honest to put the index, the
   stripe and the status. The detail is in the DOM for a crawler on arrival
   and collapsed for a reader until they ask for it.

   This is where the monospace and the tight rhythm survive, because here
   they are doing a job — this reader wants a table and should be given one. */
.products { padding: clamp(70px, 12vh, 130px) 0 clamp(50px, 9vh, 100px); }

.p-filter {
  display: flex; flex-wrap: wrap; align-items: center; gap: 7px;
  margin-bottom: clamp(18px, 2.6vw, 26px);
}
.p-filter-label {
  font-family: var(--font-mono); font-size: 0.62rem; letter-spacing: 0.18em;
  text-transform: uppercase; color: var(--text-4); margin-right: 8px;
}
.fbtn {
  font-size: 0.82rem; font-weight: 400;
  padding: 8px 15px; border-radius: 100px;
  border: 1px solid var(--line); background: transparent; color: var(--text-3);
  cursor: pointer;
  transition: color 0.22s, border-color 0.22s, background 0.22s;
}
.fbtn:hover { color: var(--text); border-color: var(--line-2); }
.fbtn[aria-pressed="true"] {
  color: var(--accent-ink); border-color: transparent;
  background: linear-gradient(135deg, var(--accent), var(--accent-2));
  font-weight: 500;
}
.fbtn b { font-weight: 400; opacity: 0.55; margin-left: 6px; }
/* Expand-all sits with the filters but is not one of them — it toggles state
   rather than selecting a set, so it never takes the pressed treatment. */
.fbtn-all { margin-left: 10px; border-style: dashed; color: var(--text-3); }
.fbtn-all:hover { color: var(--accent-2); border-color: var(--accent-2); border-style: solid; }
.p-count {
  margin-left: auto;
  font-family: var(--font-mono); font-size: 0.62rem; letter-spacing: 0.14em;
  text-transform: uppercase; color: var(--text-4);
}
@media (max-width: 640px) { .p-count { display: none; } }

.p-rack {
  list-style: none; margin: 0; padding: 0;
  border-top: 1px solid var(--line);
}

.p-unit {
  position: relative;
  border-bottom: 1px solid var(--line);
  transition: background 0.35s var(--ease);
}
.p-unit:hover { background: var(--panel); }
.p-unit.is-open { background: var(--panel); }

/* The stripe — where each product's own colour lives in this half. */
.p-unit::before {
  content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 2px;
  background: var(--c1);
  transform: scaleY(0); transform-origin: top;
  transition: transform 0.45s var(--ease-mech);
}
.p-unit:hover::before, .p-unit.is-open::before { transform: scaleY(1); }

.p-bar {
  display: grid;
  grid-template-columns: 46px 132px minmax(0, 1fr) minmax(0, 1.25fr) auto auto;
  grid-template-areas: 'idx thumb id gist status toggle';
  align-items: center; gap: clamp(12px, 1.8vw, 24px);
  width: 100%; text-align: left;
  padding: clamp(16px, 2vw, 22px) clamp(10px, 1.6vw, 20px);
  background: none; border: 0; color: inherit; font: inherit;
  cursor: pointer;
}
.p-idx { grid-area: idx; }
.p-thumb { grid-area: thumb; }
.p-id { grid-area: id; display: block; min-width: 0; }
.p-gist { grid-area: gist; min-width: 0; display: block; }
.p-status { grid-area: status; }
.p-toggle { grid-area: toggle; }

/* The thumbnail. This is the change that turns the index from a table of
   names into something a stranger can read at a glance — seven rows of text
   ask you to already know what the words mean, seven rows with pictures do
   not. Held at 16:10, the same ratio as every full-size shot, so nothing in
   the column is letterboxed or cropped differently from its neighbour. */
.p-thumb {
  display: block; position: relative;
  aspect-ratio: 16 / 10; border-radius: 7px; overflow: hidden;
  border: 1px solid var(--line);
  background: var(--bg-3);
  transition: border-color 0.35s var(--ease), transform 0.45s var(--ease);
}
.p-thumb img {
  display: block; width: 100%; height: 100%;
  object-fit: cover; object-position: top center;
  opacity: 0.72; transition: opacity 0.4s var(--ease);
}
.p-unit:hover .p-thumb,
.p-unit.is-open .p-thumb { border-color: var(--line-2); transform: scale(1.03); }
.p-unit:hover .p-thumb img,
.p-unit.is-open .p-thumb img { opacity: 1; }
/* A product with no capture keeps the column's rhythm rather than leaving a
   ragged hole in it. */
.p-thumb-blank { background: linear-gradient(140deg, var(--bg-3), var(--bg-2)); }

.p-meta {
  display: block; margin-top: 5px;
  font-family: var(--font-mono); font-size: 0.62rem;
  letter-spacing: 0.06em; color: var(--text-4);
}
/* At rest the number is neutral; it takes the product's accent on hover and
   while open — identity when you are actually looking at that row. */
.p-idx {
  font-family: var(--font-mono); font-size: 0.74rem; font-weight: 600;
  letter-spacing: 0.06em; color: var(--text-4);
  transition: color 0.3s var(--ease);
}
.p-unit:hover .p-idx, .p-unit.is-open .p-idx { color: var(--c1); }
.p-name {
  display: block;
  font-family: var(--font-display); font-weight: 650;
  font-size: clamp(1.2rem, 1.9vw, 1.6rem); letter-spacing: -0.032em;
  line-height: 1.06; margin: 0; color: var(--text);
}
.p-cat {
  display: block; margin-top: 5px;
  font-family: var(--font-mono); font-size: 0.58rem;
  letter-spacing: 0.16em; text-transform: uppercase; color: var(--text-4);
}
.p-tagline {
  display: block;
  color: var(--text-2); font-size: 0.94rem; line-height: 1.45; margin: 0;
}
.p-status {
  display: inline-flex; align-items: center; gap: 7px;
  font-family: var(--font-mono); font-size: 0.58rem; font-weight: 500;
  letter-spacing: 0.16em; text-transform: uppercase; color: var(--text-3);
}
.p-status i { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.p-status.s-ok   { color: var(--live); }
.p-status.s-beta { color: var(--beta); }
.p-status.s-demo { color: var(--text-4); }

/* The expand affordance — a plus that becomes a minus. */
.p-toggle {
  position: relative; width: 28px; height: 28px; flex-shrink: 0;
  border: 1px solid var(--line-2); border-radius: 50%;
  transition: border-color 0.25s, background 0.25s;
}
.p-unit:hover .p-toggle { border-color: var(--line-3); }
.p-toggle::before, .p-toggle::after {
  content: ''; position: absolute; top: 50%; left: 50%;
  width: 9px; height: 1px; background: var(--text-2);
  transform: translate(-50%, -50%);
  transition: transform 0.4s var(--ease-mech), background 0.25s;
}
.p-toggle::after { transform: translate(-50%, -50%) rotate(90deg); }
.p-unit.is-open .p-toggle { border-color: var(--accent-2); }
.p-unit.is-open .p-toggle::before,
.p-unit.is-open .p-toggle::after {
  background: var(--accent-2); transform: translate(-50%, -50%) rotate(180deg);
}

/* ── The detail drawer ──
   Height is animated from a measured pixel value by hub.js. Without JS the
   drawer is simply open — `html:not(.js)` below — so the page is complete
   for a crawler and for a reader with scripting off. */
.p-detail { overflow: hidden; }
html.js .p-detail { height: 0; }
html.js .p-unit.is-open .p-detail { height: auto; }

.p-detail-in {
  display: grid;
  grid-template-columns: 54px minmax(0, 1fr) minmax(0, 1fr);
  gap: clamp(14px, 2vw, 28px);
  padding: 0 clamp(10px, 1.6vw, 20px) clamp(26px, 3vw, 36px);
}
.p-detail-in > .sp { grid-column: 1; }
.p-pitch { color: var(--text-2); font-size: 0.94rem; line-height: 1.7; margin: 0 0 14px; }
.p-audience {
  font-size: 0.88rem; line-height: 1.6; font-style: italic;
  color: var(--text-4); margin: 0;
}

/* The product's screenshot inside its open drawer. Muted at rest and brought
   to full strength on hover: at this size it is evidence that the thing
   exists, not something to read, and a bright picture would outweigh the
   words beside it. */
.p-shot {
  margin: 18px 0 0; border-radius: var(--r-sm); overflow: hidden;
  border: 1px solid var(--line);
  line-height: 0;
  /* Capped. At the full width of its column this became the largest thing in
     an open drawer, which inverts the row's hierarchy — the words are what
     the reader opened it for. */
  max-width: 430px;
}
.p-shot img {
  display: block; width: 100%; height: auto;
  opacity: 0.78; transition: opacity 0.4s var(--ease);
}
.p-unit:hover .p-shot img { opacity: 1; }

.p-highlights { list-style: none; margin: 0 0 18px; padding: 0; display: grid; gap: 9px; }
.p-highlights li {
  position: relative; padding-left: 18px;
  font-size: 0.88rem; line-height: 1.5; color: var(--text-2);
}
.p-highlights li::before {
  content: ''; position: absolute; left: 0; top: 0.66em;
  width: 8px; height: 1px; background: var(--c1);
}

/* Spec table — the data sheet for this row. */
.p-spec { display: grid; gap: 0; margin: 0 0 20px; }
.p-spec div {
  display: grid; grid-template-columns: 82px 1fr; gap: 12px;
  padding: 8px 0; border-top: 1px solid var(--line);
  font-family: var(--font-mono); font-size: 0.64rem; letter-spacing: 0.03em;
}
.p-spec dt { color: var(--text-4); letter-spacing: 0.13em; text-transform: uppercase; }
.p-spec dd { color: var(--text-2); margin: 0; }

.p-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; }
.p-try {
  display: inline-flex; align-items: center; gap: 9px;
  font-size: 0.86rem; font-weight: 600;
  color: var(--accent-ink);
  background: linear-gradient(135deg, var(--accent), var(--accent-2));
  border-radius: 100px; padding: 11px 20px;
  transition: transform 0.28s var(--ease), box-shadow 0.28s var(--ease);
}
.p-try:hover { transform: translateY(-2px); box-shadow: 0 12px 30px -10px rgba(var(--accent-rgb), 0.7); }
.p-try span { transition: transform 0.3s var(--ease); }
.p-try:hover span { transform: translateX(4px); }
.p-code {
  font-size: 0.84rem; color: var(--text-3);
  padding: 11px 18px; border: 1px solid var(--line); border-radius: 100px;
  transition: color 0.25s, border-color 0.25s;
}
.p-code:hover { color: var(--text); border-color: var(--line-2); }

/* Filtered-out rows. Kept in the DOM, just not displayed — a filter should
   narrow a view, not delete content. */
.p-unit.is-filtered { display: none; }

/* ── Index responsive ──
   The row sheds columns rather than reflowing them: on a narrow screen the
   tagline and status move under the name instead of squeezing. */
@media (max-width: 1080px) {
  .p-bar {
    grid-template-columns: 42px 108px minmax(0, 1fr) auto auto;
    grid-template-areas:
      'idx thumb id   status toggle'
      '.   thumb gist gist   gist';
    row-gap: 8px; align-items: start;
  }
  .p-thumb { align-self: center; }
  .p-status, .p-toggle { align-self: center; }
  .p-detail-in { grid-template-columns: 42px minmax(0, 1fr); }
  .p-detail-in > .col-b { grid-column: 2; }
}
@media (max-width: 720px) {
  /* Below this the thumbnail would be smaller than a favicon and the words
     would be squeezed to pay for it. The picture goes; the listing keeps its
     name, its promise and its price, which is the part that has to survive. */
  .p-bar {
    grid-template-columns: 30px minmax(0, 1fr) auto;
    grid-template-areas:
      'idx id   toggle'
      '.   gist gist';
    gap: 10px 12px; padding: 18px 4px;
  }
  .p-bar .p-thumb { display: none; }
  .p-bar .p-status { display: none; }
  .p-idx { font-size: 0.64rem; }
  .p-detail-in { grid-template-columns: 30px minmax(0, 1fr); padding: 0 4px 26px; gap: 12px; }
  .p-spec div { grid-template-columns: 70px 1fr; }
}

/* ═══ WRITING ═══════════════════════════════════════════════════════════ */
.hub-writing { padding: 0 0 clamp(50px, 9vh, 100px); }
.hub-posts { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); }
.hub-posts a {
  display: grid; grid-template-columns: 54px 1fr auto; align-items: baseline;
  gap: clamp(12px, 2vw, 26px);
  padding: 18px clamp(10px, 1.6vw, 20px);
  border-bottom: 1px solid var(--line);
  transition: background 0.3s var(--ease);
}
.hub-posts a:hover { background: var(--panel); }
.hp-idx { font-family: var(--font-mono); font-size: 0.66rem; color: var(--text-4); }
.hp-title {
  font-family: var(--font-display); font-weight: 600;
  font-size: 1.05rem; letter-spacing: -0.024em; color: var(--text);
}
.hp-desc {
  display: block; margin-top: 5px;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  color: var(--text-3); font-size: 0.88rem;
}
.hp-meta {
  font-family: var(--font-mono); font-size: 0.6rem; letter-spacing: 0.12em;
  text-transform: uppercase; color: var(--text-4); white-space: nowrap;
}
@media (max-width: 640px) {
  .hub-posts a { grid-template-columns: 30px 1fr; }
  .hp-meta { display: none; }
}

/* ═══ HIRE ══════════════════════════════════════════════════════════════ */
/* Almost nothing below it: the footer's own top padding is the separation,
   and closing this gap is what makes the panel and the footer read as one
   closing gesture instead of two stranded blocks. */
.hub-hire { padding: 0 0 clamp(14px, 2vh, 26px); }
.hire-in {
  position: relative; overflow: hidden;
  display: grid;
  grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.85fr);
  gap: clamp(28px, 4.5vw, 64px);
  align-items: center;
  border: 1px solid var(--line);
  border-radius: clamp(16px, 2vw, 26px);
  padding: clamp(30px, 4.6vw, 58px);
  background:
    radial-gradient(ellipse 70% 130% at 100% 0%, rgba(var(--accent-rgb), 0.12), transparent 62%),
    var(--bg-2);
  /* `.wrap` is the centring column and this is the panel inside it, so the
     panel's own horizontal padding is added on top rather than replacing it. */
  max-width: 1340px;
}

/* ── What the work is ── */
.hire-what {
  list-style: none; margin: 0; padding: 0;
  display: grid; gap: 0;
}
.hire-what li {
  display: grid;
  grid-template-columns: 30px minmax(0, 1fr);
  gap: 2px 14px;
  padding: 16px 0;
  border-top: 1px solid var(--line);
}
/* No rule on the last row — the panel's own edge is a few pixels below it,
   and two closing lines that near each other read as a misalignment. */
.hw-n {
  grid-row: 1 / span 2;
  font-family: var(--font-mono); font-size: 0.64rem; font-weight: 600;
  letter-spacing: 0.1em; color: var(--accent-2); padding-top: 3px;
}
.hw-t {
  font-family: var(--font-display); font-weight: 620;
  font-size: 1.02rem; letter-spacing: -0.022em; color: var(--text);
}
.hw-d { color: var(--text-3); font-size: 0.88rem; line-height: 1.55; }

@media (max-width: 860px) {
  .hire-in { grid-template-columns: 1fr; gap: 28px; }
}
.hire-eyebrow {
  font-family: var(--font-mono); font-size: 0.64rem;
  letter-spacing: 0.2em; text-transform: uppercase; color: var(--accent-2);
  margin: 0 0 16px;
}
.hub-hire h2 {
  font-family: var(--font-display); font-weight: 700;
  font-size: clamp(1.6rem, 3.4vw, 2.7rem); letter-spacing: -0.04em;
  line-height: 1.04; margin: 0 0 16px; color: var(--text);
}
.hire-p { color: var(--text-2); max-width: 56ch; margin: 0 0 30px; line-height: 1.68; }

/* ═══ FOOT ══════════════════════════════════════════════════════════════
   A real footer, for two reasons.

   The first is structural: every product lives on its own subdomain and
   nothing else on the web links to them, so this page is their only route in.
   Repeating all seven here means a crawler that stops at the index, or one
   that renders no JavaScript, still leaves with every product URL.

   The second is that the page used to end in four small links and a stretch
   of empty ground — a page that spends three screens introducing products
   should not read as though it ran out of them. */
body.hub .foot {
  position: relative;
  border-top: 0;
  /* blog.css gives `.foot` `margin: clamp(70px,10vw,130px) auto 0` and a
     1500px max-width — right for an article, wrong here. That margin was the
     130px of dead ground between the freelance panel and the footer, and the
     max-width stopped this footer being the full-bleed band it is drawn as.
     Both have to be reset explicitly; neither is inherited away. */
  margin: 0;
  max-width: none;
  padding: 0;
  overflow: hidden;
  background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.4));
}
/* A gradient hairline rather than a flat one: the rule is brightest under the
   brand and fades out across the columns, which gives the top edge of the
   footer a direction instead of a border. */
body.hub .foot::before {
  content: ''; position: absolute; inset: 0 0 auto 0; height: 1px;
  background: linear-gradient(90deg,
    rgba(var(--accent-rgb), 0.45), var(--line) 38%, var(--line) 100%);
}
/* The closing light — the hero's horizon, dimmer, low and wide. */
.foot-glow {
  /* Anchored to the footer's own bottom edge, not pushed past it: the
     gradient's hot point is at 100% of this box, so if the box hangs below
     the footer the `overflow: hidden` above clips away the only part worth
     seeing and the glow renders as nothing. */
  position: absolute; inset: auto 0 0 0; height: 300px;
  background: radial-gradient(ellipse 55% 120% at 50% 108%,
    rgba(var(--accent-rgb), 0.22), transparent 68%);
  filter: blur(30px); pointer-events: none;
}

body.hub .foot-in {
  position: relative; z-index: 1;
  display: grid;
  /* Four columns, not three. The brand used to hold the socials as well and
     took 1.5fr to do it, which left a wide gap of nothing between it and the
     product list — the footer read as unbalanced rather than airy. Splitting
     the links off into their own column narrows the brand and closes it. */
  /* The brand column is sized to its own text rather than to a share of the
     grid. At 1.15fr it was far wider than the 30ch it holds, which opened a
     void between it and the product list and made a perfectly ordinary footer
     look broken. */
  grid-template-columns:
    minmax(0, 0.92fr) minmax(0, 0.9fr) minmax(0, 0.5fr) minmax(0, 0.5fr);
  gap: clamp(22px, 2.6vw, 40px);
  padding-top: clamp(48px, 6vw, 74px);
  padding-bottom: clamp(30px, 4vw, 46px);
}

.foot-logo {
  display: inline-flex; align-items: center; gap: 10px;
  font-family: var(--font-display); font-weight: 700;
  font-size: 1.06rem; letter-spacing: -0.02em; color: var(--text);
  margin-bottom: 14px;
}
.foot-tag {
  color: var(--text-3); font-size: 0.92rem; line-height: 1.62;
  max-width: 30ch; margin: 0 0 18px;
}
/* The one line in the footer that is an offer rather than a link. It reuses
   the hero's live dot, so the same green means the same thing in both places:
   this is true right now. */
.foot-avail {
  display: inline-flex; align-items: center; gap: 9px;
  margin: 0; padding: 7px 15px 7px 12px;
  border: 1px solid var(--line); border-radius: 100px;
  font-size: 0.84rem; color: var(--text-2);
}

.foot-h {
  display: block;
  font-family: var(--font-mono); font-size: 0.6rem; font-weight: 500;
  letter-spacing: 0.2em; text-transform: uppercase; color: var(--text-4);
  margin-bottom: 16px;
}
.foot-col ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; }
.foot-products ul { gap: 0; }
.foot-col a {
  display: flex; align-items: baseline; gap: 10px;
  padding: 5px 0;
  font-size: 0.92rem; color: var(--text-2);
  transition: color 0.22s;
}
.foot-col a:hover { color: var(--accent-2); }
/* The category, so the footer list is still a listing rather than seven
   proper nouns a stranger has no way to tell apart. */
.foot-note {
  font-family: var(--font-mono); font-size: 0.58rem;
  letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-4);
}

/* ── The signature ──
   Full-bleed, clipped by the footer's own edges, and only a few percent
   brighter than the ground. It is a watermark: if you notice it as text before
   you notice it as a shape, it is too bright. */
.foot-mark {
  position: absolute; z-index: 0;
  left: 50%; bottom: -0.30em; transform: translateX(-50%);
  font-family: var(--font-display); font-weight: 800;
  font-size: clamp(4.4rem, 16.5vw, 15rem);
  letter-spacing: -0.055em; line-height: 0.78;
  white-space: nowrap; text-transform: uppercase;
  background: linear-gradient(180deg,
    rgba(255, 236, 222, 0.028), rgba(var(--accent-rgb), 0.075) 84%);
  -webkit-background-clip: text; background-clip: text;
  color: transparent;
  pointer-events: none; user-select: none;
}

body.hub .foot-base {
  position: relative; z-index: 1;
  display: flex; flex-wrap: wrap; justify-content: space-between; gap: 10px 20px;
  padding-top: 20px; padding-bottom: 26px;
  border-top: 1px solid var(--line);
  font-size: 0.8rem; color: var(--text-4);
}
.foot-top {
  display: inline-flex; align-items: center; gap: 7px;
  color: var(--text-4); transition: color 0.22s;
}
.foot-top:hover { color: var(--accent-2); }
.foot-top span { transition: transform 0.3s var(--ease); }
.foot-top:hover span { transform: translateY(-3px); }

@media (max-width: 1040px) {
  body.hub .foot-in { grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr) minmax(0, 1fr); }
  .foot-brand { grid-row: span 2; }
}
@media (max-width: 780px) {
  body.hub .foot-in { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 34px; }
  .foot-brand { grid-column: 1 / -1; grid-row: auto; }
}
@media (max-width: 460px) {
  body.hub .foot-in { grid-template-columns: 1fr; gap: 28px; }
  .foot-brand { grid-column: auto; }
}

/* ═══ REVEAL ════════════════════════════════════════════════════════════
   Gated on `html.js`: a crawler and a JS-off visitor get the whole page,
   unhidden, on arrival. This page's entire purpose is being read by that
   client, so the enhancement is never a precondition for the content. */
html.js body.hub [data-reveal] {
  opacity: 0;
  transform: translateY(16px);
}
html.js body.hub [data-reveal].is-in {
  opacity: 1; transform: none;
  transition: opacity 0.6s var(--ease), transform 0.7s var(--ease);
}
@media (prefers-reduced-motion: reduce) {
  html.js body.hub [data-reveal] { opacity: 1; transform: none; }
}

/* No JS: the drawers are open, so every word is on the page. */
html:not(.js) body.hub .p-toggle,
html:not(.js) body.hub .p-filter { display: none; }
html:not(.js) body.hub .p-bar { cursor: default; }

/* ═══ HERO ENTRANCE ═════════════════════════════════════════════════════
   A short, plain rise. hub.js adds `.hero-in-anim` and a `--d` index, then
   lights the sequence with `.is-lit`; the easing lives here with the rest of
   the design. Gated on JS having run — the elements are only hidden once JS
   has marked them, so a page whose scripts never load shows the hero
   immediately rather than never. */
.hero .hero-anim { opacity: 0; transform: translateY(18px); }
.hero.is-lit .hero-anim {
  opacity: 1; transform: none;
  transition: opacity 0.7s var(--ease) var(--d, 0s),
              transform 0.9s var(--ease) var(--d, 0s);
}
@media (prefers-reduced-motion: reduce) {
  .hero .hero-anim { opacity: 1; transform: none; transition: none; }
}

/* The chapter headings that wrap each index row's button carry no visual
   weight of their own — the button inside is the whole row. They exist for
   the document outline and for the accordion pattern. */
.p-h { margin: 0; font: inherit; }

/* A row arrived at by deep link gets a brief marker, so a shared `/#cairn`
   link makes it obvious which of seven rows was meant. */
.p-unit:target::before { transform: scaleY(1); }
.p-unit:target .p-idx { color: var(--c1); }

/* Anchored sections must clear the fixed header when jumped to. */
body.hub :target { scroll-margin-top: 80px; }
.showcase, .products { scroll-margin-top: 70px; }

/* ═══ TOUCH TARGETS ═════════════════════════════════════════════════════
 * Coarse pointers only — the desktop layout is untouched by any of this.
 *
 * Measured at 390px beforehand: the nav links were 28px tall, the category
 * filter pills 33px, the footer's product/site/elsewhere links 34px, and
 * "Back to top" 20px. WCAG 2.2 wants 44x44; a thumb is about that wide.
 *
 * The nav and footer links take their extra height from padding, not from an
 * invisible overlay: both are laid out in tight lists where a 44px overlay
 * would hang over the neighbouring row and steal its taps.
 */
@media (hover: none), (pointer: coarse) {
  body.hub .nav-links a {
    padding: 12px 0;
    min-height: 44px;
    display: inline-flex;
    align-items: center;
  }

  .fbtn {
    min-height: 44px;
    padding: 10px 16px;
  }

  .foot-col a { padding: 11px 0; min-height: 44px; align-items: center; }
  .foot-logo,
  .foot-top {
    min-height: 44px;
    display: inline-flex;
    align-items: center;
  }
}
