/* ============================================================
   hero-sphere.css — the sphere and its ring of eight points.

   This is the original composition, restored. Two later reworkings
   — an orbital scene, then a dense ball with the practices listed
   underneath it — were both rejected; the ball, the dotted orbit,
   the eight spokes and the eight labels around them are what Lucas
   asked to have back, and they are not to be reinterpreted again.

   Gated on one attribute, which is what makes pulling it out a
   one-line revert:

       <section class="hero" data-hero-visual="sphere">   ← sphere
       <section class="hero">                             ← nothing

   It is NOT gated by appearance any more. The sphere used to belong
   to one side of a toggle that swapped identity; the toggle changes
   Light and Dark only now, and the sphere takes its ink from
   tokens.css, so the one composition reads in both.

   ---- the geometry, and why --------------------------------
   At 1440px the hero grid is 1248px, split evenly, so the sphere
   has a 36rem box. Everything below is a fraction of that box:

       canvas            62% of the box
       sphere radius     18%    (184px across)
       spoke start       23% x, 26% y     ← 26px clear of the ball
       spoke end        29.5% x, 34% y
       label            past the spoke end, 5.5rem wide

   The ring is an ELLIPSE, not a circle: the column is tighter
   across than down, so the east and west points have to sit in
   closer than the diagonals or they push out of the column.
   The spoke endpoints in index.html are computed from these
   same four numbers — change them there and here together.
   ============================================================ */

.hero__sphere,
.hero__caption { display: none; }

.hero[data-hero-visual="sphere"] .hero__grid {
  /* even columns: the three-line name no longer needs the larger
     share, and the ring uses everything it is given */
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}

.hero[data-hero-visual="sphere"] .hero__sphere {
  display: block;
  position: relative;
  width: 100%;
  max-width: 36rem;
  aspect-ratio: 1 / 1;
  margin-inline: auto;
}

/* ---- the sphere -------------------------------------------
   The canvas is deliberately much larger than the ball inside
   it (62% of the box, holding a ball of 18%): the cursor shoves
   points outward, and they need somewhere to go that is not the
   canvas edge, or they visibly clip. */
.hero__sphere canvas {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 62%;
  height: 62%;
  transform: translate(-50%, -50%);
  display: block;
}

/* ---- orbit and spokes -------------------------------------- */
.hero__spokes {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  color: var(--text-3);
  pointer-events: none;
  overflow: visible;
}
.hero__orbit {
  fill: none;
  stroke: currentColor;
  stroke-width: 1;
  vector-effect: non-scaling-stroke;
  stroke-dasharray: 1 5;
  opacity: 0.28;
}
.hero__spokes line {
  stroke: currentColor;
  stroke-width: 1;
  vector-effect: non-scaling-stroke;
  opacity: 0.45;
  transition: opacity 320ms var(--ease-sand), stroke-width 320ms var(--ease-sand);
}
.hero__spokes circle {
  fill: currentColor;
  opacity: 0.8;
  transition: opacity 320ms var(--ease-sand), r 320ms var(--ease-sand);
}

/* the spoke belonging to the point under the cursor */
.hero__spokes line.is-hot   { opacity: 1; stroke-width: 1.6; }
.hero__spokes circle.is-hot { opacity: 1; r: 1.5; }

/* ---- the points -------------------------------------------- */
.hero__points {
  position: absolute;
  inset: 0;
  z-index: 2;             /* always above the ball and its spokes */
  margin: 0;
  padding: 0;
  list-style: none;
  pointer-events: none;   /* only the labels themselves are hoverable */
}

/* The ball and the spokes must never be able to take the pointer.
   Dimming them applies a `filter`, which gives the element its own
   stacking context; the canvas then painted over the label, the
   pointer counted as having left it, the dimming was removed, and
   the whole thing flickered on and off about four times a second. */
.hero__sphere canvas,
.hero__spokes { pointer-events: none; }
.spt {
  position: absolute;
  width: max-content;
  max-width: 5.5rem;
  pointer-events: auto;
  cursor: default;
  font-family: var(--font-mono);
  font-size: 0.7rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  line-height: 1.4;
  color: var(--text-3);
  transition: color 300ms var(--ease-sand), opacity 300ms var(--ease-sand);
}
.spt:hover { color: var(--text); }

/* ---- a node is a destination, not a caption ----------------
   Every term is an ordinary link to the project it came from:
   hovering shows what is behind it, following it goes there, and
   with scripts off it still does. Reset to the label it looks
   like, then given a hit area a thumb can find without changing
   where the type sits.
   -------------------------------------------------------------- */
