/* /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); }
/* 10.5px of uppercase mono is a 17px-tall target, and this is the only way back
   out of a case-study page — the one link on the page that must not be fiddly.
   Padded rather than enlarged, so the type keeps its size, and the padding is
   pulled back out of the left margin so the text stays flush with the column
   above it instead of stepping in by 10px. */
@media (pointer: coarse) {
  .back { padding: 12px 10px; margin-left: -10px; }
}

.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; }
}

/* ------------------------------------------------------------ the kova cups ---
   Two SKUs overlapped rather than sat side by side, because side by side reads
   as a comparison ("which one?") and overlapping reads as a line ("these are
   the product"). The back cup is smaller and dimmer, which is the same depth
   language the film memories use — nearer is brighter and larger.

   ⚠️ THE STILLS ARE SQUARE AND MOSTLY EMPTY. Both sources are 2048x2048 with the
   cup floating in a wide cream margin, so laying them out by their BOX leaves a
   large hole; the negative margin is pulling the boxes together, not the cups.
   Change the photography and this number has to be re-measured, not adjusted by
   feel. */
.kova-cups {
  display: flex; align-items: flex-end; justify-content: center;
  gap: 0;
}

/* ⚠️ NO MASK AND NO BRIGHTNESS CUT ANY MORE — THE FILES CARRY REAL ALPHA.
   These were feathered and dimmed because the studio sweep was baked into the
   photographs and they landed on a near-black page as two glaring rectangles.
   The background is genuinely gone now (HiggsField's matting model, not a
   colour key — a key was rejected outright because the vanilla scoops are the
   same cream as the sweep behind them and keying would have punched holes
   straight through the product), so there is no rectangle left to hide and
   nothing to compensate for. Masking a cutout only eats the subject.

   ⇒ If these are ever replaced with images that have a background again, the
   feather has to come back with them. The treatment belongs to the ASSET, not
   to the layout. */
