/* /work — the evidence page.

   It shares the tube with the hero on purpose: this is an internal page, so it
   belongs to the machine. /resume.html deliberately does NOT, because its job
   is to be parsed by ATS software and skimmed in ten seconds, and a CRT is the
   wrong costume for that.

   The tier rule still holds. Pixels are the machine (the back arrow, the HUD
   light). Mono is what it says (keys, eyebrows, tags). Type is what Juan says
   (the headings and every line of prose). No body copy is ever pixels. */

.doc { min-height: 100%; }

/* the hero scrolls nothing; this page is a document and scrolls normally */
.doc__head { padding-block: 12vh 6vh; }
.doc__main { padding-block: 0 4vh; }
.doc__foot { padding-block: 6vh 16vh; border-top: 1px solid rgba(220, 230, 221, .09); }

.back {
  display: inline-flex; align-items: center; gap: 10px;
  font-family: var(--mono); font-size: 10.5px; letter-spacing: .22em;
  text-transform: uppercase; color: var(--fg-dim); text-decoration: none;
  margin-bottom: 40px;
}
.back i {
  width: 7px; height: 7px; background: var(--amber); border-radius: 1px;
  box-shadow: 0 0 10px rgba(240, 168, 60, .8);
  transition: transform .25s cubic-bezier(.2, .8, .25, 1);
}
.back:hover { color: var(--amber); }
.back:hover i { transform: translateX(-4px); }

.doc__h1 {
  font-size: clamp(30px, 5.4vw, 58px);
  line-height: 1.02; letter-spacing: -.022em; font-weight: 800;
  margin: 6px 0 22px; max-width: 16ch;
  color: var(--fg);
}
.doc__lede {
  margin: 0; max-width: 56ch;
  font-size: clamp(15px, 1.5vw, 17.5px); line-height: 1.62; color: var(--fg-dim);
}

/* ------------------------------------------------------------------ items */

.item {
  padding-block: clamp(38px, 6vh, 62px);
  border-top: 1px solid rgba(220, 230, 221, .09);
  max-width: 62ch;
  /* anchored from the icon row, so leave room under a sticky-free header */
  scroll-margin-top: 8vh;
}
.item:first-child { border-top: none; }

.item__k {
  margin: 0 0 10px;
  font-family: var(--mono); font-size: 10.5px; letter-spacing: .22em;
  text-transform: uppercase; color: var(--amber);
}
.item__h {
  margin: 0 0 18px;
  font-size: clamp(21px, 2.6vw, 30px); line-height: 1.12;
  letter-spacing: -.014em; font-weight: 800; color: var(--fg);
}
.item p {
  margin: 0 0 14px;
  font-size: clamp(14.5px, 1.45vw, 16.5px); line-height: 1.68; color: var(--fg-dim);
}
.item em { font-style: italic; color: var(--fg); }

/* the line that makes this a portfolio rather than a list */
.item__cost {
  border-left: 2px solid rgba(240, 168, 60, .45);
  padding-left: 16px;
  margin-block: 20px 18px !important;
}
.item__cost b { color: var(--amber); font-weight: 600; }

.tags {
  font-family: var(--mono); font-size: 10.5px !important; letter-spacing: .18em;
  text-transform: uppercase; color: var(--fg-dim); opacity: .7;
  margin-bottom: 18px !important;
}
.item__go { margin: 0 !important; }

@media (max-width: 620px) {
  .doc__head { padding-block: 8vh 4vh; }
  .back { margin-bottom: 28px; }
  .item__cost { padding-left: 12px; }
}

/* the human block — the same page, a different job. It reads as an aside
   rather than a sixth project, so the "five things" count still means the
   work. The research is blunt that this is the part that decides whether
   someone wants to work with you, and equally blunt that it should be short. */