a.spt__t {
  position: relative;
  display: block;
  color: inherit;
  text-decoration: none;
  cursor: pointer;
}
a.spt__t::before {
  content: "";
  position: absolute;
  inset: -0.7rem -0.5rem;
}
.spt--term { cursor: pointer; }
a.spt__t:focus-visible { outline: 2px solid var(--text); outline-offset: 4px; border-radius: 2px; }

/* ---- one point at a time ------------------------------------
   Hovering a point pulls it forward by pushing everything else
   back: the other labels, their spokes, the orbit and the ball
   all drop away, so the sentence in the caption has an obvious
   owner. Nothing moves; only weight changes.

   The dimming rides on `filter`, not `opacity`. The arrival
   sequence animates opacity with fill-mode `both`, and a running
   animation outranks anything the cascade says about the property
   it holds — an opacity rule here simply never applied.
   -------------------------------------------------------------- */
.hero__sphere .spt,
.hero__sphere canvas,
.hero__sphere .hero__orbit,
.hero__spokes line,
.hero__spokes circle { transition: filter 300ms var(--ease-sand); }
.hero__sphere .spt { transition: color 300ms var(--ease-sand), filter 300ms var(--ease-sand); }

.hero__sphere.is-focusing .spt { filter: opacity(0.24); }
.hero__sphere.is-focusing .spt.is-focused { filter: none; color: var(--text); }
.hero__sphere.is-focusing .hero__spokes line:not(.is-hot),
.hero__sphere.is-focusing .hero__spokes circle:not(.is-hot) { filter: opacity(0.2); }
.hero__sphere.is-focusing .hero__orbit { filter: opacity(0.35); }
/* The ball stays on the page while a term is being read. It used to
   drop to .45 on hover and to .14 once a point with pillars was open,
   and at .14 it read as the sphere leaving rather than standing back —
   the one thing a visitor came to look at, gone at the moment they
   reached for it. Both cases hold at .48: far enough back that the
   term and its pillars are what the eye lands on, near enough that
   the object is still there. The pillar panel is an opaque
   var(--surface), so nothing behind it costs its text any contrast. */
.hero__sphere.is-focusing canvas { filter: opacity(0.48); }
.hero__sphere.is-focusing:has(.spt.is-focused .spt__p) canvas { filter: opacity(0.48); }

html.no-motion .hero__sphere .spt,
html.no-motion .hero__sphere canvas,
html.no-motion .hero__sphere .hero__orbit,
html.no-motion .hero__spokes line,
html.no-motion .hero__spokes circle { transition: none; }

/* the identity at the top gets more room than the seven terms
   around it, and reads as a statement rather than a term */
.spt--lead { max-width: 13rem; font-size: var(--text-micro); color: var(--text-2); }

/* ---- the pillars, beside the point -------------------------
   Hovering a term opens a small panel next to it with what is
   actually behind the word, a line at a time. It opens away from
   the ball so it never covers it: east points to the right, west
   points to the left. The sentence still goes to the caption
   under the sphere, which is where the eye already expects it.
   -------------------------------------------------------------- */
.spt__p {
  /* Out of flow until it is placed. `visibility: hidden` still takes up
     layout, and an unplaced panel sitting at an east point pushed the
     document 32px wider than the window at 1280. */
  display: none;
  position: absolute;
  top: 50%;
  /* --panel-shift is set by sphere.js when the panel would otherwise
     hang off the top or bottom of the fold */
  translate: 0 calc(-50% + var(--panel-shift, 0px));
  z-index: 3;
  width: max-content;
  max-width: min(13rem, 36vw);
  margin: 0;
  padding: 0.72rem 0.9rem;
  list-style: none;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 6px;
  box-shadow: var(--shadow-card);
  text-align: left;
  text-transform: none;
  letter-spacing: 0;
  font-size: 0.68rem;
  line-height: 1.5;
  color: var(--text-2);
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transition: opacity 200ms var(--ease-sand), visibility 0s linear 200ms;
}
/* the panel is inside the point, so moving the pointer onto it does
   not count as leaving — the reader can go and read it */
.spt.is-focused .spt__p {
  opacity: 1;
  visibility: visible;
  pointer-events: auto;
  transition-delay: 0s;
}

/* Which side it opens on is measured at hover time, not fixed to the
   compass point: at 1100px the east points had no room on the right and
   the panel ran off the page. sphere.js picks the side with space and
   falls back to below when neither has any. */