.kova-cups__cup {
  display: block;
  width: min(250px, 40vw); height: auto;
  /* ⚠️ A BOTTOM FADE, AND ONLY THE BOTTOM. The mound runs off the bottom of the
     original frame, so the cutout's alpha ends in a dead-straight horizontal
     line where the photograph was cropped rather than where the ice cream
     actually stops — visible on device as a hard shelf under both cups. Fading
     the last 12% turns that crop into the pile continuing out of view.
     The other three sides are left alone on purpose: the matte follows the real
     silhouette there, and feathering a good edge is what made the pre-cutout
     version look like a soft rectangle. */
  -webkit-mask-image: linear-gradient(to bottom, #000 78%, rgba(0,0,0,.55) 92%, transparent 100%);
          mask-image: linear-gradient(to bottom, #000 78%, rgba(0,0,0,.55) 92%, transparent 100%);
}
/* the second SKU sits behind: smaller, dimmer, lower — the same depth language
   the film memories use, so the pair reads as one line rather than as two
   things being compared. The dimming stays because it is DEPTH, not background
   compensation. */
.kova-cups__cup--back {
  width: min(210px, 34vw);
  margin-left: -13%;
  filter: brightness(.72) saturate(.9);
  transform: translateY(7%);
}

@media (max-width: 760px) {
  .kova-cups { margin-bottom: 6px; }
}

/* ------------------------------------------------------------------ lockup ---
   The brand holding the top-left after the first section, on Juan's ask. It is
   the SAME pixel sprite the page already uses at .plate and .page-mark, at a
   smaller pitch — a new mark drawn specially for this would be a second brand.

   ⚠️ Fixed, and therefore a sibling of .hud rather than a child of .phosphor —
   see the note in sipful.html. A filter ancestor captures fixed children.

   It starts hidden and is revealed by a class, so with no JS nothing appears
   over the copy and nothing is lost: the page's own <h1> is the real identity
   and this only ever restates it. */
.lockup {
  position: fixed; z-index: 45;
  top: calc(18px + env(safe-area-inset-top, 0px)); left: 4vw;
  display: flex; align-items: center; gap: 9px;
  pointer-events: none;
  opacity: 0; transform: translateY(-6px);
  transition: opacity .34s ease, transform .34s cubic-bezier(.2, .8, .25, 1);
}
.lockup.is-on { opacity: 1; transform: none; }
.lockup__mark {
  display: grid;
  grid-template-columns: repeat(8, var(--lp));
  grid-auto-rows: var(--lp);
  gap: calc(var(--lp) * .2);
  --lp: 2.5px;
}
.lockup__mark i { background: transparent; border-radius: 1px; }
.lockup__mark i.on {
  background: var(--amber);
  box-shadow: 0 0 6px rgba(240, 168, 60, .6);
}
.lockup__word {
  font-family: var(--mono); font-size: 10.5px; letter-spacing: .18em;
  text-transform: uppercase; color: var(--fg-dim);
  /* the same dark halo the HUD carries, for the same reason: this sits over
     whatever the page happens to be showing behind it */
  text-shadow: 0 0 2px rgba(0, 0, 0, .95), 0 0 6px rgba(0, 0, 0, .8);
}

@media (max-width: 760px) {
  .lockup { top: calc(14px + env(safe-area-inset-top, 0px)); left: 7vw; }
  .lockup__mark { --lp: 2px; }
}

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

/* ---------------------------------------------------------------- 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) {
  /* `bottom: 8px` put this row exactly on top of .hud, which is a fixed 50px
     band along the bottom edge. The collision was invisible for as long as the
     nav was mis-parented into .phosphor and rendering thousands of px down the
     document — fixing the containing block is what surfaced it. It clears the
     HUD now instead of sharing its band. */
  .index {
    right: auto; left: 50%; top: auto;
    /* clears .hud's band, and .hud itself now grows by the safe-area inset
       because the page runs under the home indicator (viewport-fit=cover) */
    bottom: calc(62px + env(safe-area-inset-bottom, 0px));
    transform: translateX(-50%);
    flex-direction: row; gap: 6px;
  }
  /* ⚠️ NOT display:none. Seven items cannot show their words at 390px, so the
     rail collapses to numbers — but display:none also removes the word from
     the accessibility tree, and the link's whole accessible name becomes "01".
     A screen reader then reads this nav as "01 02 03 04 05 06 07". The .vh
     technique (os.css) hides it from the eye and keeps it for the ear, which
     is the same trade every other hidden label on this site makes. */
  .index a span {
    position: absolute; width: 1px; height: 1px;
    margin: -1px; padding: 0; border: 0; overflow: hidden;
    clip-path: inset(50%); white-space: nowrap;
  }
  /* 6px of padding made a 27x27 target. WCAG 2.2 asks 24x24 as the floor and a
     thumb wants more, so the padding grows and the gap grows with it — a bigger
     target that still touches its neighbour is a mis-tap, not a fix. */
  .index { gap: 10px; }
  .index a { padding: 12px 11px; }
  /* ⚠️ DO NOT GROW THIS TO "STOP TEXT RUNNING UNDER THE HUD". That was tried on
     Aug 31 and reverted the same hour: the overlap is not a padding shortfall,
     it is a `position: fixed` HUD painting over a panel that is simply taller
     than the phone viewport. Padding cannot move content out from under a fixed
     bar — it only makes the panel taller and pushes the copy further up. The
     overlap is site-wide and by design; the HUD carries a text-shadow (os.css)
     so it stays legible over whatever is behind it. */
  #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); }
/* 6px dot + 6px padding = an 18x18 target, which a mouse hits and a thumb does
   not — and this is the ONLY way to reach systems 2-4, so missing it means the
   other three panels are unreachable on a phone. Grown on coarse pointers only,
   so the desktop rail keeps the small precise mark it was drawn as. The dot
   itself does not change size; only the invisible box around it does. */
@media (pointer: coarse) {
  .rig__dots { gap: 4px; }
  .rig__dots button { padding: 15px 13px; }
}
.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; }

/* ==================================================================== TIMELINE ==
   `#rail.tl` — /sipful only, Aug 31 2026. Juan asked for a timeline so the story
   is easier to follow, and for media to sit BESIDE the copy rather than under it.

   WHAT THIS IS NOT: a replacement for the panel deck. `#rail > .item` is already
   `min-height: 100svh` + `scroll-snap-align: center`, so the page advances one
   chapter per screen — which is the right structure for a case study and is
   already mobile-verified. The deck was never the problem. What it could not do
   is show the THREAD: which of six steps you are on, and that there is a
   sequence at all. So the spine is added to the deck rather than instead of it.

   ⚠️ VERTICAL, NOT HORIZONTAL, and this was researched rather than assumed.
   Horizontal scroll has a weak "information scent" — people cannot tell there is
   more, or how much — it needs custom arrow-key handling and skip links to stay
   keyboard-accessible, and it fights the one input every phone has. The usable
   version of horizontal is a short gallery INSIDE a vertical page, which is not
   what a six-chapter narrative is. Vertical is also the only one that survived
   the mobile pass this site just went through.

   ⚠️ AND THE SPINE CARRIES NO DATES, DELIBERATELY. The copy contains real ones,
   and they do not run in the order the sections do: step 03 reviews the second
   prototype in April 2025, step 04 has the Kickstarter budget built by June 2024.
   The page is a NARRATIVE order — problem, design, prototypes, decision, what
   stopped it, lesson — which is the right way to tell it and is not a chronology.
   Putting dates on the spine would print 2025 above 2024 and make the page argue
   with itself. Numbers are honest about being chapters; dates would not be.
   If Juan wants a true date axis, the sections have to be re-ordered first. */

/* ⚠️ EVERY SELECTOR HERE IS `#rail.tl`, NOT `.tl`, AND THAT IS NOT STYLE. The
   deck is laid out by `#rail > .item { padding: 10vh 7vw }` — an ID, so it
   outranks any class selector, and a plain `.tl > .item { padding-left: ... }`
   loses silently. It looked half-applied rather than ignored: the node hung off
   .item__lead so it moved, while the spine and the indent did not, leaving the
   thread drawn 9px INSIDE the text instead of in the margin beside it. Measured
   — lead at x=101, spine at x=109.8 — not eyeballed. Third time this file family
   has been bitten by specificity (see .w-full .stage in world.css). */
#rail.tl { --tl-g: 30px; }
/* ⚠️ AND /kova DELIBERATELY DOES NOT GET THIS, though Juan asked whether it
   should. Its five sections are FACETS of a business that is still running —
   product, storefront, operation, numbers, lesson — not steps in a story. Sipful
   has a beginning and an end (a problem, four revisions, two prototypes, a
   refused Kickstarter, a 145% tariff, a lesson), so a spine tells the truth
   about it. Kova has no ending to draw a line toward, and a timeline would
   assert a chronology the page does not have. It also carries ZERO media —
   `grep -c figure|img|vid__play kova.html` returns 0 — so the half of this that
   Juan actually wanted, media beside the copy, has nothing to put in the second
   column. Revisit if Kova gains photography or a real founding chronology. */

#rail.tl > .item { position: relative; padding-left: calc(7vw + var(--tl-g)); }

/* the thread. It runs the full height of every panel so it is continuous across
   the snap points, and fades at both ends of the first and last so it does not
   look cut off at the top of the page or amputated at the bottom.

   ⚠️ THE COLOUR IS A CUSTOM PROPERTY ON .item, NOT A LITERAL, and that is what
   makes the progress fill possible without rewriting these three rules. The
   first and last panels draw the line as a GRADIENT, so a state rule that set
   `background` directly would have to restate both gradients or silently lose
   the end fades. Setting `--tl-line` instead means the fades keep working and
   inherit the state for free. */
#rail.tl > .item {
  --tl-line: rgba(240, 168, 60, .18);   /* upcoming — the resting colour */
  --tl-lit:  rgba(240, 168, 60, .55);   /* travelled */
}
#rail.tl > .item::before {
  content: ""; position: absolute; z-index: 0;
  left: calc(7vw + 9px); top: 0; bottom: 0; width: 1px;
  background: var(--tl-line);
}
#rail.tl > .item:first-of-type::before {
  background: linear-gradient(to bottom, transparent, var(--tl-line) 46%);
}
#rail.tl > .item:last-of-type::before {
  background: linear-gradient(to bottom, var(--tl-line) 54%, transparent);
}

