  .sr-only{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0;}
  /* The document must never scroll - .page is the scroller (height:100svh, overflow-y:auto).
     If the window itself picks up a scroll offset (focusing the fixed rail, or a layout that
     shrank after scrolling), every rect shifts by that offset and the page appears to scroll
     past the footer into empty space, because .page's scroll range is then added on top of a
     window offset that has nowhere to go. The scroller is also position:fixed, which takes
     it out of flow entirely so the document has nothing to scroll - overflow:hidden alone
     only stops user scrolling, not focus- or script-driven scrolling. */
  html,body{margin:0;padding:0;height:100%;overflow:hidden;background:#0a0a0a;-webkit-text-size-adjust:100%;text-size-adjust:100%;}
  .page[data-page="portrait"].on{position:fixed;top:0;left:0;width:100%;height:100svh;overflow-y:auto;overflow-x:hidden;scroll-snap-type:y mandatory;scroll-padding-top:var(--p-top-h,60px);}
  .v-port .snap{scroll-snap-align:start;}
  /* The footer is the LAST snap target, aligned to the end. Without one, mandatory snapping
     has nothing to rest on past the final .snap div and refuses the last stretch of scroll -
     with .p-outro removed the page snapped back 85px every time and the footer could never
     be reached. `end` rather than `start` because the footer is short: starting it would
     leave most of the viewport empty. */
  .v-port .p-foot{scroll-snap-align:end;}

  @font-face{font-family:"Distortion";src:url("data:font/woff2;base64,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") format("woff2");font-weight:400;font-style:normal;font-display:swap;}

  /* ============ v-portrait: editorial, phone-centric ============ */
  /* ===================== HERO COMPOSITION KEYFRAMES =====================
     Two window widths - 360px (smallest) and 1144px (largest, = --maxw + 64, the width
     at which the content band stops growing) - and EVERY size and position in the hero
     composition is stated at both and interpolated linearly between, clamped outside.
     One shared pair, no per-property breakpoints.

     The form is always:   clamp(lo, calc(v0 + var(--kf-x) * K), hi)
       --kf-x = how far past the small keyframe the window is (negative below it)
       K      = (v1 - v0) / 784            <- 784 is the span, 1144 - 360
       lo/hi  = min/max of v0 and v1       <- swapped when the value decreases

     To retune, change v0 and/or v1 in the declaration and recompute that one K.
     CSS cannot divide a length by a length, so K has to be a literal - it is the
     only derived number, and it is local to its own line.

         property            360px      1144px          K
         --hero-size            54    ->     85     0.03954081
         badge width            82    ->    136     0.06887755
         --kicker-fs             9    ->     10     0.00127551
         --kicker-mb            20         20     -            (flat: taken down to
                                                                its floor, so the lag
                                                                line sits the same
                                                                distance off the title
                                                                at every width)
         --hero-drop ceiling   490.84 -> 424     -0.0852551   (see below - BOTH of
         --hero-drop guard     458.16 -> 525      0.0852551    its limits are pairs)
         hero pad left          48    ->     65     0.02168367   (= the info bar rule's
                                                                 own x; mobile keeps 60)
         hero pad right         60    ->     32     -0.03571428
         --bar-tag-size         18    ->     19     0.00127551
         info bar pad top       28    ->     36     0.01020408
         info bar pad right     24    ->     32     0.01020408
         --bar-pad-b            40    ->     48     0.01020408
         info bar pad left      48    ->     40     -0.01020408  (--bar-pad-l; 566+
                                                                 overrides it 25 lower,
                                                                 23 -> 15, since the
                                                                 rail is hidden there)

     WIDENING THE BAND RESCALES EVERY K. The span is (--maxw + 64) - 360, so moving
     --maxw means multiplying every K in this file by oldSpan/newSpan - the endpoint
     values stay put and the ramp just stretches. The ?tune panel prints the exact
     factor. The one ramp deliberately left off that rescale is the feature-list
     font-size inside @media (max-width:599px): it saturates inside the mobile range
     by design, so stretching it to the band cap would flatten mobile type instead.

     The info bar's VERTICAL padding is on this list for a reason that is not obvious:
     .frame's height is the stage minus the bar, and the hero is centred in the frame, so
     the bar's ramp moves the wordmark. While the bar still floored at its own 500px
     anchor, the hero's travel had a kink there - and since the badge's `top` is a straight
     line, the two summed to a badge that drifted up to ~600px wide and then reversed and
     came back down. Anything that changes the frame's height belongs on these keyframes.

     Two invariants the keyframe values have to respect, both of which have bitten:
       - badge top and badge width interact: the badge is aspect-ratio 1, so its BOTTOM
         edge is top + width. At -137 against a 140px badge the bottom lands +3px past the
         wordmark's top edge - i.e. resting on it, the relationship the composition was
         drawn with. Widen the badge without moving top and it grows downward into the
         first line; make top more negative to compensate and the badge starts rising as
         the window widens, which is the drift that was just removed. Pick one.
       - the hero panel has NO vertical padding, and must not be given any. It is a
         centred grid item, so padding cannot clear anything - all it does is shrink the
         content box. Once the block's margin box outgrows that box, grid centring stops
         and the block pins to padding-top, at which point the wordmark starts TRACKING
         padding-top and falls as the window widens. That is exactly what a 40->80px
         padTop ramp did on a ~700px-tall window: it fit at 915 wide and overflowed at
         1000, so the hero reversed direction mid-resize. The vertical bias lives in
         --hero-drop (a margin, which participates in centring and never clips) - it
         shifts the block by HALF its value.
     ====================================================================== */
  .v-port{--kf-x:calc(100vw - 360px);--ring-step:40px;--bar-pad-b:clamp(40px,calc(40px + var(--kf-x) * 0.01020408),48px);--ink:#eae6dc;--dim:#9c9993;--muted:#777;--hairline:#181615;--mono:"JetBrains Mono",ui-monospace,Menlo,monospace;--panels:3;--hero-size:clamp(54px,calc(54px + var(--kf-x) * 0.03954081),85px);background:#000;color:var(--ink);font-family:"Inter Tight",ui-sans-serif,Helvetica,Arial,sans-serif;font-size:15px;line-height:1.55;--maxw:1080px;--gutter:max(32px,calc((100% - var(--maxw)) / 2));--cap:calc(var(--gutter) - 32px);}
  .v-port .p-top,.v-port .frame,.v-port .p-sub,.v-port .p-outro,.v-port .p-foot{background:linear-gradient(to right,var(--gutterC,#000) 0,var(--gutterC,#000) var(--cap),var(--section-bg,#0a0a0a) var(--cap),var(--section-bg,#0a0a0a) calc(100% - var(--cap)),var(--gutterC,#000) calc(100% - var(--cap)),var(--gutterC,#000) 100%);}
  .v-port *{box-sizing:border-box;}
  .v-port a{color:inherit;text-decoration:none;}
  .v-port .p-top{position:sticky;top:0;z-index:5;display:flex;justify-content:space-between;align-items:center;padding:18px calc(var(--cap) + clamp(24px, calc(24px + var(--kf-x) * 0.01020408), 32px));border-bottom:1px solid var(--hairline);}
  .v-port .p-top .mark{font-family:"Distortion",serif;font-size:22px;letter-spacing:.02em;color:var(--ink);}
  .v-port .p-top nav{display:flex;gap:20px;font-family:var(--mono);font-size:11px;letter-spacing:.2em;text-transform:uppercase;color:var(--muted);}
  .v-port .p-top nav a.on{color:var(--ink);}
  /* Contact fan-out. The two options are absolutely positioned off the LEFT of the nav, so
     opening them costs no layout and "Contact" never moves - the header is a
     space-between flex row, and an in-flow option would push the button toward the middle.
     visibility (not just opacity) is what keeps them out of the tab order while closed;
     it is a discrete property, so the transition holds it visible for the duration of the
     close rather than cutting focusability off mid-fade. */
  .v-port .p-top nav.p-contact{position:relative;align-items:center;gap:0;}
  .v-port .p-contact .contact-btn{font:inherit;color:inherit;letter-spacing:inherit;
    text-transform:inherit;background:none;border:0;padding:0;cursor:pointer;}
  .v-port .p-contact .contact-btn:hover,.v-port .p-contact[data-open="true"] .contact-btn{color:var(--ink);}
  .v-port .p-contact .fan{position:absolute;right:100%;top:50%;transform:translateY(-50%);
    display:flex;align-items:center;gap:20px;margin-right:20px;white-space:nowrap;}
  .v-port .p-contact .fan > *{color:var(--muted);text-decoration:none;
    opacity:0;visibility:hidden;transform:translateX(10px);
    transition:opacity .26s ease,transform .26s ease,visibility .26s;}
  .v-port .p-contact .fan > *:hover{color:var(--ink);}
  .v-port .p-contact[data-open="true"] .fan > *{opacity:1;visibility:visible;transform:none;}
  /* fan: the option nearest the button leads, the far one follows */
  .v-port .p-contact .fan > *:nth-last-child(1){transition-delay:0s;}
  .v-port .p-contact .fan > *:nth-last-child(2){transition-delay:.07s;}
  /* Instagram is the glyph only; the label lives in aria-label. */
  .v-port .p-contact .fan .ig{display:inline-flex;align-items:center;}
  .v-port .p-contact .fan .ig svg{display:block;width:15px;height:15px;}
  /* Click-to-copy, not mailto. The width is pinned to the address's own before the label
     swaps to "copied", so the right-anchored fan doesn't reflow for the 1.4s it shows -
     a button centres its text, so the feedback lands in the same footprint. */
  .v-port .p-contact .fan .email{font:inherit;color:inherit;letter-spacing:inherit;
    text-transform:none;background:none;border:0;padding:0;cursor:copy;}
  .v-port .p-contact .fan .email[data-copied="true"]{color:var(--ink);cursor:default;}
  @media (prefers-reduced-motion: reduce){
    .v-port .p-contact .fan > *{transition-duration:.01ms;transition-delay:0s;}
  }
  /* Below 566 there is no room to fan LEFT - the email string alone is ~210px, which runs
     under the wordmark at 430 and off the header entirely at 360. Drop it under the button
     instead, right-aligned. The header is sticky at z-index 5, so it sits over the stage. */
  @media (max-width:565px){
    .v-port .p-contact .fan{right:0;left:auto;top:100%;transform:none;margin:10px 0 0;
      flex-direction:column;align-items:flex-end;gap:10px;}
    .v-port .p-contact .fan > *{transform:translateY(-6px);}
    .v-port .p-contact[data-open="true"] .fan > *{transform:none;}
  }

  /* sticky side rail — project counter */
  /* left tracks --gutter so the rail holds a constant offset from the content column at
     every width. At the 32px gutter floor this resolves to the original left:14px.
     The rail runs vertically, so its LENGTH is bound by viewport height, not width: it is
     anchored 16px under the header and level with the info bar's divider, so its height
     follows the viewport while the type (--rail-fs/--rail-ls) scales with both axes.
     Whatever height the labels don't use shows up as gaps between them; padRail() in
     js/site.js absorbs that by growing every section number's leading zeros in step
     (00 -> 000 -> 0000...), down to a small residual gap. */
  .v-port .p-rail{position:fixed;left:calc(var(--gutter) - 18px);top:calc(var(--p-top-h,60px) + 16px);bottom:var(--bar-pad-b);font-family:var(--mono);--rail-fs:clamp(6px,min(calc(8.61px + var(--kf-x) * 0.00177298),calc(1.97svh - 2.56px)),10px);--rail-ls:clamp(1.3px,min(calc(1.958px + var(--kf-x) * 0.00132905),calc(0.55svh - 0.9px)),3px);font-size:var(--rail-fs);letter-spacing:var(--rail-ls);gap:0;text-transform:uppercase;color:#555;display:flex;flex-direction:row-reverse;justify-content:space-between;z-index:6;pointer-events:none;writing-mode:vertical-rl;transform:rotate(180deg);}
  /* TRIAL: rail hidden on desktop. Delete this block to bring it back - nothing else
     depends on it. padRail() no-ops on its own here (it bails when the probe measures no
     height, which a display:none rail gives it), and the rail is position:fixed, so
     nothing reflows. Mobile keeps it: this is a desktop-only look. */
  @media (min-width:566px){ .v-port .p-rail{display:none;} }
  /* buttons are content-sized and space-between spreads the remainder, so 00 lands hard on
     the top edge and Contact hard on the bottom whatever the leftover is. padRail() then
     eats most of that leftover by lengthening the numbers themselves. */
  .v-port .p-rail button{appearance:none;background:none;border:0;padding:0;margin:0;font:inherit;letter-spacing:inherit;text-transform:inherit;writing-mode:inherit;color:inherit;transition:color .3s;white-space:nowrap;pointer-events:auto;cursor:pointer;}
  /* the zeros padRail() prepends read as part of the number, so they take the button's own
     colour and light up with it - no opacity of their own. They are hidden from the
     accessible name (aria-label on the button) so nothing reads "zero zero zero title". */
  .v-port .p-rail button:focus-visible{outline:1px solid var(--ink);outline-offset:3px;color:var(--ink);}
  .v-port .p-rail button.on{color:var(--ink);}
  /* Same offset shadow as the Porcupine title, for the same reason: when this label is
     active the gutter behind it is carrying --ringOverlay's pale pink, and teal on that is
     1.67:1 - the glyphs (and their leading zeros, which take the button's colour) all but
     vanish. The shadow is a darkened teal rather than the title's plum so it reads as this
     label's own edge. */
  /* The offsets here are NOT screen-space. .p-rail composes writing-mode:vertical-rl with
     transform:rotate(180deg), and the two together rotate the shadow's coordinate system by
     90deg, not 180. Measured with a single-axis probe:
         local +x  ->  screen UP
         local +y  ->  screen RIGHT      i.e. screen = (localY, -localX)
     So down-and-right on screen is local (-a, +a). Guessing 180deg (-0.5,-0.5) put it down
     and LEFT. Probe before changing this rather than reasoning from the transform. */
  .v-port .p-rail button[data-s="0"].on{color:#1fcbc4;text-shadow:-0.5px 0.5px 0 rgba(14,91,88,.45);}

  /* ===== stage: pinned frame, panels slide through it =====
     .stage-track is N*100svh tall minus the header, so each panel step is exactly one
     viewport and lines up with the .snap targets. .stage sticks for the whole track, then
     releases and .p-outro / .p-foot scroll in normally. */
  .v-port .stage-track{position:relative;height:calc(var(--panels) * 100svh - var(--p-top-h,60px));}
  .v-port .stage{position:sticky;top:var(--p-top-h,60px);height:calc(100svh - var(--p-top-h,60px));display:flex;flex-direction:column;}
  .v-port .snap{position:absolute;left:0;width:1px;height:1px;pointer-events:none;top:calc(var(--n) * 100svh);}
  /* the frame is the query container — everything inside sizes to it, not the viewport,
     so nothing can keep growing once the layout caps */
  /* .frame is the size container: vertical sizing inside panels uses cqb (the frame's
     height is what actually constrains them, via the sticky stage), while horizontal
     layout stays on viewport @media because the content column is viewport-capped. */
  .v-port .frame{position:relative;flex:1;min-height:0;overflow:hidden;border-bottom:1px solid var(--hairline);container-type:size;container-name:frame;}
  /* transform creates a stacking context, so the panel itself must clear the ::after
     decoration (z-index 1) — a z-index on its children would be trapped inside. */
  /* BASELINE (mobile) - the whole panel slides through the frame, which is what this has
     always done. 566+ overrides it with the cross-fade further down; the two scroll
     regimes split at the same 565 the two layouts do. */
  .v-port .panel{position:absolute;inset:0;z-index:2;will-change:transform;
    transform:translateY(calc((var(--i) - var(--progress,0)) * 100%));}
  /* Horizontal padding is a keyframe pair; VERTICAL padding is zero and must stay zero.
     The block is a centred grid item, so vertical padding clears nothing - all it can do
     is shrink the content box. The moment the block's margin box outgrows that box, grid
     centring stops and the block pins to padding-top, and from there the wordmark TRACKS
     padding-top: it starts falling as the window widens. A 40->80px padTop ramp did
     exactly that on a ~700px-tall window - the block fit at 915px wide and overflowed at
     1000px, so the hero reversed direction mid-resize. It looked like a width bug and was
     really a height one.
     The vertical bias lives in --hero-drop instead: a margin, which participates in
     centring, never clips, and has no floor to hit. It shifts the block by HALF its value.
     --hero-drop is the ONE value whose keyframe pair is not on the value itself but on
     its two LIMITS, and that is deliberate. The drop is bounded by a ceiling and by an
     `svh` guard - the one place a height term is needed on the width keyframes - and which
     one binds depends on the window's height: the guard takes over below ~950px tall (any
     normal laptop) and the ceiling above it. Without the guard the block re-overflows its
     content box on short windows and the direction reversal described above comes back.

     Where the block ENDS UP is `(frameH - drop - blockH + marginBottom) / 2` above the
     frame's bottom. frameH, blockH and marginBottom all differ between the two keyframes -
     the frame is 16px taller at 360 (a shorter info bar), the block 64.4px shorter (54px
     type against 85px), its margin 13.6px smaller - so an IDENTICAL drop does not put the
     hero in the same place at both ends. Netting those three out, the drop at 360 has to
     run exactly 66.84px AHEAD of the drop at the cap. Hence one pair on each limit, equal
     and opposite: ceiling 490.84 -> 424, guard constant 458.16 -> 525. The svh term then
     cancels out of the expression above, so the block's bottom sits ~72px over the frame's
     at every width AND every height until the ceiling takes over.

     To move the hero vertically, shift BOTH limits by the same amount in opposite
     directions (ceiling up = guard constant down) and keep the 66.84 gap between the ends
     intact - the block moves half of whatever you shift them by. Touching only one moves
     the hero on tall windows or short ones but not both, which is how this drifted before.
     Left padding must stay non-decreasing - it also absorbs what used to be .hero-block's
     own padding-left, and when it and the right padding both dipped mid-range the hero
     text ended up 19px from the side rail. */
  /* Floating snake - ported from the rng1 app's FloatingSnakeView. Fills the space to the
     right of the wordmark, in the same region as the badge - the two now coexist at every
     width, with the badge painting over the snake.
     z-index 1 puts it BEHIND the hero text (panel children are z-index 2), so it can pass
     under the wordmark instead of colliding with it. Absolutely positioned, so it stays out
     of the panel's grid and cannot affect the centring - **the selector needs .panel--hero
     in it** to outweigh `.v-port .panel > *`, which sets position:relative further down and
     otherwise turns this into a second grid row that pushes the wordmark off centre. */
  /* height:100% rather than top:0;bottom:0 - <canvas> is a REPLACED element, so with
     height:auto it falls back to its intrinsic size and ignores the stretch. */
  /* right is inset by --cap, NOT 0. Absolute offsets resolve against the panel's padding
     box, which spans the full frame, so right:0 pinned the canvas to the SCREEN edge -
     it drifted into the right gutter as the window widened (32px past the band at 1000,
     252px at 1440, 492px at 1920), the same drift everything else on the page freezes
     out. --cap is the band's own inset, so this keeps the snake inside the lit band and
     holds it at a constant offset from the composition at every width; +24px brings it
     off the edge. */
  /* translateZ(0) is a Safari fix, not decoration. The canvas repaints every frame and
     its left edge falls right on the last glyph of the wordmark; sharing .panel's
     compositing layer, Safari re-rasterised that glyph on every repaint and the 'd'
     shimmered. Promoting the canvas to its own layer keeps its repaints off the text.
     backface-visibility pins the promotion on older WebKit. */
  .v-port .panel--hero .hero-snake{position:absolute;top:0;height:100%;right:calc(var(--cap) + 24px);z-index:1;pointer-events:none;
    transform:translateZ(0);backface-visibility:hidden;
    width:clamp(205px, calc(205px + var(--kf-x) * 0.44642857), 555px);}
  /* The canvas is right-anchored, so its WIDTH is what sets how far left the snake can
     swim. The path only uses the middle ~76% of it (radiusX is 0.38w about the centre),
     so the leftmost reach sits ~0.12w inside the canvas's left edge - widen it well past
     the wordmark to get the snake to actually cross the text. It passes BEHIND (z-index
     1 against the text's 2), so overlap is the intent, not a collision. */
  @media (max-width:565px){ .v-port .panel--hero .hero-snake{display:none;} }
  /* The drop and the kicker metrics live on .v-port, not on .panel--hero, because the
     PROJECT panels reuse them above 565 to put their title exactly where the wordmark
     sits. The mobile block further down still overrides --hero-drop on .panel--hero, so
     the hero keeps its own phone position while the projects keep the base values. */
  .v-port{--hero-drop:min(clamp(424px, calc(490.84px + var(--kf-x) * -0.0852551), 490.84px),
      max(0px, calc(100svh - clamp(458.16px, calc(458.16px + var(--kf-x) * 0.0852551), 525px))));
    --kicker-fs:clamp(9px, calc(9px + var(--kf-x) * 0.00127551), 10px);
    --kicker-mb:20px;
    --kicker-block:calc(var(--kicker-fs) * 1.55 + var(--kicker-mb));
    /* the hero's own horizontal padding, shared with .panel--project below 
       (the pair is stated once here and referenced twice) */
    --hero-pad-r:calc(var(--cap) + clamp(32px, calc(60px + var(--kf-x) * -0.03571428), 60px));
    /* The info bar's vertical rule sits at --cap + these two, and --hero-pad-l is built
       from the SAME two rather than a hand-fitted pair - that is what keeps the titles on
       the line under them when either one moves. It used to be its own clamp(48 -> 65),
       which meant two places to edit and a silent drift if you forgot one. Mobile
       overrides --hero-pad-l below, and --bar-pad-l is tightened at 566+ where the rail
       is no longer taking up the left edge. */
    --bar-pad-l:clamp(40px, calc(48px + var(--kf-x) * -0.01020408), 48px);
    --rule-indent:clamp(0px, calc(0px + var(--kf-x) * 0.03188775), 25px);
    --hero-pad-l:calc(var(--cap) + var(--bar-pad-l) + var(--rule-indent));}
  .v-port .panel--hero{display:grid;align-items:center;
    padding:0 var(--hero-pad-r) 0 var(--hero-pad-l);}
  /* MOBILE IS ITS OWN VERTICAL REGIME. Above 565 the drop's two limits are tuned so the
     block lands at the same height as it does at the band cap - that match is a
     desktop-resize concern, and at phone widths it pushed the wordmark down out of the
     upper half of the frame and into the tagline's. So below 565 the drop goes back to
     being a pair on the VALUE (60 at 360) against a flat guard, which is where the hero
     has always sat on a phone, and the badge comes back with it. Same 565 boundary the
     snake and the ring colour ramp use.
     This STEPS, it does not ramp: crossing 565 on a tall narrow window moves the wordmark
     by up to ~127px (much less on short ones - ~25px at 640 tall). A phone never crosses
     it; dragging a desktop window across it does. */
  @media (max-width:565px){
    /* Mobile keeps the pre-alignment padding verbatim - the bar's rule is at 48 down here
       and the wordmark at 60, which is the composition as tuned. Aligning it would be a
       12px move on a phone. Costs a 7.5px step at the boundary (52.5 against 60), only
       visible dragging a desktop window across it. */
    .v-port{--hero-pad-l:calc(var(--cap) + clamp(60px, calc(60px + var(--kf-x) * 0.02678571), 81px));}
    /* The lag line is a .kicker, which carries its own margin-bottom for the hero's
       composition. Mobile's project column spaces its children with the flex gap instead,
       so the two stack and the title ends up twice as far from its lag line as anything
       else in that column is from its neighbour. */
    .v-port .panel--project .p-info .kicker{margin-bottom:0;}
    .v-port .panel--hero{
      /* Bigger type and a smaller drop than the shared curve gives: a phone has far less
         frame to fill, so the wordmark reads small and sits low on the base values. The
         SIZE ramp is fitted to rejoin the base ramp exactly at 565 (56 -> 62.1059, which is
         what 54 + 205 x 0.03954081 comes to), so it adds no step of its own - the position
         step described above is the only one at this boundary. --hero-size is set on the
         panel rather than .v-port: custom properties inherit, so the nearer element wins
         and the h1 picks this up without a specificity fight. */
      --hero-size:clamp(56px, calc(56px + var(--kf-x) * 0.02978471), 62.11px);
      --hero-drop:min(clamp(36px, calc(36px + var(--kf-x) * 0.46428571), 424px),
      max(0px, calc(100svh - 525px)));}
  }
  /* Project panel interpolates the old breakpoint steps: right pad 28@390 -> 32@1000
     (the old 12px floor left the title and description almost touching the frame edge on
     narrow windows),
     left pad 48 (gutter+16, rail clearance) until 860 -> 32@1000, gap 16 -> 24 -> 40, and the
     info column max is a smoothed V (420@500 -> 280@860 -> 420@1000). All gutter-tied sides
     ride var(--cap) so they keep growing past the layout cap. */
  .v-port .panel--project{display:grid;
    grid-template-columns:minmax(0,260px) minmax(0, clamp(280px, calc(280px + var(--kf-x) * 0.17857143), 420px));
    gap:clamp(16px, calc(16px + var(--kf-x) * 0.03061224), 40px);
    align-items:center;justify-content:center;
    padding:2cqb
            calc(var(--cap) + clamp(28px, calc(28px + var(--kf-x) * 0.00510205), 32px))
            2cqb
            calc(var(--cap) + clamp(32px, calc(48px + var(--kf-x) * -0.02040816), 48px));}
  /* The rings are an inline <svg> (not a background image) so their fill inherits colour
     from CSS. --rect-mix is 1 on the Porcupine panel and falls to 0 either side of it, so the
     tint interpolates across the transition rather than snapping. */
  .v-port .frame{--rect-mix:clamp(0,calc(1 - max(var(--progress,0) - 2, 2 - var(--progress,0))),1);--gutterC:#000;--gutterDark:rgba(0,0,0,.3);--ringOverlay:color-mix(in srgb, rgba(255,226,238,1) calc(var(--rect-mix) * 100%), rgba(0,0,0,0));}
  /* The gutter (past --cap) reads DARKER than the band, not brighter - it used to be the
     band colour plus full grain, which lifted it. Two things hold it down now: --gutterC is
     #000 rather than the band's #0a0a0a, and --gutterDark lays a flat black wash over the
     gutter columns. Both sit in the same ::after as --ringOverlay, in two stacked layers so
     the Porcupine pink ramp is untouched: the pink is the TOP layer and is fully opaque at
     rect-mix 1, so it covers the wash completely on that panel and the ramp still reads as
     one step out from the warm-white ring. Because ::after paints above the noise, the wash
     also mutes the grain in the gutter, which is most of what makes it read as recessed. */
  .v-port .frame::after{content:"";position:absolute;inset:0;z-index:1;pointer-events:none;background:linear-gradient(to right,var(--ringOverlay) 0,var(--ringOverlay) var(--cap),transparent var(--cap),transparent calc(100% - var(--cap)),var(--ringOverlay) calc(100% - var(--cap)),var(--ringOverlay) 100%),linear-gradient(to right,var(--gutterDark) 0,var(--gutterDark) var(--cap),transparent var(--cap),transparent calc(100% - var(--cap)),var(--gutterDark) calc(100% - var(--cap)),var(--gutterDark) 100%);}
  .v-port .frame > .rects{position:absolute;top:0;left:50%;transform:translateX(-50%);pointer-events:none;z-index:1;}
  /* fill fades to black off-panel (keeps the documented 0.15 vignette on other panels);
     on the Porcupine panel opacity reaches 1 so no dark frame bg bleeds through. Below full
     opacity the single-layer outer ring bleeds more bg than the multi-layer inner bands, so
     the edge reads as a dark rim; at opacity 1 every band shows its exact colour and the ramp
     is clean (warm-white outer -> pink inner). */
  .v-port .frame > .rects rect{fill:color-mix(in srgb, var(--c,#000) calc(var(--rect-mix) * 100%), #000);fill-opacity:calc(0.15 + 0.85 * var(--rect-mix));}
  .v-port .frame::before{content:"";position:absolute;inset:-48px;background:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='200' height='200'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.6 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");background-repeat:repeat;pointer-events:none;opacity:.2;z-index:0;will-change:transform;animation:bgnoise .6s steps(1) infinite;}

  /* ===== Scroll-driven animations: panel motion + bar cross-fade on the COMPOSITOR =====
     Safari throttles requestAnimationFrame to 30fps in Low Power Mode, so any rAF-driven
     motion stutters against native (compositor) scrolling no matter how cheap the JS is.
     Where supported, the scroller exposes a scroll timeline and the panels/bar animate
     against it directly - full frame rate, zero main-thread work per frame. The JS driver
     still writes --progress (rail state, rect tint), but the smoothness-critical motion no
     longer depends on it; CSS animations also outrank the var-driven declarations in the
     cascade, so these simply take over where supported. Travel is exactly 200svh: track
     (3*100svh - header) minus sticky stage (100svh - header). Prosession is hidden, so
     these are 3-panel values - restore 300svh and 33.333%/66.667% when it returns.
     ONE keyframe set drives both the panels and the bar: they cross-fade on the same
     value, so a single fade-N is the only place the panel count is encoded here.
     The panels are selected by [data-panel], not :nth-child - the rect <svg> is
     prepended to .frame at runtime, which shifts every child index. */
  @supports (animation-timeline: scroll()) {
    .page[data-page="portrait"].on{scroll-timeline-name:--page;}
    .v-port .panel,.v-port .panel > *:not(.p-phone),.v-port .sub-panel,
    .v-port .panel--project .p-phone{
      animation-duration:auto;animation-timing-function:linear;
      animation-fill-mode:both;animation-timeline:--page;animation-range:0 200svh;}
    /* the bar cross-fades at every width; the PANEL slides, which is the mobile regime */
    .v-port .panel{animation-name:panel-slide;}
    .v-port .sub-panel:nth-child(1){animation-name:fade-0;}
    .v-port .sub-panel:nth-child(2){animation-name:fade-1;}
    .v-port .sub-panel:nth-child(3){animation-name:fade-2;}
    .v-port .sub-panel:nth-child(4){animation-name:fade-3;}
    @media (min-width:566px){
      .v-port .panel{animation-name:none;}
      .v-port .panel[data-panel="0"] > *:not(.p-phone){animation-name:fade-0;}
      .v-port .panel[data-panel="1"] > *:not(.p-phone){animation-name:fade-1;}
      .v-port .panel[data-panel="2"] > *:not(.p-phone){animation-name:fade-2;}
      .v-port .panel[data-panel="3"] > *:not(.p-phone){animation-name:fade-3;}
      .v-port .panel--project .p-phone{animation-name:phone-slide;}
    }
  }
  @keyframes panel-slide{from{transform:translateY(calc(var(--i) * 100%))}to{transform:translateY(calc((var(--i) - 2) * 100%))}}
  @keyframes phone-slide{from{transform:translateY(calc(var(--i) * 100cqb))}to{transform:translateY(calc((var(--i) - 2) * 100cqb))}}
  @keyframes fade-0{0%{opacity:1}50%{opacity:0}100%{opacity:0}}
  @keyframes fade-1{0%{opacity:0}50%{opacity:1}100%{opacity:0}}
  @keyframes fade-2{0%{opacity:0}50%{opacity:0}100%{opacity:1}}
  /* fade-3 unused while Prosession is hidden; 4-panel stops were 33.333%/66.667% */
  @keyframes fade-3{0%{opacity:0}66.667%{opacity:0}100%{opacity:1}}

  @media (prefers-reduced-motion: reduce){
    .v-port .frame::before{animation:none;}
  }
  @keyframes bgnoise{0%{transform:translate(0,0)}10%{transform:translate(-30px,-20px)}20%{transform:translate(40px,10px)}30%{transform:translate(-20px,30px)}40%{transform:translate(15px,-35px)}50%{transform:translate(-45px,25px)}60%{transform:translate(30px,40px)}70%{transform:translate(-15px,-30px)}80%{transform:translate(25px,20px)}90%{transform:translate(-35px,-15px)}100%{transform:translate(0,0)}}
  .v-port .panel > *{position:relative;z-index:2;}
  /* 566+ ONLY: the panel stops moving and its contents take over, doing two different
     things - the TEXT cross-fades in place on exactly the formula .sub-panel uses
     (opacity 1 - |i - progress|; CSS clamps opacity to 0..1 itself), while the phone
     image keeps the original slide. Mobile is untouched by all of this.
     The panels end up stacked at inset:0, so an invisible one still hit-tests: the stage
     driver sets pointer-events on the active panel, since opacity:0 does not stop clicks
     and the LAST panel in the DOM would otherwise swallow every link on the page.
     The phone's travel is 100cqb, the frame's block size (.frame is container-type:size)
     - the same distance 100% of the panel resolved to, and the reason it can't just be a
     percentage: a transform percentage is of the PHONE's own box. positionAll() does not
     run at these widths, so nothing else is writing that transform. */
  @media (min-width:566px){
    .v-port .panel{transform:none;will-change:auto;}
    .v-port .panel > *:not(.p-phone){will-change:opacity;
      opacity:calc(1 - max(var(--i) - var(--progress,0), var(--progress,0) - var(--i)));}
    .v-port .panel--project .p-phone{will-change:transform;
      transform:translateY(calc((var(--i) - var(--progress,0)) * 100cqb));}
  }
  /* vw-based insets below all top out at their keyframe value at 1000px. --maxw
     is reached — past that the content column is a fixed width, so anything still scaling
     with vw would slide the content sideways against the background band. */
  /* indent folded into .panel--hero's padding-left so one ramp owns the hero text's
     left edge - see the note there */
  /* Centring the BLOCK is not the same as centring the wordmark. With the kicker sitting
     above it, h1's top lands at (centre + kickerBlock/2 - h1Height/2), so the h1 rises
     whenever the kicker's type or gap shrinks - which is exactly what fluid sizing makes
     them do. A margin-bottom equal to the kicker's own occupied height cancels that term
     out (the algebra: with M = kickerBlock the +kickerBlock/2 and -M/2 terms disappear),
     leaving the h1 itself centred, kicker hanging above, footprint unchanged. This and
     --hero-drop are what keep the wordmark still while the window resizes.
     margin-top is the deliberate bias on top of that - see --hero-drop. It survives the
     algebra above untouched: the mt/2 term is independent of the kicker cancellation. */
  .v-port .hero-block{padding-left:0;margin-top:var(--hero-drop);margin-bottom:var(--kicker-block);}
  /* 566+: the wordmark drops 0.55 of a line of its own type, and .p-info's --title-drop
     carries the same 0.55 so the project titles move with it (1.37 = 0.82 + 0.55).
     A full line was tried and left the wordmark 1.4px off the info bar - see the
     clearance note in CLAUDE.md before moving it further. Added to the top margin and taken
     off the bottom, which moves the block by exactly --line-drop - on the top margin alone
     a centred item moves by half. Scoped to 566+ because mobile overrides --hero-drop on
     .panel--hero and is its own vertical regime. */
  @media (min-width:566px){
    /* Tighter than mobile's 20px. Scoped rather than changed at the source because the
       base value IS the mobile one - the hero reads it at every width. The 4px step at the
       boundary is nothing against the ~127px one the hero's own vertical regime already
       takes there. */
    .v-port{--kicker-mb:16px;
      /* The rail is hidden up here, so the left edge tightens by 25px to match.
         --bar-pad-l is the one knob: the bar's padding, its rule, the tagline/feats and
         the titles all read it, so they move as a unit and the titles stay on the rule.
         Same ramp shape, 25 lower at both ends. It STEPS at the boundary rather than
         ramping, which is right - the rail is there at 565 and gone at 566.
         This has to sit after the base .v-port block: same specificity, so an override
         placed earlier in the file loses (it did, on the first attempt). */
      --bar-pad-l:clamp(15px, calc(23px + var(--kf-x) * -0.01020408), 23px);}
    .v-port .hero-block{--line-drop:calc(var(--hero-size) * 0.55);
      margin-top:calc(var(--hero-drop) + var(--line-drop));
      margin-bottom:calc(var(--kicker-block) - var(--line-drop));}
  }
  /* The badge is anchored to the THIRD concentric rectangle - two --ring-step insets in
     from the band's top-right corner (--cap is the band's own inset). --ring-step MUST stay
     equal to STEP in js/site.js, which is what actually spaces the rings; move one and the
     badge stops sitting on a ring. Retarget it by changing the multiplier: * 1 is ring 2,
     * 3 is ring 4. Its
     containing block is .panel--hero, not the h1 - that is what makes those two offsets
     expressible at all, since the h1 is vertically centred and moves with the window.
     Moving it off the h1 also retired the old construct where an absolutely positioned,
     negative-z child sat inside the heading. That forced WebKit to split the h1's paint
     between the badge and the glyphs above it, and was behind the Safari seam on the last
     glyph. z-index 3 now puts it ABOVE .hero-block (z-index 2), so it sits on top of the
     wordmark rather than passing under it. Note it precedes .hero-block in the DOM, so an
     equal z-index would put the text on top - it has to be strictly greater.
     Both `top` and `right` are ring-anchored CONSTANTS, so the badge's size never drags its
     position around: top on ring 3's line, right on ring 2's. `right` was briefly a
     keyframe pair walking out to x10.425 as the band grew - that was for a desktop
     composition where it hung off the end of the wordmark, and it needed the pair because
     a flat 417px inset is off the left of a 360px screen entirely. The badge is mobile
     only now, so the pair's wide half was never on screen: a small constant is both
     simpler and safe at every width. Retarget with the multiplier - x1 is ring 2, x3 is
     ring 4 - and keep it small enough to fit a 360px screen if it ever goes wide again. */
  .v-port .panel--hero .p-badge{position:absolute;top:calc(var(--ring-step) * 2);right:calc(var(--cap) + var(--ring-step));
    width:clamp(82px, calc(82px + var(--kf-x) * 0.06887755), 136px);
    aspect-ratio:1;color:#e84264;z-index:3;pointer-events:auto;cursor:pointer;}
  /* The badge is MOBILE ONLY - above 565 the hero composition is the wordmark alone. This
     has to be a min-width rule sitting AFTER the rule above, not a display:block inside the
     mobile block further up the file: that block comes first, so at equal specificity the
     base rule would win and the badge would never appear. Note 566+ is exactly the range
     the snake runs in and the badge is its HEAD, so re-enabling the snake means dropping
     this rule too - the ?tune overlay writes an inline display, so its snake mode already
     overrides it either way. */
  @media (min-width:566px){ .v-port .panel--hero .p-badge{display:none;} }

  /* the badge sits inside the h1, so reset the inherited hero font-size — the SVG's own
     letter-spacing is in em and would otherwise resolve against 137px instead of 15px */
  .v-port .panel .p-badge svg{width:100%;height:100%;display:block;font-size:15px;letter-spacing:normal;}
  /* Vertical padding here interpolates 28/40 (at 500px) -> 36/48 (capping ~600px) instead of
     stepping. .p-sub shares the sticky stage with .frame, so a jump in its height resizes
     the frame and makes every concentric ring jump at once. */
  /* --bar-rule-h is the hero tagline's rendered height (2 lines at line-height 1.2).
     The project rows' feature list adopts it as a min-height so the divider rule is the
     same length on every bar row instead of shrinking to its own content. */
  .v-port .p-sub{--bar-tag-size:clamp(18px,calc(18px + var(--kf-x) * 0.00127551),19px);--bar-rule-h:calc(2.4 * var(--bar-tag-size));display:grid;padding:clamp(28px,calc(28px + var(--kf-x) * 0.01020408),36px)
            calc(var(--cap) + clamp(24px, calc(24px + var(--kf-x) * 0.01020408), 32px))
            var(--bar-pad-b)
            calc(var(--cap) + var(--bar-pad-l));border-bottom:1px solid var(--hairline);}
  /* one .sub-panel per stage panel, all in the same grid cell so they overlay and the
     container still sizes to the tallest. Opacity is 1 - |i - progress|, so the bar
     cross-fades on exactly the same scroll value that drives the panels. */
  .v-port .sub-panel{grid-area:1/1;display:flex;justify-content:space-between;align-items:stretch;gap:24px;flex-wrap:nowrap;will-change:opacity;
    opacity:calc(1 - max(var(--i) - var(--progress,0), var(--progress,0) - var(--i)));}
  /* Projects now carry the same two metadata pairs as the hero, so they inherit .meta's
     stacked layout unchanged; only the description is resized to hold the bar's height. */
  .v-port .p-sub .tag{font-family:"DM Serif Display",serif;font-size:var(--bar-tag-size);line-height:1.2;letter-spacing:-.005em;margin:0 0 0 var(--rule-indent);padding:0 0 0 clamp(16px, calc(16px + var(--kf-x) * 0.00510205), 20px);border-left:1px solid rgba(234,230,220,0.25);max-width:24ch;color:var(--ink);font-weight:400;}
  .v-port .p-sub .meta{font-family:var(--mono);font-size:11px;letter-spacing:.2em;text-transform:uppercase;color:var(--muted);display:grid;row-gap:4px;white-space:nowrap;text-align:right;margin:0;}
  .v-port .p-sub .meta b{color:var(--ink);font-weight:400;font-size:13px;text-transform:none;letter-spacing:.02em;}
  /* size and gap live in vars on .panel--hero so .hero-block can cancel them out - see the
     --kicker-block note there. line-height is stated explicitly because that calc needs it. */
  .v-port .panel .kicker{font-family:var(--mono);font-size:var(--kicker-fs);line-height:1.55;letter-spacing:.3em;text-transform:uppercase;color:var(--muted);margin-bottom:var(--kicker-mb);}
  /* padding-right is load-bearing, not spacing. Distortion's last glyph ends EXACTLY on
     the h1's box edge - measured 0.00px of margin - so any compositing layer bounded by
     that box clips the final column of the 'd' under subpixel rounding. In em so it
     tracks the type. The h1 is left-aligned, so this widens the box without moving the
     text. */
  .v-port .panel h1{padding-right:0.05em;font-family:"Distortion",serif;font-size:var(--hero-size);line-height:.82;margin:0;color:var(--ink);letter-spacing:-.015em;font-weight:400;position:relative;width:max-content;max-width:100%;}
  .v-port .panel h1 em{font-style:normal;display:block;padding:0 .1em 0 .3em;}
  .v-port .panel h1 em .dim{color:var(--dim);}
  .v-port .panel h1 em .acc{color:#a89a7d;}

  /* each project slot is a full-height editorial portrait */

  /* Porcupine info-block colour treatment, pulled from the app's screenshot palette */
  .v-port .panel[data-idx="0"]{--section-bg:#1fcbc4;}
  .v-port .panel[data-idx="0"] .p-info,.v-port .panel[data-idx="0"] .p-info *{color:#141b26;}
  /* Offset shadow under the Porcupine title. It is the only --ink text sitting on the pink
     rings, and at 2.78:1 it was getting lost - the offset gives the glyph edges definition
     without darkening the letterform itself. 0.5px is one device pixel on a 2x display, the
     smallest offset that still renders as an edge; the colour carries 45% alpha so it reads
     as a soft edge rather than a hard second copy of the glyph. Not rotated, so positive =
     down-right. Colour is the ring pink darkened, not the old
     #a8b0bd grey-blue, which was tuned for the teal panel this layout replaced. */
  .v-port .panel[data-idx="0"] .p-info .role{color:var(--ink);text-shadow:0.5px 0.5px 0 rgba(104,36,71,.45);}
  .v-port .panel[data-idx="0"] .p-info .role .acc{color:#e7519d;}
  .v-port .panel[data-idx="1"] .p-info .role{color:var(--dim);}
  .v-port .panel[data-idx="0"] .p-info .cta,.v-port .panel[data-idx="0"] .p-info .cta *{color:#1fcbc4;}
  .v-port .panel[data-idx="0"] .p-info .cta{background:#141b26;border-color:#141b26;}
  .v-port .panel[data-idx="0"] .p-info dt{color:var(--ink);text-shadow:1px 1px 0 #a8b0bd;}

  /* center column — the phone */
  /* the phone is now the tallest thing in the panel (aspect ~2.175), so this cap is what
     keeps the panel inside the frame at any window height; syncAll scales the info type
     from the same width. */
  .v-port .p-phone{position:relative;width:100%;max-width:min(240px,40cqb);justify-self:center;}
  .v-port .p-phone .shell{position:relative;border-radius:min(28px,4.667cqb);overflow:hidden;box-shadow:0 0 0 1px rgba(255,255,255,.04);}
  .v-port .p-phone .shell img{width:100%;height:auto;display:block;}
  .v-port .p-phone .numbadge{position:absolute;top:clamp(-8px, calc(-4px + var(--kf-x) * -0.00510205), -4px);left:clamp(-8px, calc(-4px + var(--kf-x) * -0.00510205), -4px);font-family:var(--mono);font-size:clamp(8px, calc(8px + var(--kf-x) * 0.00255102), 10px);letter-spacing:.2em;text-transform:uppercase;color:#0a0a0a;background:var(--ink);padding:clamp(4px,calc(1.222px + .5556vw),6px) clamp(7px,calc(2.833px + .8333vw),10px);border-radius:999px;z-index:2;}
  .v-port .p-phone .status{position:absolute;top:clamp(-7px, calc(-4px + var(--kf-x) * -0.00382653), -4px);right:clamp(-7px, calc(-4px + var(--kf-x) * -0.00382653), -4px);font-family:var(--mono);font-size:clamp(7.5px, calc(7.5px + var(--kf-x) * 0.00255102), 9.5px);letter-spacing:.2em;text-transform:uppercase;color:var(--ink);background:rgba(20,20,20,.9);padding:clamp(4px,calc(1.222px + .5556vw),6px) clamp(7px,calc(2.833px + .8333vw),10px);border-radius:999px;border:1px solid rgba(255,255,255,.12);z-index:2;}

  /* right column — metadata stack */
  .v-port .p-info{display:flex;flex-direction:column;gap:clamp(20px, calc(20px + var(--kf-x) * 0.01020408), 28px);max-width:320px;justify-self:start;}
  .v-port .p-info .role{font-family:"Distortion",serif;font-weight:400;font-size:min(60px,10cqb);line-height:.9;letter-spacing:-.015em;text-transform:none;color:#a89a7d;margin:0;}
  .v-port .p-info .title{font-family:"DM Serif Display",serif;font-size:26px;line-height:1.2;letter-spacing:-.005em;margin:0;color:inherit;font-weight:400;}
  .v-port .p-info .title .thin{font-weight:inherit;opacity:1;}
  .v-port .p-info .title em{font-style:italic;font-weight:inherit;}
  .v-port .p-info .tagline{font-family:"DM Serif Display",serif;font-size:clamp(17px, calc(17px + var(--kf-x) * 0.01147959), 26px);line-height:1.2;margin:0;color:var(--ink);letter-spacing:-.005em;}
  .v-port .p-info dl{display:grid;grid-template-columns:auto 1fr;row-gap:10px;column-gap:clamp(10px, calc(10px + var(--kf-x) * 0.00510205), 14px);margin:0;font-family:var(--mono);font-size:11px;letter-spacing:.1em;}
  .v-port .p-info dt{color:var(--muted);text-transform:uppercase;letter-spacing:.2em;font-size:clamp(9px, calc(9px + var(--kf-x) * 0.00127552), 10px);}
  .v-port .p-info dd{margin:0;color:var(--ink);font-size:clamp(10px, calc(10px + var(--kf-x) * 0.00127552), 11px);}
  /* Feature list lives in the info bar (the panel carries the description). Flows
     column-first at 3 rows, so six items read as two balanced columns and fill the rule
     height (3 rows = 70px inside the 77px rule). Adding a 7th item would silently start a
     third column - keep the count a multiple of 3. */
  .v-port .p-sub .feats{list-style:none;
    display:grid;grid-auto-flow:column;grid-template-rows:repeat(3,auto);column-gap:clamp(20px, calc(20px + var(--kf-x) * 0.0127551), 30px);row-gap:8px;
    align-content:center;min-height:var(--bar-rule-h);
    font-family:var(--mono);font-size:clamp(9px, calc(9px + var(--kf-x) * 0.00255102), 11px);line-height:1.5;letter-spacing:.02em;color:var(--ink);
    /* same divider anchor as the hero's tagline, and deliberately the same margin/padding
       so the rule sits at one x-position across every bar row - otherwise it shifts
       sideways as you scroll from the hero to a project */
    margin:0 0 0 var(--rule-indent);
    padding:0 0 0 clamp(16px, calc(16px + var(--kf-x) * 0.00510205), 20px);
    border-left:1px solid rgba(234,230,220,0.25);}
  .v-port .p-sub .feats li{position:relative;padding-left:1.35em;white-space:nowrap;}
  .v-port .p-sub .feats li::before{content:"+";position:absolute;left:0;top:0;opacity:.45;}
  /* Panel description: the body copy that used to sit in the bar. Sized in cqb so it
     scales with the frame like the title and CTA; measure-capped so it wraps to a block
     rather than stretching the info column to its 320px max. */
  .v-port .p-info .cta{margin-top:8px;display:inline-flex;align-items:center;gap:10px;font-family:var(--mono);font-size:min(11px,1.833cqb);letter-spacing:.2em;text-transform:uppercase;color:var(--ink);padding:8px 14px;border:1px solid #2a2724;align-self:flex-start;width:auto;}
  .v-port .p-info a.cta::after{content:"→";}

  /* PROJECT PANELS, 566+: the title takes the wordmark's place.
     .panel--project gets the SAME grid and padding as .panel--hero, and .p-info carries
     the same --hero-drop and margin-bottom as .hero-block - so the title lands exactly
     where the wordmark does BY CONSTRUCTION, not by a second set of numbers fitted to
     match. Anything that moves the hero moves these with it.
     The CTA is taken out of flow for that to hold: .p-info's in-flow height has to stay
     kicker + title, which is what the margin-bottom cancels against (see .hero-block).
     Leave the CTA in flow and it pushes the title up by half its own height.
     The phone leaves the grid for the band's right edge, on the hero's own right padding.
     Below 566 none of this applies and the old two-column layout is untouched. */
  @media (min-width:566px){
    .v-port .panel--project{align-items:center;justify-content:stretch;
      grid-template-columns:minmax(0,1fr);gap:0;
      padding:0 var(--hero-pad-r) 0 var(--hero-pad-l);}
    /* A FIXED height, not min-height: it has to equal .hero-block's whatever the content
       does, because that equality is what aligns the two blocks' tops. The CTA then rides
       in normal flow directly under the title and simply overflows the box when the title
       runs to two lines - which moves the CTA without moving the block. min-height would
       let a two-line title grow the box and drag the whole composition down with it. */
    /* STRETCH, not start. .p-info carries will-change:opacity for the cross-fade, which
       promotes it to its own layer, and this face's glyphs overhang their advance width by
       ~0.14em - the 'x' of Shadowbox, the 'e' of Porcupine. Shrink-wrapped, .p-info's box
       ended exactly at the title's, so that ink fell outside the layer and was clipped.
       Stretching costs nothing visually (the kicker is a block, .role is width:max-content
       and the CTA is align-self:flex-start, so none of them move) and it is also why the
       hero never had this: .hero-block stretches, so the h1's overhang is well inside it. */
    .v-port .panel--project .p-info{position:relative;justify-self:stretch;gap:0;max-width:none;
      height:calc(var(--kicker-block) + var(--hero-size) * 1.64);
      /* A line and a bit over a half down. The first full line lands the title exactly
         where the wordmark's SECOND line sits - the box is the wordmark's two lines and
         the titles are one, so top-aligned they read high against it. The remaining 0.55
         is the shared move down that .hero-block also takes (--line-drop below), which is
         what keeps the two in step - change one and you must change the other. Added to the top margin and taken off the bottom one,
         which moves the block by exactly --title-drop - put it on the top margin alone and
         a centred item moves by half. The whole block moves, lag line included, so the
         gap between the two stays the hero's. */
      --title-drop:calc(var(--hero-size) * 1.37);
      margin-top:calc(var(--hero-drop) + var(--title-drop));
      margin-bottom:calc(var(--kicker-block) - var(--title-drop));}
    /* the box is shorter than its content on a two-line title, and flex items shrink to
       fit by default - which would squash the type rather than overflow */
    .v-port .panel--project .p-info > *{flex-shrink:0;}
    /* Natural height - the two-line box lives on .p-info above, so the title is free to
       be one line or two and the CTA below it tracks the text rather than the block. */
    .v-port .panel--project .p-info .role{font-size:var(--hero-size);line-height:.82;
      width:max-content;max-width:100%;}
    .v-port .panel--project .p-info .cta{margin-top:16px;}
    /* Centred in the frame, both axes - inset:0 + margin:auto rather than a transform,
       since positionAll() owns the phone's transform below 566. The title is allowed to
       run across it on a narrow window: .p-info comes after .p-phone in the DOM at the
       same z-index, so the type paints on top. */
    /* 165, not 240: the phone is centred in the frame and the title sits low, so the two
       share a band and the type used to cross the image. 165 is the largest that clears
       the lag line on a normal window - the true limit measures 171 at 1512x885, 175 at
       900x885, 181 at 566x885 and 200 at 1080 tall - with a few px in hand.
       It cannot clear at every height: the room above the text is set by --hero-drop,
       which the svh guard shrinks on short windows, and below ~850px tall the limit falls
       to ~86px, which is a thumbnail rather than an image. There the type crosses it
       again. Horizontal clearance is not achievable at all while the phone is centred -
       at 1512 the Porcupine title's ink already ends past the frame's centre line. */
    .v-port .panel--project .p-phone{position:absolute;inset:0;margin:auto;
      height:max-content;width:min(200px,40cqb);max-width:none;}
  }
  .v-port .p-info .short{display:none;}

  /* ticker bar between projects */
  .v-port .p-ticker{overflow:hidden;white-space:nowrap;padding:14px 0;border-top:1px solid var(--hairline);border-bottom:1px solid var(--hairline);font-family:"Distortion",serif;font-size:28px;letter-spacing:.02em;color:#6a624f;}
  .v-port .p-ticker span{display:inline-block;padding:0 30px;}
  .v-port .p-ticker span::before{content:"◎";font-family:serif;color:#3a3630;margin-right:30px;}

  .v-port .p-outro{padding:clamp(32px, calc(32px + var(--kf-x) * 0.01020408), 40px)
            calc(var(--cap) + clamp(24px, calc(24px + var(--kf-x) * 0.01020408), 32px))
            clamp(50px, calc(50px + var(--kf-x) * 0.0127551), 60px);border-top:1px solid var(--hairline);display:grid;grid-template-columns:minmax(0,1fr);justify-items:center;align-items:end;}
  .v-port .p-outro .contact{padding-left:0;}
  .v-port .p-outro .contact dl{display:grid;grid-template-columns:auto auto;row-gap:8px;column-gap:20px;font-family:var(--mono);font-size:clamp(11px, calc(11px + var(--kf-x) * 0.00382653), 14px);letter-spacing:.12em;text-transform:uppercase;color:var(--muted);margin:0;align-items:baseline;}
  .v-port .p-outro .contact dt{}
  .v-port .p-outro .contact dd{margin:0;color:var(--ink);text-transform:none;letter-spacing:.02em;}
  .v-port .p-outro .contact a{color:inherit;text-decoration:none;}

  .v-port .p-foot{padding:28px var(--gutter);border-top:1px solid var(--hairline);display:flex;justify-content:space-between;align-items:baseline;font-family:var(--mono);font-size:11px;letter-spacing:.15em;text-transform:uppercase;color:var(--muted);}
  .v-port .p-foot .wm{font-family:"Distortion",serif;font-size:18px;letter-spacing:.02em;color:var(--ink);text-transform:none;}
  .v-port .p-foot .to-top{color:inherit;text-decoration:none;cursor:pointer;}
  .v-port .p-foot .to-top:hover{color:var(--ink);}

  /* Everything that used to step at 859/500 now interpolates in the base rules above.
     Only genuinely binary switches remain below. */
  /* Two columns of nowrap mono stop fitting beside the metadata at ~600px, so stack to one
     column below that. This threshold moves whenever --maxw or the feature type size does -
     narrowing the band cut the room and the type reaches its max sooner, which pushed the
     crossover from 565 up to 600. It is NOT the same boundary as the snake's or the ring
     ramp's, which happen to also sit at 565 for unrelated reasons. The threshold is set by the single longest feature string -
     currently "Procedural drawing workflows" - so **re-measure it when feature copy
     changes**: past it the metadata is shoved out through the bar's right padding. Watch
     for pastEdge > 0 sweeping widths, not for the columns colliding; the columns still
     look fine well after the row has overflowed.
     align-items:stretch makes every sub-panel's left block fill the row, so the divider
     rule stays the same length on the hero and project rows even though their content
     heights now differ.
     The type is tightened here for one reason: **the six stacked rows must stay shorter
     than .meta**, which is a constant 86px (fixed 11/13px type) and is what sets the bar's
     height at every other width. Overshoot it and the bar's height jumps at this
     breakpoint instead of interpolating - and because .p-sub shares the sticky stage with
     .frame, that jump resizes every concentric ring at once. Budget at the breakpoint:
     6 rows x 12px + 5 x 2px = 82px. Raising the font cap, the line-height or the row-gap,
     or adding a 7th feature, spends past 86 and the jump comes back. */
  @media (max-width:599px){
    .v-port .p-sub .feats{grid-auto-flow:row;grid-template-rows:none;grid-template-columns:minmax(0,1fr);
      min-height:0;row-gap:2px;line-height:1.2;font-size:clamp(9px, calc(9px + var(--kf-x) * 0.01056338), 10px);}
  }
  @media (max-width:500px){
    .v-port .p-top .mark,.v-port .p-foot .wm{-webkit-text-stroke:.4px currentColor;}
    /* the type/spacing that used to snap here is now fluid in the base rules, interpolating
       from these same values at 500px up to the desktop values at 860px */
    .v-port .p-info .full{display:none;}
    .v-port .p-info .short{display:inline;}
  }