.spt.is-panel-right .spt__p,
.spt.is-panel-left  .spt__p,
.spt.is-panel-under-right .spt__p,
.spt.is-panel-under-left  .spt__p { display: block; }
.spt.is-panel-right .spt__p { left: 100%; margin-left: 0.75rem; }
.spt.is-panel-left  .spt__p { right: 100%; margin-right: 0.75rem; }

/* Under the label, hanging off its outer edge: it clears the ball the
   same way opening sideways does, and gets the label's own width back
   for a node that is a few pixels short of fitting beside itself. */
.spt.is-panel-under-right .spt__p,
.spt.is-panel-under-left  .spt__p {
  top: 100%;
  margin-top: 0.65rem;
  translate: 0 var(--panel-shift, 0px);
}
.spt.is-panel-under-right .spt__p { left: 0; }
.spt.is-panel-under-left  .spt__p { right: 0; }
.spt__p li {
  position: relative;
  padding-left: 0.85rem;
  opacity: 0;
  translate: 0 4px;
  transition: opacity 240ms var(--ease-sand), translate 240ms var(--ease-sand);
}
.spt__p li + li { margin-top: 0.4rem; }
/* the same hairline marker the card lists use */
.spt__p li::before {
  content: "";
  position: absolute;
  left: 0; top: 0.62em;
  width: 0.45rem; height: 1px;
  background: var(--text-3);
}
.spt.is-focused .spt__p li { opacity: 1; translate: 0 0; }
.spt.is-focused .spt__p li:nth-child(1) { transition-delay: 40ms; }
.spt.is-focused .spt__p li:nth-child(2) { transition-delay: 115ms; }
.spt.is-focused .spt__p li:nth-child(3) { transition-delay: 190ms; }
.spt.is-focused .spt__p li:nth-child(4) { transition-delay: 265ms; }

html.no-motion .spt__p,
html.no-motion .spt__p li { transition: none; }

/* the sentence: always in the accessibility tree, shown visually
   in the caption instead of at the point, where it would not fit */