/* ---- PROGRESS, and everything below is gated on .is-tracking -------------
   Only index.js adds that class, so with no JS the page keeps the solid amber
   dots it already shipped with. That direction matters: making the DEFAULT node
   hollow would leave a no-JS reader with six empty rings and no current
   position at all, so the enhancement switches states ON rather than switching
   the old look off.

   ⚠️ IT IS index.js AND NOT A FILE OF ITS OWN, deliberately. This shipped for
   about ten minutes as `timeline.js` with its own IntersectionObserver before
   that was thrown away: index.js ALREADY computes which panel you are on, to
   light the .index chapter nav, and its own header records that it was rewritten
   off an IntersectionObserver because the observer broke when the layout
   changed. Adding a second, independent answer to "which panel am I on" — by the
   exact mechanism this page had already rejected — is how the nav and the spine
   end up disagreeing. One computation, two things painted from it. */

/* the panels already read: bright above where you are, dim below */
#rail.tl.is-tracking > .item.is-past { --tl-line: var(--tl-lit); }

/* and the CURRENT panel is the one that sells it — the line changes colour
   exactly at the node (top: calc(50% - 4.5px), so the hard stop is 50%), which
   reads as the thread filling to your position rather than as six differently
   coloured segments. Written with the id so it outranks the :first-of-type and
   :last-of-type gradients above when the current panel is either of those. */