.item--human { border-top-width: 1px; }
.item--human .item__k { color: var(--cool, #2FD9C5); }
.item--human .item__h { font-size: clamp(19px, 2.2vw, 25px); }

/* ===================================================== LEAD MARK + STATS ===
   The page was a wall of prose. The research on portfolio pages is blunt about
   why that fails: recruiters scroll a case study in SECONDS looking for visual
   proof, and visuals "convey complex information far more efficiently than
   words." So every project now opens with a mark and carries its numbers where
   a scan will hit them, with the prose demoted to the layer that has to stand
   up in an interview rather than the layer that has to survive a skim.

   A CAROUSEL was considered and rejected — see the README. Short version: in
   the Notre Dame study only 1% of visitors clicked a carousel at all and 84% of
   those clicks were the FIRST slide, so four of these five projects would have
   been invisible. */

.item__lead {
  display: flex; align-items: center; gap: clamp(16px, 2.4vw, 28px);
  margin-bottom: 20px;
}
.item__lead .item__k { margin-bottom: 6px; }
.item__lead .item__h { margin-bottom: 0; }

/* the mark is the SAME pixel sprite the hero icons use, at hero size — which is
   what keeps this page reading as the same machine as the front door */
.plate {
  flex: none;
  display: grid;
  grid-template-columns: repeat(8, var(--pp));
  grid-auto-rows: var(--pp);
  gap: calc(var(--pp) * .18);
  --pp: clamp(5px, 0.85vw, 8px);
  padding: calc(var(--pp) * 1.5);
  border: 1px solid rgba(240, 168, 60, .16);
  border-radius: 4px;
  background: rgba(240, 168, 60, .028);
}
.plate i { background: transparent; border-radius: 1px; }
.plate i.on {
  background: var(--amber);
  box-shadow: 0 0 7px rgba(240, 168, 60, .55);
}

/* the numbers. Display type for the figure, mono for the label — the figure is
   Juan's fact, the label is the machine annotating it. */
.stats {
  list-style: none; margin: 0 0 20px; padding: 0;
  display: flex; flex-wrap: wrap; gap: clamp(20px, 3.4vw, 42px);
}
.stats li { display: flex; flex-direction: column; gap: 4px; }
.stats b {
  font-size: clamp(20px, 2.6vw, 30px); font-weight: 800; line-height: 1;
  letter-spacing: -.02em; color: var(--fg);
  text-shadow: 0 0 22px rgba(240, 168, 60, .30);
}
.stats span {
  font-family: var(--mono); font-size: 10px; letter-spacing: .18em;
  text-transform: uppercase; color: var(--fg-dim);
}

@media (max-width: 560px) {
  .item__lead { gap: 14px; }
  .stats { gap: 18px 26px; }
}

/* the one real photograph on the site, and the only place a face belongs: the
   hero is a machine whose face is the eyes, so a person appears here — beside
   the block that says who is behind the work. */
.me {
  display: block;
  width: clamp(150px, 19vw, 250px); height: auto;
  /* sit it in the tube's palette rather than letting a bright studio blue
     punch a hole in the screen */
  filter: saturate(.72) contrast(1.04) brightness(.9);

  /* FEATHERED, not framed. A hard-edged rectangle with a border reads as a
     photo pasted ONTO the screen; the whole conceit here is that the viewport
     IS the tube, so a photograph should look like it is being displayed BY the
     phosphor — emerging out of the black rather than sitting on top of it.

     A radial mask does that in one property and costs nothing: it is composited
     on the GPU, there is no second asset, no cut-out PNG with a baked edge that
     would break the moment the background colour changed, and the source JPEG
     stays a plain rectangle. Opaque through the middle 52% so the face is never
     touched, then a long falloff so there is no visible edge anywhere.

     The border and radius are gone deliberately — a frame around a feathered
     edge is the one combination that looks like a mistake. */
  -webkit-mask-image: radial-gradient(closest-side, #000 52%, rgba(0,0,0,.55) 76%, transparent 100%);
          mask-image: radial-gradient(closest-side, #000 52%, rgba(0,0,0,.55) 76%, transparent 100%);
}
.item__lead--me { align-items: center; }

/* ================================================== PANELS, VERTICAL AGAIN ==
   ⚠️ THE HORIZONTAL RAIL IS REVERTED, and the reason is a real conflict rather
   than a change of taste: on a trackpad a two-finger horizontal swipe IS the
   browser's back/forward gesture. A full-width horizontal scroll container sits
   directly on top of that, so moving between projects and moving through
   history become the same motion — you either navigate when you meant to browse
   or fight the page when you meant to go back. Juan hit it immediately.

   There is no way to have both. The gesture belongs to the browser, so the
   panels give it up and go back to scrolling down.

   Everything that made these not-a-carousel still holds: nothing auto-advances,
   no panel is hidden, every one stays in the DOM and the accessibility tree, the
   index names all six and shows where you are, and the browser owns the physics.

   PROXIMITY, not mandatory — a panel taller than the viewport must never trap
   anyone mid-scroll, and mandatory also breaks programmatic scrolling outright
   (measured: scrollTo with behavior:'smooth' moved this container zero pixels). */

html { scroll-snap-type: y proximity; }

#rail {
  display: block;
  padding-block: 0;
}

#rail > .item {
  min-height: 100svh;
  display: grid;
  align-content: center;
  scroll-snap-align: center;
  padding: 10vh 7vw;
  max-width: none;
  border-top: none;
}
/* ⚠️ .lead__grid is exempt on purpose: it is a TWO-COLUMN container, and a
   62ch cap collapses it into one measure. Stated in the exclusion list
   rather than fought with a specificity tie further down the file. */
#rail > .item > *:not(.item__lead):not(.rig):not(.lead__grid):not(.vid) { max-width: 62ch; }
#rail > .item .item__lead { max-width: 62ch; }

.doc__head, .doc__foot { padding-block: 0; border: none; }
.item--intro .back { margin-bottom: 32px; }
.item--end { border-top: 1px solid rgba(220, 230, 221, .09); }

/* the cue that there is more below — the affordance a full-screen panel needs,
   now pointing the way the page actually moves */
.item--intro { position: relative; }
.item--intro::after {
  content: "";
  position: absolute; left: 7vw; bottom: 12vh;
  width: 1px; height: 38px;
  background: linear-gradient(to bottom, var(--amber), transparent);
  animation: rail-cue 2.4s cubic-bezier(.55, 0, .45, 1) infinite;
}
@keyframes rail-cue {
  0%        { transform: translateY(0);    opacity: 0; }
  15%       { opacity: .9; }
  70%, 100% { transform: translateY(22px); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .item--intro::after { animation: none; opacity: .7; }
}

/* ---------------------------------------------------------------- index --- */
.index {
  position: fixed; z-index: 44; right: max(2vw, 14px); top: 50%;
  transform: translateY(-50%);
  display: flex; flex-direction: column; gap: 2px;
  font-family: var(--mono);
}
.index a {
  display: flex; align-items: center; gap: 10px;
  padding: 5px 8px; border-radius: 3px;
  text-decoration: none; color: var(--fg-dim);
  font-size: 9.5px; letter-spacing: .18em; text-transform: uppercase;
  opacity: .5; transition: opacity .3s ease, color .3s ease;
}
.index a b { font-weight: 400; color: var(--amber); opacity: .6; }
.index a span {
  max-width: 0; overflow: hidden; white-space: nowrap;
  transition: max-width .35s cubic-bezier(.2, .8, .25, 1);
}
.index:hover a, .index a:focus-visible { opacity: .9; }
.index:hover a span, .index a:focus-visible span { max-width: 130px; }
.index a.is-here { opacity: 1; }
.index a.is-here b { opacity: 1; }
.index a.is-here span { max-width: 130px; }

@media (max-width: 760px) {
  .index {
    right: auto; left: 50%; top: auto; bottom: 8px;
    transform: translateX(-50%);
    flex-direction: row; gap: 6px;
  }
  .index a span { display: none; }
  .index a { padding: 6px; }
  #rail > .item { padding: 12vh 7vw 14vh; }
}

/* ====================================================== THE TURNTABLE ======
   A 360° rotation of the portrait, baked from a 3D lift of the same headshot
   and played back as a sprite sheet. The reference was an idle game-character
   screen, where the whole turnaround is visible.

   NO WEBGL. A GLB on the page needs a viewer, and a viewer is a dependency this
   site does not have. The mesh was rendered to 24 frames ONCE, offline, and
   steps() walks them: zero JS per frame, zero runtime 3D, 121KB for the whole
   rotation at 2x — less than most hero images. It is also how a game shipped a
   character turnaround before real-time 3D was cheap, so the method matches the
   costume.

   The frame count lives in ONE place. --turn-n is used by the background-size,
   by the keyframe distance and by the step count, so changing the bake means
   changing one number rather than finding three that must agree. */
.me-wrap { position: relative; flex: none; line-height: 0; }

/* ⚠️ THE ONE PHOTOREAL THING ON THE SITE, so it gets the tube's treatment or it
   reads as an asset borrowed from somewhere else. Juan asked whether to give it
   a holographic look; the answer is YES but restrained — the conceit is that the
   viewport IS the tube, so a rotating head should look like the phosphor is
   DISPLAYING it, not like a render dropped on top.

   What that means concretely: scanlines, a slight chromatic fringe, a duotone
   pull toward the amber, and the feather the still already had. What it does NOT
   mean is the sci-fi version — blue wireframes, glitch bars, a floating grid.
   That is costume, it fights the palette, and it is the same instinct the README
   already rejected when it cut burn-in: a theatrical effect on a hiring page
   reads as a gimmick rather than as a machine. */
.turn {
  --turn-n: 24;
  position: absolute; inset: 0;
  background-image: url("/assets/img/juan-turn.webp");
  background-repeat: no-repeat;
  background-size: calc(100% * var(--turn-n)) 100%;
  background-position: 0 0;

  /* pulled toward the tube's amber and lifted in contrast, so it sits in the
     phosphor's colour rather than in a studio's */
  filter: saturate(.62) contrast(1.12) brightness(.92) sepia(.18) hue-rotate(-8deg);
  -webkit-mask-image: radial-gradient(closest-side, #000 52%, rgba(0,0,0,.55) 76%, transparent 100%);
          mask-image: radial-gradient(closest-side, #000 52%, rgba(0,0,0,.55) 76%, transparent 100%);

  /* hidden until the sheet has actually decoded — see contact/index.js. Until
     then the still underneath is what shows, which is why there is never a gap. */
  opacity: 0;
  transition: opacity .6s ease;
}
/* ⚠️ THE STILL GETS OUT OF THE WAY once the rotation is live. The turntable is
   layered OVER the photograph — the photo is the base, the fallback and the alt
   text — but leaving it lit underneath meant the render was sitting on top of a
   different image of the same face, which is what Juan was seeing. It stays in
   the DOM and in the accessibility tree; it simply stops being drawn. */
.me-wrap:has(.turn.is-on) .me { opacity: 0; }
.me-wrap .me { transition: opacity .5s ease; }

.turn.is-on {
  opacity: 1;
  animation: turn-spin 9s steps(var(--turn-n)) infinite;
}

/* the scanlines and the halation, drawn OVER the sprite. A pseudo-element rather
   than a second image: it costs nothing, scales with the element, and cannot
   fall out of sync with the frame underneath. */
.turn::after {
  content: ""; position: absolute; inset: 0; pointer-events: none;
  background:
    repeating-linear-gradient(to bottom,
      rgba(0, 0, 0, .22) 0 1px, transparent 1px 3px),
    radial-gradient(70% 70% at 50% 42%, rgba(240, 168, 60, .16), transparent 70%);
  mix-blend-mode: screen;
  -webkit-mask-image: radial-gradient(closest-side, #000 52%, rgba(0,0,0,.55) 76%, transparent 100%);
          mask-image: radial-gradient(closest-side, #000 52%, rgba(0,0,0,.55) 76%, transparent 100%);
}

/* the chromatic edge — the same red-right/blue-left fringing the tube's SVG
   filter does to text, at a fraction of the strength so a face stays a face */
.me-wrap::after {
  content: ""; position: absolute; inset: 0; pointer-events: none; z-index: 2;
  box-shadow: inset 1px 0 0 rgba(255, 60, 60, .12), inset -1px 0 0 rgba(60, 120, 255, .12);
  border-radius: 50%;
}

/* ⚠️ THIS WAS BROKEN AND THE BREAK WAS INVISIBLE TO EVERY DOM CHECK. It read
   `to { background-position-x: calc(-100% * var(--turn-n)) }` — i.e. -2400% —
   on the assumption that a percentage background-position is a linear pixel
   offset. It is not. A percentage positions the image PROPORTIONALLY:

       pos = p × (containerWidth − imageWidth)

   and with a 2400%-wide sheet that is p × (−23W). So -100% pushes the image
   TWENTY-THREE container widths to the right, off-screen, and with no-repeat you
   see nothing at all. The turntable showed frame 0 and then went BLANK for the
   other 23/24 of every loop.

   Nothing caught it: the class was on, the animation name was right, the sheet
   had fetched, computed values changed frame to frame. It only appeared when the
   frames themselves were rendered side by side and 7 of 8 came back empty.

   The standard formula for an n-frame sheet is `100% / (n − 1)` per frame, so
   the run must end at 100%. `steps(n)` divides the range into n jumps, which
   means the keyframe target has to be 100% × n/(n−1) for step i to land exactly
   on i/(n−1) × 100%. */
@keyframes turn-spin {
  to { background-position-x: calc(100% * var(--turn-n) / (var(--turn-n) - 1)); }
}

/* A head that spins forever is precisely what this setting exists to stop. The
   turntable still loads and still replaces the still — it simply holds on frame
   zero, which is the front-facing view. */
@media (prefers-reduced-motion: reduce) {
  .turn.is-on { animation: none; background-position: 0 0; }
}

/* ============================================================ THE RIG ======
   Four systems, one at a time, as centred tree diagrams.

   ⚠️ THIS SECTION WAS SILENTLY DELETED ONCE by a range replacement that ran
   between two other section headers while the rig sat between them — nothing
   errored, the file still parsed, the braces still balanced, and the diagram
   rendered as a default bulleted list. It is deliberately the LAST section for
   that reason. If a block here ever "stops applying", check for that before
   debugging the rules.

   NO BOXES. Juan's call and it is the right one: every node used to sit in its
   own bordered chip inside one big amber-bordered container, which is two
   layers of chrome around content that needed none. Stripping both makes the
   MARKS the objects and leaves the connector lines as the only structure —
   which is what a tree diagram actually is. It also means the amber container
   is gone: on a page whose entire job is this diagram, the page IS the
   container.

   The one thing the boxes were carrying is the "this part is Juan's own work"
   signal, which the gate node needs. That now rides on COLOUR alone — an amber
   mark and an amber name, against everything else in --fg. Same information,
   no chrome. */

.rig { margin: 0; }

/* ---------------------------------------------------------------- tabs --- */

/* the position indicator — four marks, no labels. It is the smallest thing that
   still answers "how many are there and which am I on", which is the one job a
   rotating set cannot do without. */
.rig__dots {
  display: flex; justify-content: center; gap: 9px;
  margin-bottom: clamp(24px, 3.6vh, 40px);
}
.rig__dots button {
  padding: 6px; border: none; background: none; cursor: pointer;
  line-height: 0;
}
.rig__dots i {
  display: block; width: 6px; height: 6px; border-radius: 1px;
  background: rgba(220, 230, 221, .26);
  transition: background .3s ease, box-shadow .3s ease, transform .3s ease;
}
.rig__dots button:hover i { background: rgba(220, 230, 221, .5); }
.rig__dots button.is-on i {
  background: var(--amber);
  box-shadow: 0 0 8px rgba(240, 168, 60, .8);
  transform: scale(1.18);
}

/* the lesson sits WITH its diagram, not below the fold. The research is blunt
   that this is the part hiring managers care about most — "the outcomes and
   lessons are arguably the most important part of your case study" — and one
   shared lesson for four different systems was not saying anything about three
   of them. */
.rig__lesson {
  margin: clamp(14px, 2vh, 20px) auto 0 !important;
  max-width: 62ch; text-align: left;
  border-left: 2px solid rgba(240, 168, 60, .45);
  padding-left: 15px;
  font-size: 13px !important; line-height: 1.6; color: var(--fg-dim);
}
.rig__lesson b { color: var(--amber); font-weight: 600; }


/* --------------------------------------------------------------- trees --- */

/* ⚠️ A HORIZONTAL SCROLLER, CONTAINED. Juan wanted to swipe between trees, and
   he was right about the condition: it must not leak into the browser's
   back/forward gesture, which is the same two-finger horizontal swipe and the
   reason /work gave its rail up.

   `overscroll-behavior-x: contain` is the property that makes that possible —
   it stops scroll CHAINING, so a swipe that starts in this box stays in this
   box and never becomes a history navigation, even when the track hits its end.
   Scoped to the trees exactly as he asked.

   PROXIMITY, not mandatory: mandatory breaks programmatic scrolling outright
   here — measured earlier on /work, where scrollTo with behavior:'smooth' moved
   a mandatory container zero pixels.

   Nothing is hidden any more, which is why the tab/tabpanel roles are gone: in
   a scroller every tree is present and reachable, so announcing them as tabs
   would have described a show/hide that no longer happens. The dots are plain
   buttons that scroll to a tree. */
.rig__track {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 100%;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scroll-snap-type: x proximity;
  scrollbar-width: none;
  -ms-overflow-style: none;
}
.rig__track::-webkit-scrollbar { display: none; }
.rig__panel {
  scroll-snap-align: center;
  min-width: 0;
}
.rig__panel:focus-visible { outline: 1px solid rgba(240, 168, 60, .5); outline-offset: 8px; }
.rig__tree { display: grid; justify-items: center; }
.rig__row { display: flex; justify-content: center; }

/* a node is now a mark with a name under it, and nothing else */
.rig__node {
  display: inline-flex; flex-direction: column; align-items: center; gap: 8px;
  text-align: center;
  padding: 0;
}
.rig__node b {
  font-size: 15px; font-weight: 700; letter-spacing: -.01em; color: var(--fg);
  display: block;
}
.rig__node > span:not(.rig__glyph) {
  display: block; margin-top: 3px;
  font-family: var(--mono); font-size: 9px; letter-spacing: .16em;
  text-transform: uppercase; color: var(--fg-dim);
}
/* Juan's own work, said in colour rather than in a box */
.rig__node--gate b { color: var(--amber); }
.rig__node--gate > span:not(.rig__glyph) { color: rgba(240, 168, 60, .75); }

.rig__mark { width: 24px; height: 24px; flex: none; fill: var(--fg); opacity: .9; }
.rig__glyph {
  --pp: 3.4px;
  flex: none; display: grid;
  grid-template-columns: repeat(8, var(--pp));
  grid-auto-rows: var(--pp);
  gap: calc(var(--pp) * .2);
}
.rig__glyph i { background: transparent; border-radius: .5px; }
.rig__glyph i.on { background: var(--amber); box-shadow: 0 0 6px rgba(240, 168, 60, .85); }

/* -------------------------------------------------------------- trunk --- */

.rig__link {
  position: relative;
  width: 1px; height: 30px;
  background: linear-gradient(to bottom, rgba(240, 168, 60, .45), rgba(240, 168, 60, .16));
}
.rig__link i {
  position: absolute; left: -1.5px; top: 0;
  width: 4px; height: 4px; border-radius: 50%;
  background: var(--amber);
  box-shadow: 0 0 8px rgba(240, 168, 60, .9);
  animation: rig-pulse 2.6s cubic-bezier(.55, 0, .45, 1) infinite;
}
.rig__link:nth-of-type(2) i { animation-delay: .5s; }
@keyframes rig-pulse {
  0%        { transform: translateY(0);    opacity: 0; }
  12%       { opacity: 1; }
  70%, 100% { transform: translateY(30px); opacity: 0; }
}

.rig__row--two { display: grid; gap: 14px; justify-items: center; }

.rig__guards {
  list-style: none; margin: 0; padding: 0;
  display: grid; gap: 5px; text-align: center;
}
.rig__guards li {
  font-family: var(--mono); font-size: 9.5px; letter-spacing: .1em;
  text-transform: uppercase; color: var(--fg-dim); opacity: .8;
}

/* ------------------------------------------------------------ the fan --- */

/* ⚠️ THE CROSSBAR HAS TO KNOW HOW MANY LEAVES THERE ARE. It used to be a fixed
   `left: 9%; right: 9%` while the leaves were flex-CENTRED, so it could never
   line up with the outer two: with six leaves it stopped 14px SHORT of them, so
   Shopify and Cloudflare hung off stubs that connected to nothing, and with two
   or three it ran up to 36px PAST them, leaving a line poking out into space on
   either side. Wrong in one direction when the set was full and the other when
   it was small.

   A GRID fixes it exactly. With `repeat(var(--n), 1fr)` every leaf sits in an
   equal column, so the first column's centre is `50%/n` from the left edge and
   the last is the same from the right — which is precisely where the bar has to
   start and stop. Each fan declares its own --n inline, because only the markup
   knows how many leaves it has. */
.rig__sys {
  list-style: none; margin: 0; padding: 22px 0 0;
  position: relative;
  display: grid;
  --gap: clamp(6px, 1vw, 16px);
  grid-template-columns: repeat(var(--n, 3), 1fr);
  gap: 22px var(--gap);
  width: 100%;
}
/* the inset has to account for the GAP, or it is off by (n-1)·gap/2n — which
   measured as a consistent 3px before this: small, but it is the difference
   between a stub that meets the bar and one that nearly does. A column is
   (100% - (n-1)·gap) / n wide, so its centre is half that from the edge. */
.rig__sys::before {
  content: ""; position: absolute; top: 0;
  left:  calc((100% - (var(--n, 3) - 1) * var(--gap)) / var(--n, 3) / 2);
  right: calc((100% - (var(--n, 3) - 1) * var(--gap)) / var(--n, 3) / 2);
  height: 1px; background: rgba(240, 168, 60, .26);
}
.rig__sys li {
  position: relative;
  display: flex; flex-direction: column; align-items: center; gap: 8px;
  text-align: center;
  padding: 0; border: none; background: none;
}
.rig__sys li::before {
  content: ""; position: absolute; top: -22px; left: 50%;
  width: 1px; height: 22px; background: rgba(240, 168, 60, .26);
}
.rig__sys b { font-size: 13.5px; font-weight: 700; color: var(--fg); letter-spacing: -.008em; }
.rig__sys > li > span:not(.rig__glyph) {
  display: block; margin-top: 2px;
  font-family: var(--mono); font-size: 8.5px; letter-spacing: .14em;
  text-transform: uppercase; color: var(--fg-dim); opacity: .75;
}

/* Narrow screens: six equal columns stop being readable long before they stop
   fitting, and any wrap breaks the crossbar geometry the grid just fixed. So
   below this the fan becomes a stacked list on a left rail — a shape that is
   correct for ANY number of leaves, because nothing has to line up with
   anything. */
@media (max-width: 720px) {
  .rig__sys {
    grid-template-columns: 1fr;
    gap: 12px;
    padding: 0 0 0 20px;
    width: auto;
    justify-items: start;
    border-left: 1px solid rgba(240, 168, 60, .26);
  }
  .rig__sys::before { display: none; }
  .rig__sys li { flex-direction: row; align-items: center; gap: 10px; text-align: left; }
  .rig__sys li::before {
    top: 50%; left: -20px; width: 14px; height: 1px;
  }
}

.rig__cap {
  margin: clamp(26px, 4vh, 40px) auto 0 !important;
  max-width: 64ch; text-align: center;
  font-size: 13px !important; line-height: 1.6; color: var(--fg-dim);
}
.rig__cap b { color: var(--amber); font-weight: 600; }

@media (prefers-reduced-motion: reduce) {
  .rig__link i { animation: none; opacity: .9; top: 13px; }
  .rig__dots i { transition: none; }
}





/* ⚠️ .page-mark carries `margin-inline: auto`, which is right on /code, /film,
   /music and /off — their headers are centred, so the mark centres with them. In
   a left-aligned copy column it centred against left-aligned type and read as a
   misplaced element. Scoped to the panel-style intros so the four centred pages
   are untouched. (The morph flies the eyes to this element, so it stays put
   rather than being removed.) */
.item--intro .page-mark { margin-inline: 0; }

/* ─── THE OBJECT PANEL ────────────────────────────────────────────────────────
   Juan's call: the product is centred and it is the ONLY thing on that screen.
   `#rail > .item` is already min-height:100svh with align-content:center, so this
   adds the horizontal half and the centring of the text under it. The back link
   is lifted out of the flow so it does not count as a second thing in the panel —
   it is the only exit from the page, since the HUD carries no navigation. */
#rail > .item--object { justify-items: center; text-align: center; position: relative; }
.back--float {
  position: absolute; top: 8vh; left: 7vw; margin: 0;
}
.prod-stage { display: grid; justify-items: center; gap: 18px; margin: 0; }

/* sized off viewport HEIGHT, not width: this is a tall object alone on a
   full-height panel, so height is the axis that runs out first. */
.prod-wrap {
  position: relative; margin-inline: auto;
  /* ⚠️ BOUNDED BY WIDTH AS WELL AS HEIGHT. This was `clamp(260px, 72vh, 860px)`,
     sized off viewport HEIGHT because on a desktop landscape panel height is what
     runs out first. On a PORTRAIT phone that inverts: 72vh of an 844px screen is
     608px on a 390px viewport, so the stage hung 198px off the side of the page.
     Measured, not guessed — _lab's mobile probe reported scrollWidth 588 vs
     clientWidth 390 with .prod-wrap as the widest offender.
     min() of all three bounds means whichever axis is scarcer wins. */
  /* ⚠️ THE WIDTH HAS TO PAY FOR THE ROTATION. `.prod-wrap--float` rests at about
     21deg, and a rotated square's bounding box is wider than the square by
     cos21 + sin21 = 1.29. So at 88vw the box measured 417px on a 390px viewport
     and hung off the page — the width was right and the TRANSFORM overflowed.
     66vw x 1.29 = 85vw, which fits with margin. Desktop is unaffected: 72vh is
     the smaller bound there, and it was already winning. */
  width: min(72vh, 66vw, 860px); aspect-ratio: 1;
}

/* ─── FLOAT ───────────────────────────────────────────────────────────────────
   The reference Juan shot this against is a tilted floating product shot, so the
   resting state is tilted and the drift oscillates around it. The transform is on
   the WRAPPER, never on .turn — .turn animates background-position and a transform
   there would be a second animation on the same element fighting the first.
   ⚠️ Reduced motion kills the drift but KEEPS the tilt: the tilt is composition,
   the movement is the thing the setting is asking us to stop. */
/* ⚠️ THE RESTING ANGLE IS COPIED FROM JUAN'S OWN PROTOTYPE RENDER: the cup leans
   top-RIGHT at roughly 21 degrees, not the small left lean this had before. CSS
   rotate() is clockwise-positive, so the sign flips as well as the magnitude. */
@keyframes prod-float {
  0%, 100% { transform: translateY(-11px) rotate(19deg); }
  50%      { transform: translateY(11px)  rotate(23deg); }
}
.prod-wrap--float { animation: prod-float 7.5s ease-in-out infinite; will-change: transform; }
@media (prefers-reduced-motion: reduce) {
  .prod-wrap--float { animation: none; transform: rotate(21deg); }
}

/* ⚠️ THE TIERS START HIDDEN, WHICH IS THE OPPOSITE OF HOW THIS BEGAN. Painting
   the still and then the sprite and then the model meant a visible flash of two
   stand-ins before the real thing — Juan's note, and he is right that it reads as
   a glitch rather than as progressive loading. So nothing paints until something
   has actually loaded: `.is-3d` reveals the model, and `.no-3d` (set by
   product3d.js only when 3D genuinely cannot load) brings the stand-ins back.
   ⚠️ WITH NO JS NEITHER CLASS IS EVER SET, so the <noscript> block in sipful.html
   reveals the still. Without that this panel would be empty for a reader with JS
   off — the same trap the boot gate hit on the home page. */
.prod-still { width: 100%; height: auto; display: block; transition: opacity .45s ease; }
.prod-wrap .prod-still { opacity: 0; }
.prod-wrap .turn--prod.is-on { opacity: 0; }
.prod-wrap.no-3d .prod-still { opacity: 1; }
.prod-wrap.no-3d .turn--prod.is-on { opacity: 1; }
.prod-wrap.no-3d.is-3d .prod-still,
.prod-wrap.no-3d.is-3d .turn--prod.is-on { opacity: 0; }

.turn--prod {
  background-image: url("/assets/img/sipful-turn.webp");
  -webkit-mask-image: none;
          mask-image: none;
  /* ⚠️ NO SEPIA AND NO HUE-ROTATE HERE, unlike the portrait. Those exist to pull a
     GREYSCALE face toward the tube's amber; run them over a royal blue and the
     brand colour turns muddy teal. The product keeps its own colour — a CRT
     displaying a blue bottle shows a blue bottle — and the scanlines plus the
     bloom below are what put it on the phosphor. */
  filter: saturate(1.02) contrast(1.06);
  cursor: grab; touch-action: pan-y;
}
.turn--prod.is-drag { cursor: grabbing; animation: none; }
/* the bloom is cooled and weakened against the blue; the portrait's amber haze
   over this reads as a yellow film */
.turn--prod::after {
  background:
    repeating-linear-gradient(to bottom, rgba(0, 0, 0, .20) 0 1px, transparent 1px 3px),
    radial-gradient(70% 70% at 50% 44%, rgba(200, 220, 255, .10), transparent 72%);
  -webkit-mask-image: radial-gradient(closest-side, #000 60%, transparent 100%);
          mask-image: radial-gradient(closest-side, #000 60%, transparent 100%);
}



/* ─── TIER 3 · the real model ──────────────────────────────────────────────────
   Absolutely positioned over the still and the sprite sheet, and transparent
   until `.is-3d` says its GLB actually loaded. An un-upgraded <model-viewer> is
   an unknown inline element, so pinning it to inset:0 also means a failed import
   cannot disturb the layout. */
.prod-3d {
  position: absolute; inset: 0; width: 100%; height: 100%;
  opacity: 0; pointer-events: none;
  transition: opacity .6s ease;
  background: transparent;
  --poster-color: transparent;
  cursor: grab;
}
.prod-wrap.is-3d .prod-3d { opacity: 1; pointer-events: auto; }
.prod-3d:active { cursor: grabbing; }
/* once the model is live the two tiers under it get out of the way, for the same
   reason the portrait's still does: two images of one object stacked up is what
   reads as a ghost */
/* tiers 1 and 2 are hidden by default now, so .is-3d has nothing left to hide */

/* the float is a transform on the wrapper, so it drags the canvas around under
   the pointer. Fine idle, disorienting mid-orbit. */
.prod-wrap.is-holding { animation-play-state: paused; }

/* ─── THE LEAD ROW ────────────────────────────────────────────────────────────
   Juan's spec, precisely: the model and the copy sit in ONE container, that
   container is centred in the panel, and the copy inside it stays LEFT aligned.
   The panel keeps `justify-items: center` so the container centres; `text-align`
   is reset to left here because the panel sets it centre for the object-only
   case. */
.lead__grid {
  display: grid;
  grid-template-columns: auto minmax(0, 40ch);
  align-items: center;
  justify-content: center;
  gap: clamp(24px, 4.5vw, 68px);
  text-align: left;
  margin-inline: auto;
}
.lead__copy { max-width: 40ch; }
/* the copy under the model rather than beside it, before the measure gets
   squeezed — and the object stops being the taller of two cramped columns */
@media (max-width: 1040px) {
  .lead__grid { grid-template-columns: minmax(0, 1fr); justify-items: center; }
  .lead__copy { text-align: left; }
}

/* ─── THE MODEL'S FRAME HAD TO STOP BEING VISIBLE ─────────────────────────────
   Juan could see the model-viewer's rectangle on the page. Two plausible causes,
   and both are fixed here because I could not reproduce the symptom locally — the
   model does not load in this machine's headless WebGL, so there was no box to
   photograph.

   1. `.phosphor > *:not(.world)` carries
      `drop-shadow(0 0 30px rgba(240,168,60,.12)) url(#crt-chroma)`. A canvas is a
      replaced element that contributes an OPAQUE RECTANGLE to its ancestor's alpha,
      so the drop-shadow traces that rectangle instead of the object inside it. This
      repo has already paid for this exact class of bug once: "the artifact was a
      CSS filter tracing every alpha silhouette."
   2. The page carries a dot-field texture and CRT overlays everywhere EXCEPT over
      a canvas, so the canvas reads as a smooth patch on a textured surface — a
      frame you can see even with no glow at all.

   Fix for (1): fade the element's own edges, so its alpha silhouette has no hard
   rectangle left for a filter to trace. An INSET fade on all four sides rather
   than a radial — the cup sits diagonally and nearly fills its box, and
   `closest-side` would clip its ends.
   Fix for (2): carry the scanlines and a little amber bloom OVER the model, masked
   the same way, so the texture is continuous across the boundary. That is also the
   treatment the portrait already gets, and the site's whole conceit is that the
   viewport IS the tube — a model displayed on it should look displayed. */
.prod-3d {
  --edge: 5%;
  -webkit-mask-image:
    linear-gradient(to right,  transparent 0, #000 var(--edge), #000 calc(100% - var(--edge)), transparent 100%),
    linear-gradient(to bottom, transparent 0, #000 var(--edge), #000 calc(100% - var(--edge)), transparent 100%);
  -webkit-mask-composite: source-in;
          mask-image:
    linear-gradient(to right,  transparent 0, #000 var(--edge), #000 calc(100% - var(--edge)), transparent 100%),
    linear-gradient(to bottom, transparent 0, #000 var(--edge), #000 calc(100% - var(--edge)), transparent 100%);
          mask-composite: intersect;
}

/* the phosphor texture, over the model only once the model is what is showing */
.prod-wrap.is-3d::after {
  content: ""; position: absolute; inset: 0; pointer-events: none; z-index: 3;
  background:
    repeating-linear-gradient(to bottom, rgba(0, 0, 0, .17) 0 1px, transparent 1px 3px),
    radial-gradient(66% 66% at 50% 46%, rgba(200, 220, 255, .07), transparent 72%);
  mix-blend-mode: screen;
  -webkit-mask-image: radial-gradient(closest-side, #000 62%, transparent 100%);
          mask-image: radial-gradient(closest-side, #000 62%, transparent 100%);
}

/* ─── THE VIDEO FACADE ────────────────────────────────────────────────────────
   A local still, a play triangle, a duration — and no YouTube until the click.
   See assets/js/ytfacade.js for why. Styled to sit on the phosphor like the rest
   of the page rather than like an embed dropped in: the still is graded toward
   the tube and carries scanlines, and it brightens on hover so it reads as
   something you can press. */
/* ⚠️ WIDER THAN THE MEASURE, AND CENTRED — Juan's call on the origin video. The
   prose beside it stays at 62ch because that is a reading measure, but a video is
   media and media is not prose: the same rule the reel already follows on /film.
   `.lead__grid` is exempt from the shared 62ch child cap for the same reason, so
   `.vid` joins it in that :not() list rather than fighting specificity. */
.vid { margin: 30px auto 24px; max-width: min(920px, 100%); }
.vid__play {
  display: block; position: relative; width: 100%; padding: 0;
  aspect-ratio: 16 / 9; overflow: hidden; cursor: pointer;
  background: rgba(220, 230, 221, .03);
  border: 1px solid rgba(220, 230, 221, .14); border-radius: 6px;
}
.vid__play:focus-visible { outline: 2px solid var(--amber); outline-offset: 2px; }
.vid__thumb {
  display: block; width: 100%; height: 100%; object-fit: cover;
  filter: saturate(.70) contrast(1.06) brightness(.82);
  transition: filter .4s ease, transform .6s cubic-bezier(.2, .8, .25, 1);
}
.vid__play:hover .vid__thumb,
.vid__play:focus-visible .vid__thumb { filter: saturate(.92) contrast(1.04) brightness(1); transform: scale(1.03); }
/* the scanlines, same trick as the turntable: screen-blended black does nothing
   to the dark surround and only bites on the picture */
.vid__play::after {
  content: ""; position: absolute; inset: 0; pointer-events: none;
  background: repeating-linear-gradient(to bottom, rgba(0,0,0,.20) 0 1px, transparent 1px 3px);
  mix-blend-mode: screen;
}
.vid__tri {
  position: absolute; left: 50%; top: 50%; z-index: 2;
  width: 54px; height: 54px; margin: -27px 0 0 -27px;
  border-radius: 50%; border: 1px solid rgba(240, 168, 60, .55);
  background: rgba(5, 7, 6, .55);
  box-shadow: 0 0 26px rgba(240, 168, 60, .28);
  transition: background .3s ease, box-shadow .3s ease, transform .3s ease;
}
.vid__tri::before {
  content: ""; position: absolute; left: 50%; top: 50%;
  margin: -9px 0 0 -5px;
  border-style: solid; border-width: 9px 0 9px 14px;
  border-color: transparent transparent transparent var(--amber);
}
.vid__play:hover .vid__tri { background: rgba(240, 168, 60, .16); transform: scale(1.06); }
.vid__len {
  position: absolute; right: 9px; bottom: 9px; z-index: 2;
  font-family: var(--mono); font-size: 10px; letter-spacing: .1em;
  padding: 3px 6px; border-radius: 3px;
  background: rgba(5, 7, 6, .74); color: var(--fg-dim);
}
.vid__frame {
  display: block; width: 100%; aspect-ratio: 16 / 9;
  border: 0; border-radius: 6px; background: #000;
}
.vid__cap {
  margin: 10px 0 0 !important;
  font-family: var(--mono); font-size: 10px !important; line-height: 1.7;
  letter-spacing: .04em; color: var(--fg-dim); opacity: .78;
}

/* ─── THE RIG EXPANDERS ───────────────────────────────────────────────────────
   Juan's note: "73 tests" says nothing on its own, and a diagram of four systems
   does not tell you what any of them DOES. So each panel gains a <details> — the
   summary stays the skimmable claim, and the open state is the receipts.

   <details> rather than a JS accordion: native, keyboard-operable, works with no
   JS at all, and survives the router without any wiring. On a site whose rule is
   no dependencies, the platform element is not a compromise, it is the answer.

   ⚠️ .rig__track is a grid with `grid-auto-columns: 100%` and no fixed height, so
   an open panel grows the shared row and the others gain whitespace. That is the
   grid working, not a bug — and the vertical snap is `proximity`, never
   `mandatory`, so a panel taller than the viewport cannot trap anyone mid-scroll. */
.rig__more { margin: 16px auto 0; max-width: 62ch; }
.rig__more summary {
  cursor: pointer; list-style: none;
  display: inline-flex; align-items: center; gap: 9px;
  font-family: var(--mono); font-size: 10.5px; letter-spacing: .16em;
  text-transform: uppercase; color: var(--amber);
  padding: 7px 0;
}
.rig__more summary::-webkit-details-marker { display: none; }
.rig__more summary::before {
  content: "+"; display: inline-grid; place-items: center;
  width: 16px; height: 16px; border-radius: 3px;
  border: 1px solid rgba(240, 168, 60, .45); color: var(--amber);
  font-size: 12px; line-height: 1;
}
.rig__more[open] summary::before { content: "\2212"; }   /* a real minus sign */
.rig__more summary:hover { color: var(--fg); }
.rig__more summary:focus-visible { outline: 1px solid var(--amber); outline-offset: 3px; }

.rig__body { padding: 4px 0 2px; }
.rig__note {
  margin: 0 0 12px !important;
  font-size: 13.5px !important; line-height: 1.66; color: var(--fg-dim);
}
.rig__grp { list-style: none; margin: 0; padding: 0; display: grid; gap: 9px; }
.rig__grp li {
  display: grid; grid-template-columns: 34px minmax(0, 1fr); gap: 12px;
  align-items: start;
  padding-top: 9px; border-top: 1px solid rgba(220, 230, 221, .08);
}
.rig__grp li > b {
  font-family: var(--mono); font-size: 12px; color: var(--amber);
  text-align: right; font-weight: 400; padding-top: 1px;
}
.rig__grp li > span { font-size: 13px; line-height: 1.62; color: var(--fg-dim); }
.rig__grp li > span b { color: var(--fg); font-weight: 600; }
.rig__grp code {
  font-family: var(--mono); font-size: 11.5px;
  padding: 1px 4px; border-radius: 3px;
  background: rgba(220, 230, 221, .07); color: var(--fg);
}

/* the lead row with no product to show: one column, and the copy keeps its
   left alignment rather than sliding into the empty half */
.lead__grid.is-noart { grid-template-columns: minmax(0, 46ch); }
.prod-stage[hidden] { display: none; }