.spt__d {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0; border: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* compass placement — mirrors the spoke endpoints in the SVG */
.spt[data-at="n"]  { left: 50%;    top: 16%; transform: translate(-50%, -100%); text-align: center; }
.spt[data-at="s"]  { left: 50%;    top: 84%; transform: translate(-50%, 0);     text-align: center; }
.spt[data-at="ne"] { left: 71%;    top: 26%; transform: translate(0, -50%);     text-align: left; }
.spt[data-at="e"]  { left: 80.5%;  top: 50%; transform: translate(0, -50%);     text-align: left; }
.spt[data-at="se"] { left: 71%;    top: 74%; transform: translate(0, -50%);     text-align: left; }
.spt[data-at="sw"] { right: 71%;   top: 74%; transform: translate(0, -50%);     text-align: right; }
.spt[data-at="w"]  { right: 80.5%; top: 50%; transform: translate(0, -50%);     text-align: right; }
.spt[data-at="nw"] { right: 71%;   top: 26%; transform: translate(0, -50%);     text-align: right; }

/* ---- the caption ------------------------------------------- */
.hero[data-hero-visual="sphere"] .hero__caption {
  display: block;
  max-width: 30ch;
  margin: var(--space-3) auto 0;
  text-align: center;
  font-size: var(--text-small);
  line-height: var(--leading-body);
  color: var(--text-3);
  /* Three lines are reserved, because the longest sentence needs three.
     Reserving only two let the box shrink on hover; the hero centres its
     columns, so the whole ring rose about 11px, slid out from under the
     pointer, and the hover flickered on and off four times a second. */
  min-height: calc(3 * var(--text-small) * var(--leading-body));
  transition: opacity 220ms var(--ease-sand);
}
.hero__caption.is-swapping { opacity: 0; }

/* ============================================================
   ARRIVAL — a sequence, not a fade.

   The ball lands, the orbit draws, then each spoke extends and
   its label arrives just behind it, clockwise from the top. The
   order is the order of the points in content/sphere.json.

   One speed, and it is brisk. There is only ever one entrance —
   nothing takes the ring away any more, so nothing replays it —
   and it starts as soon as sphere.js runs rather than at load.
   The budget the sequence is written to: the ball substantially
   on the page by 500ms and all eight labels arrived by 900ms,
   both measured from the script running. The last label leaves
   160 + 7x32 + 100ms of delay and takes 340ms, so it lands at
   824ms; move any of those four numbers and that sum moves.
   The step was 42ms, which put the last label at 894ms — inside
   the budget on paper, but a real browser measured 901ms, because
   six milliseconds is not enough room for the scheduler to start
   an animation late. 32ms leaves the sum a margin it can lose.
   ============================================================ */
.hero__sphere { --reveal-base: 160ms; --reveal-step: 32ms; }

.motion .hero__sphere canvas,
.motion .hero__sphere .spt,
.motion .hero__sphere .hero__spokes circle,
.motion .hero__caption { opacity: 0; }
.motion .hero__orbit { opacity: 0; }
.motion .hero__spokes line { stroke-dashoffset: var(--len, 9); }

.motion .hero__sphere.is-revealed canvas {
  animation: sphere-in 560ms var(--ease-sand) both;
}
@keyframes sphere-in {
  from { opacity: 0; transform: translate(-50%, -50%) scale(0.88); }
  to   { opacity: 1; transform: translate(-50%, -50%) scale(1); }
}

.motion .hero__sphere.is-revealed .hero__orbit {
  animation: orbit-in 600ms var(--ease-sand) both;
  animation-delay: calc(var(--reveal-base) - 160ms);
}
@keyframes orbit-in {
  from { opacity: 0; transform: scale(0.92); }
  to   { opacity: 0.28; transform: scale(1); }
}
.hero__orbit { transform-origin: 50% 50%; }

/* each spoke draws outward from the ball; --len is its exact
   length in viewBox units, written by build time in index.html */
.motion .hero__spokes line { stroke-dasharray: var(--len, 9); }
.motion .hero__sphere.is-revealed .hero__spokes line {
  animation: spoke-draw 340ms var(--ease-sand) both;
}
.motion .hero__sphere.is-revealed .hero__spokes circle {
  animation: dot-in 220ms var(--ease-sand) both;
}
@keyframes spoke-draw { to { stroke-dashoffset: 0; } }
@keyframes dot-in     { from { opacity: 0; } to { opacity: 0.8; } }

/* the label arrives just behind its own spoke, drifting outward
   along it */
.motion .hero__sphere.is-revealed .spt {
  animation-duration: 340ms;
  animation-timing-function: var(--ease-sand);
  animation-fill-mode: both;
  animation-delay: calc(var(--reveal-base) + var(--i) * var(--reveal-step) + 100ms);
}
.motion .hero__sphere.is-revealed .spt[data-at="n"] { animation-name: spt-in-n; }
.motion .hero__sphere.is-revealed .spt[data-at="s"] { animation-name: spt-in-s; }
.motion .hero__sphere.is-revealed .spt[data-at="ne"],
.motion .hero__sphere.is-revealed .spt[data-at="e"],
.motion .hero__sphere.is-revealed .spt[data-at="se"] { animation-name: spt-in-e; }
.motion .hero__sphere.is-revealed .spt[data-at="nw"],
.motion .hero__sphere.is-revealed .spt[data-at="w"],
.motion .hero__sphere.is-revealed .spt[data-at="sw"] { animation-name: spt-in-w; }

@keyframes spt-in-n {
  from { opacity: 0; transform: translate(-50%, calc(-100% + 9px)); }
  to   { opacity: 1; transform: translate(-50%, -100%); }
}
@keyframes spt-in-s {
  from { opacity: 0; transform: translate(-50%, -9px); }
  to   { opacity: 1; transform: translate(-50%, 0); }
}
@keyframes spt-in-e {
  from { opacity: 0; transform: translate(-9px, -50%); }
  to   { opacity: 1; transform: translate(0, -50%); }
}
@keyframes spt-in-w {
  from { opacity: 0; transform: translate(9px, -50%); }
  to   { opacity: 1; transform: translate(0, -50%); }
}

/* the caption last, once the ring is complete */
.motion .hero__sphere.is-revealed ~ .hero__caption {
  animation: caption-in 520ms var(--ease-sand) both;
  animation-delay: calc(var(--reveal-base) + 8 * var(--reveal-step) + 120ms);
}
@keyframes caption-in { from { opacity: 0; } to { opacity: 1; } }

/* the delays that keep spoke and label locked together */
.hero__sphere.is-revealed .hero__spokes line:nth-of-type(1),
.hero__sphere.is-revealed .hero__spokes circle:nth-of-type(1) {
  animation-delay: calc(var(--reveal-base) + 0 * var(--reveal-step));
}
.hero__sphere.is-revealed .hero__spokes line:nth-of-type(2),
.hero__sphere.is-revealed .hero__spokes circle:nth-of-type(2) {
  animation-delay: calc(var(--reveal-base) + 1 * var(--reveal-step));
}
.hero__sphere.is-revealed .hero__spokes line:nth-of-type(3),
.hero__sphere.is-revealed .hero__spokes circle:nth-of-type(3) {
  animation-delay: calc(var(--reveal-base) + 2 * var(--reveal-step));
}
.hero__sphere.is-revealed .hero__spokes line:nth-of-type(4),
.hero__sphere.is-revealed .hero__spokes circle:nth-of-type(4) {
  animation-delay: calc(var(--reveal-base) + 3 * var(--reveal-step));
}
.hero__sphere.is-revealed .hero__spokes line:nth-of-type(5),
.hero__sphere.is-revealed .hero__spokes circle:nth-of-type(5) {
  animation-delay: calc(var(--reveal-base) + 4 * var(--reveal-step));
}
.hero__sphere.is-revealed .hero__spokes line:nth-of-type(6),
.hero__sphere.is-revealed .hero__spokes circle:nth-of-type(6) {
  animation-delay: calc(var(--reveal-base) + 5 * var(--reveal-step));
}
.hero__sphere.is-revealed .hero__spokes line:nth-of-type(7),
.hero__sphere.is-revealed .hero__spokes circle:nth-of-type(7) {
  animation-delay: calc(var(--reveal-base) + 6 * var(--reveal-step));
}
.hero__sphere.is-revealed .hero__spokes line:nth-of-type(8),
.hero__sphere.is-revealed .hero__spokes circle:nth-of-type(8) {
  animation-delay: calc(var(--reveal-base) + 7 * var(--reveal-step));
}

/* ---- narrower viewports ------------------------------------ */
@media (max-width: 1180px) {
  .spt { max-width: 4.6rem; font-size: 0.64rem; }
  .spt--lead { max-width: 10rem; }
}

/* One column, in both appearances, at the width main.css collapses
   the plain hero at. */
@media (max-width: 1000px) {
  .hero[data-hero-visual="sphere"] .hero__grid {
    grid-template-columns: minmax(0, 1fr);
  }
  .hero[data-hero-visual="sphere"] .hero__sphere {
    max-width: 28rem;
  }
  .spt { max-width: 5.5rem; font-size: 0.7rem; }
  .spt--lead { max-width: 13rem; }
}

/* ---- the fallback list ------------------------------------
   Off wherever the ring is on: the ring already says this, and
   saying it twice would put the same destinations in the tab order
   twice. */
.hero__map {
  display: none;
  gap: var(--space-3);
  margin: var(--space-4) 0 0;
  padding: 0;
  list-style: none;
}
.hero__map-item {
  display: grid;
  gap: 0.15rem;
  padding-top: var(--space-2);
  border-top: 1px solid var(--line-soft);
}
.hero__map-item a {
  /* A line of small caps is about 17px tall. The padding is what makes the
     row a target rather than a word: 44px of it, measured, not assumed. */
  display: block;
  min-height: 44px;
  padding-block: 0.85rem 0.55rem;
  margin-top: -0.55rem;
  font-family: var(--font-mono);
  font-size: 0.72rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--text);
  text-decoration: none;
}
.hero__map-item a:hover { text-decoration: underline; text-underline-offset: 4px; }
.hero__map-item span {
  font-size: var(--text-small);
  line-height: var(--leading-body);
  color: var(--text-3);
}

/* ---- the phone -------------------------------------------
   Below this the ring cannot clear the ball at a readable type
   size, and a 0.7rem label on an orbit is not a target anyone can
   hit. The ring stands down and the same terms become an ordinary
   list of links: same words, same destinations, at a size a thumb
   can use. The original put the old terminal readout here; that
   readout is not part of the page any more, and a list of the
   points themselves is the closer answer to what the ring says.
   ---------------------------------------------------------- */
@media (max-width: 700px) {
  .hero[data-hero-visual="sphere"] .hero__sphere,
  .hero[data-hero-visual="sphere"] .hero__caption { display: none; }
  .hero__map { display: grid; }
}

@media (prefers-reduced-motion: reduce) {
  .motion .hero__sphere canvas,
  .motion .hero__sphere .spt,
  .motion .hero__sphere .hero__spokes circle,
  .motion .hero__caption { opacity: 1; }
  .motion .hero__orbit { opacity: 0.28; }
  .motion .hero__spokes line { stroke-dashoffset: 0; }
  .motion .hero__sphere .spt,
  .motion .hero__sphere canvas,
  .motion .hero__orbit,
  .motion .hero__spokes line,
  .motion .hero__spokes circle,
  .motion .hero__caption { animation: none !important; }
}