#rail.tl.is-tracking > .item.is-current::before {
  background: linear-gradient(to bottom,
                var(--tl-lit) 0, var(--tl-lit) 50%,
                var(--tl-line) 50%, var(--tl-line) 100%);
}

/* THE NODE HANGS OFF .item, NOT off .item__lead, and the difference is where it
   sits vertically. On .item__lead it centred on the HEADING BLOCK, so it landed
   wherever that block happened to fall — high on a short panel, lower on a tall
   one, and never at a predictable place on screen. On .item it centres on the
   PANEL, so it marks the chapter as a whole rather than tracking one block
   inside it. Juan's call.

   ⚠️ PANEL-CENTRE IS NOT SCREEN-CENTRE, measured rather than assumed. On an
   iPhone the panels run ~1200px against a ~740px visible viewport, and
   `html { scroll-snap-type: y proximity }` is proximity, NOT mandatory — so a
   panel taller than the screen is never obliged to centre in it. Photographed
   at chapter 02: the node landed at y=941 with the viewport centre at y=1230.
   If what is wanted is a marker pinned to the middle of the SCREEN, this is not
   it and cannot become it by moving percentages around — that needs a fixed
   element, which on this site must live OUTSIDE .phosphor, because .phosphor
   carries a filter and would capture it (see os.css).

   ⚠️ THE OLD HORIZONTAL REASON DOES NOT APPLY HERE and that is why this is now
   simpler, not riskier. The node used to be positioned from .item__lead because
   .item__k's left edge MOVES — step 01 carries a .plate sprite and the others do
   not, so a node hung off the kicker sat 60px out on exactly one panel. Measuring
   from .item sidesteps that entirely: the spine's own offset is used, so the node
   is centred ON the thread by construction rather than by a -25px correction that
   had to agree with it. */
#rail.tl > .item > .item__lead { position: relative; }
#rail.tl > .item::after {
  content: ""; position: absolute;
  /* the spine is at calc(7vw + 9px); half the node's width centres it there */
  left: calc(7vw + 9px - 4.5px); top: calc(50% - 4.5px);
  width: 9px; height: 9px; border-radius: 1px;
  background: var(--amber);
  box-shadow: 0 0 9px rgba(240, 168, 60, .75);
  z-index: 1;   /* over the thread it sits on */
}
/* ⚠️ THE LEAD PANEL KEEPS ITS NODE. It is not a numbered chapter, but it is
   where the story starts — the object itself — and Juan's call is that the
   thread should begin at the model and step down from there rather than
   appearing from nowhere at chapter 01. Only the closing footer is excluded:
   it is a footer, not a beat. */
#rail.tl > .item--end::after { content: none; }

/* THE THREE STATES. A progress tracker is upcoming / current / past, and until
   now all six nodes looked identical from the first screen to the last — the
   thread was decoration rather than an instrument.

   `inset` is what keeps the ring from changing the node's size: a spread
   box-shadow would draw OUTSIDE the 9px box and the hollow state would sit
   proud of the solid ones. The scale on the current node is transform, not
   width/height, so it animates on the compositor and cannot nudge the line. */
#rail.tl.is-tracking > .item::after {
  background: transparent;
  box-shadow: inset 0 0 0 1px rgba(240, 168, 60, .5);
  transition: background .3s ease, box-shadow .3s ease, transform .3s ease;
}
#rail.tl.is-tracking > .item.is-past::after {
  background: var(--tl-lit);
  box-shadow: none;
}
#rail.tl.is-tracking > .item.is-current::after {
  background: var(--amber);
  box-shadow: 0 0 10px rgba(240, 168, 60, .85);
  transform: scale(1.35);
}

/* ⚠️ THE STATES STAY, ONLY THE TWEENING GOES. Which chapter you are on is
   information, not motion — removing it under this setting would take the
   instrument away from the readers most likely to want it. */
@media (prefers-reduced-motion: reduce) {
  #rail.tl.is-tracking > .item::after { transition: none; }
}

/* MEDIA BESIDE COPY — the second half of what Juan asked for. Only the panels
   that actually HAVE a video become two columns; `:has()` is how the page asks,
   the same way world.css already scopes on `body:has(.world)`. The four panels
   without media stay a single column rather than reserving an empty gutter for
   something that is not there.

   Not sticky: a sticky figure earns its keep when many text steps scroll past one
   graphic, and here it is one panel, one video, both already on screen together.
   Sticky would pin an element that never leaves. */
@media (min-width: 900px) {
  /* ⚠️ THE MEDIA LEADS AND THE COPY SITS TO ITS RIGHT — Juan's call, and it is
     a FLIP of what this rule used to do. The video was in column 2 with the
     copy in column 1, which is the opposite arrangement to the lead panel at
     the top of this very page (product left, copy right). Two different
     orders for the same relationship inside one page is what made the sections
     read as unrelated blocks rather than as one story; matching the lead means
     the eye lands in the same place in every section.

     The media column is the wider one now, because it is the thing being shown.

     `align-items: center` is what vertically centres the pair against each
     other, and `grid-row: 1 / -1` is what lets the video span every copy row so
     there IS a full height to centre within — without it the video occupies one
     row and centres against that row alone, which looks correct on a short
     section and wrong on a tall one. */
  #rail.tl > .item:has(> .vid) {
    grid-template-columns: minmax(0, 1fr) minmax(0, .92fr);
    column-gap: clamp(30px, 4vw, 68px);
    align-items: center;
    /* ⚠️ min-content IS WHAT STOPS THE COPY DEVELOPING GAPS. The video spans
       `grid-row: 1 / -1`, so the grid must be at least as tall as the video —
       and with the default `auto` rows that surplus height is handed to the
       ROWS, which means it lands as blank space BETWEEN the heading and each
       paragraph. That is the "weird gaps" in Juan's screenshot, and it is also
       why the copy did not look vertically centred: it was not a block that
       could be centred, it was several rows stretched apart to fill the height.
       Sizing the rows to their content packs the copy back into one block, and
       `align-content: center` (inherited from #rail > .item) then centres that
       block against the video. */
    grid-auto-rows: min-content;
    align-content: center;
    /* ⚠️ EXPLICIT ROWS, BECAUSE `grid-row: 1 / -1` IS A LIE IN AN IMPLICIT GRID.
       `-1` names the last line of the EXPLICIT grid, and with no
       grid-template-rows there is no explicit grid — so the video's `1 / -1`
       silently resolved to `1 / 2` and it occupied ROW ONE ALONE. Row one then
       had to be as tall as the video (measured: 351.6px against a 135px heading
       block), and `align-items: center` centred the heading inside that row,
       which is precisely the ~119px gap under the heading in Juan's screenshot
       and precisely why nothing looked vertically centred.

       Declaring rows makes `-1` mean what it reads as. Twenty is simply more
       rows than any section here has children; the unused ones are min-content
       on nothing and collapse to zero, so this costs nothing and cannot be
       outgrown by adding a paragraph. */
    grid-template-rows: repeat(20, min-content);
    /* ⚠️ THE LAST CHILD'S BOTTOM MARGIN WAS DECENTRING THE COPY BY EXACTLY HALF
       OF ITSELF. `.tags` carries margin-bottom: 18px, and align-content centres
       the ROW STACK — which includes that trailing margin — so the visible copy
       sat 9px above the true centre while the maths was perfectly correct.
       Measured, not eyeballed: copy at -9px against a video at 0. Dropping the
       trailing margin is the whole fix; it has nothing below it to separate
       from. */
  }
  /* ⚠️ NOT :last-child — .vid IS THE LAST DOM CHILD. It is placed into column 1
     by grid, so it reads as the left-hand media while still sitting last in the
     markup, and `*:last-child` therefore zeroed the VIDEO's margin and left the
     copy's trailing 18px exactly where it was. The copy stayed 9px high and the
     rule looked applied. The last copy element is the tags line. */
  /* ⚠️ !important IS REQUIRED HERE, and only because .tags already uses it:
     `.tags { margin-bottom: 18px !important }` (line ~90). Without matching it
     this rule loses SILENTLY — the declaration is present, the selector matches,
     and the computed value stays 18px. That is the third time this file family
     has been bitten by a losing declaration that looks applied. */
  #rail.tl > .item:has(> .vid) > p:last-of-type { margin-bottom: 0 !important; }
  #rail.tl > .item:has(> .vid) {
    /* ⚠️ CLEARANCE FOR THE .index RAIL, and it is a DIRECT COST OF THE FLIP.
       At this width .index is a fixed vertical rail on the right edge that
       expands to show the current section's words — so the moment the copy
       column moved from column 1 to column 2, it ran underneath it and the
       heading was overprinted by "THE PROBLEM". Invisible before the flip
       because the copy sat on the far side of the panel. Photographed on an
       iPad; the rects would not have shown it, since a fixed overlay does not
       collide in layout, it collides in paint. */
    padding-right: calc(7vw + clamp(76px, 7vw, 132px));
  }
  #rail.tl > .item:has(> .vid) > *:not(.vid) { grid-column: 2; }
  #rail.tl > .item:has(> .vid) > .vid {
    grid-column: 1; grid-row: 1 / -1;
    align-self: center; max-width: none;
    /* ⚠️ THE MEDIA CLEARS THE SPINE DELIBERATELY, and the number is matched to
       the lead panel by measurement rather than inherited from it. Measured at
       1440: the spine sits at x=110, the 3D model's left edge at 164 (a 54px
       gap) and the video's at 131 (21px) — the video was a third of the
       distance from the thread and read as crowding it.

       ⚠️ AND THE MODEL'S 54px IS AN ACCIDENT, WHICH IS WHY THIS IS NOT SHARED
       AS ONE TOKEN. `.item--lead` carries `justify-items: center`, so its
       fit-content .lead__grid (1141px) is centred inside a 1208px column and
       picks up 33.6px of inset for free. Nothing chose that; it falls out of
       the centring. Copying the VALUE and stating where it came from is honest;
       pretending the two are one system would be a lie that breaks the first
       time the lead's columns change. */
    margin: 0 0 0 clamp(18px, 2.4vw, 36px);
  }
}

/* narrower gutter on a phone — 30px of indent out of 320 is a lot to spend on a
   line, and the node has to stay clear of the text either way */
@media (max-width: 760px) {
  /* ⚠️ THE PLATE MUST NOT PUSH THE HEADING OFF THE COPY'S LEFT EDGE.
     .item__lead is a flex ROW, so on #problem — the only section carrying a
     .plate sprite — the sprite sat beside the heading and shunted it inward
     while the paragraphs below stayed at the panel's padding edge. Measured on
     an iPhone: heading at x=133, body copy at x=52. Every other section looked
     fine because no other section has a plate, which is exactly why it read as
     "weird spacing" rather than as a misalignment.

     Stacking the row on a phone puts the plate ABOVE the kicker and returns the
     heading to the same left edge as everything under it. Desktop keeps the row,
     where there is width for it and the sprite reads as a mark beside a title. */
  .item__lead { flex-direction: column; align-items: flex-start; gap: 12px; }

  #rail.tl { --tl-g: 20px; }
  #rail.tl > .item::before { left: calc(7vw + 6px); }
  #rail.tl > .item::after {
    left: calc(7vw + 6px - 3.5px);   /* the phone spine sits at 7vw + 6px */
    width: 7px; height: 7px; top: calc(50% - 3.5px);
  }
}
