/* ═══════════════════════════════════════════════════════════════════════════
 screens.css — the project-page choreography.

 Shared by every project page (Studio 0815, Redis Research, Miscellaneous works):
 a page is a stack of FIXED full-viewport screens with exactly one live, and a move
 between them fades the content out, dissolves the ground through the page base to
 its new colour, and only then brings the new content in. The rail is fixed chrome
 and is the one thing that survives the middle of a transition.

 Decision: "Screens, not a scroll" — confirmed
 the same day to carry to all three project pages, superseding the "project pages
 carry no choreography" row in §1.

 Pair this with screens.js, which drives it. The page owns its palette and type; this
 file owns position, layering and timing. It expects these tokens on:root —

 --t-out --t-bg-out
 --t-bg-in --t-in the four beats; leaving = out+bg-out, entering = bg-in+in
 --t-rail rail ink, crossed with the ground
 --pad-t --pad-b --gut-l screen padding and the rail's gutter
 --tail (100px) breathing room past the last block on a screen
 that scrolls; invisible on one that doesn't
 --ink (r,g,b triplet) light ink, for a dark ground
 --dark (r,g,b triplet) dark ink, for a light ground —.rail.on-light
 --s --head --mono accent, heading ink, mono stack

 And these hooks in the markup —

 <div id="bg"> the ground layer, first in the body
 <div class="rail" id="rail"> built by screens.js
 <section class="sec" data-bg="…" [data-ink-dark]> one per screen

 data-bg may name a token (data-bg="var(--p)") so a page keeps its live colour
 picker. data-ink-dark marks a screen light enough to need the dark rail.
 ═══════════════════════════════════════════════════════════════════════════ */

/* ── section shell ───────────────────────────────────────────────────────── */
/* Each section is a fixed, full-viewport screen and exactly one is live. Sections
   here are NOT all one screen tall — §3's wireframe tab runs to ~1300px — so a
   screen taller than the viewport scrolls inside itself rather than being clipped
   or paged away mid-content.

   visibility, not just opacity: it takes the inactive screens out of the tab order
   and the accessibility tree, which opacity alone would not. Its transition is a
   0s step DELAYED by the fade on the way out and immediate on the way in, so a
   single class drives both halves. */
/* --tail is a scroll tail, not padding in the design sense: on a screen whose content
   overflows it is the extra distance you can travel past the last block before the
   over-scroll guard starts filling, so the end of the reading is not also the edge of
   the screen. On a screen that fits, nothing is anchored to the bottom of a `.sec`, so
   it costs nothing and shows nothing. (.sec-open and .sec-close in page.css DO anchor a
   footer to the bottom and override padding-bottom outright — deliberately: a tail there
   would lift that footer off the edge it was placed on.) */
.sec { position:fixed; inset:0; width:100%; overflow-y:auto; overflow-x:hidden;
       overscroll-behavior:contain; -webkit-overflow-scrolling:touch;
       padding:var(--pad-t) 0 calc(var(--pad-b) + var(--tail,100px)); z-index:2;
       opacity:0; visibility:hidden;
       transition:opacity var(--t-out) ease, visibility 0s linear var(--t-out); }
/* Opaque INSTANTLY on the way in — the blocks inside own the entrance fade, not the
   screen. While the screen also faded as one block the two multiplied: everything dimmed
   together, the section reached full opacity while the staggered children were still
   climbing, and the stagger was invisible no matter how far apart the blocks were set.
   Leaving is untouched: `.sec` above still fades the whole screen out as one. */
.sec.is-on { opacity:1; visibility:visible;
             transition:opacity 0s, visibility 0s; }
.sec:focus { outline:none; }   /* focused only to give the keyboard a scroller */

/* The over-scroll guard's lift used to be a transform on the `.sec` itself. It moved off
   the section and onto the section's content the moment the wash and the hairlines went
   `position:fixed` (page.css): a transformed `.sec` becomes their containing block, and
   a fixed element inside a transformed scroller scrolls with the content again — so the
   ground would have snapped back into the scroll at exactly the bottom edge where the
   guard fires. Shifting the content reads identically: a soft radial and a set of
   vertical hairlines show nothing of a 60px vertical move.
   The lift rides `translate`, not `transform` — an opening figure is centred with a
   transform of its own and setShift must not compete for the property. See its comment. */
.sec > .body, .sec > .open-stage { transition:translate 120ms ease-out; }

/* Arriving content stages in top-to-bottom, each block behind the one above it. The
   per-block delay is written by screens.js as --stagger.

   Scoped to .is-staging, which exists only for the length of the entrance. Left on
   permanently, every later tab switch would inherit whatever delay that block was given
   on arrival and fade in behind it instead of switching — a 300ms lag on a control that
   should feel instant. */
.sec.is-staging .body > * { opacity:0; }
.sec.is-staging.is-on .body > * { opacity:1;
                                  transition:opacity var(--t-in) ease var(--stagger,0ms); }

/* The ground gets its own fixed layer so it can dissolve independently of what sits
   on it: out to the page base, swap colour while nothing is showing, back in. Held
   in a custom property rather than set directly, so §1 can keep naming var(--p) and
   stay on the live colour picker. */
/* transition-duration is set per direction by screens.js before each flip — leaving and
   entering no longer cost the same, so one shared value cannot express it. */
#bg { position:fixed; inset:0; z-index:0; background-color:var(--page-bg,#070d0e);
      transition:opacity var(--t-bg-out) ease; }

/* ── the rail ────────────────────────────────────────────────────────────── */
/* Fixed chrome, above every screen and outside all of them: the rail and its Back
   link are the two things that stay put through a transition. It used to be repeated
   inside all eight sections, which meant it faded out with whichever one was leaving. */
.rail { position:fixed; left:clamp(26px,2.2vw,46px); top:var(--pad-t); z-index:9;
        width:210px; display:flex; flex-direction:column; gap:18px; }
/* Back and the menu button are ONE affordance described once: same outline, same ink, same
   hover. They sit side by side under 1000px and any drift between them would read as two
   unrelated controls that happen to be adjacent. The menu button is display:none until the
   narrow block below turns it on — above 1000px the tree it opens is already on screen. */
/* line-height as a LENGTH (1.5em resolves against this rule's own 12px, to 18px) and not as
   a number, because that is what makes the two boxes the same height by construction rather
   than by coincidence. A unitless 1.5 inherits as a ratio and would be re-multiplied against
   the menu glyph's much larger font-size; the computed length inherits as 18px whatever the
   child is set in. Both buttons then stand 18px + 12px of padding tall, and the glyph inside
   the menu is free to be any size without moving the row. */
.rail-back, .rail-menu { align-self:flex-start; display:inline-flex; align-items:center; gap:9px;
             border:1px solid rgba(var(--ink),.24); padding:8px 13px;
             font-family:var(--mono); font-size:12px; line-height:1.5em; letter-spacing:.06em;
             color:rgba(var(--ink),.78); transition:border-color .2s, color .2s; }
.rail-back:hover, .rail-menu:hover { border-color:var(--s); color:var(--s); }
.rail-back span:first-child { color:var(--s); }
.rail-menu { display:none; cursor:pointer; background:none; padding:6px 11px; }
.rail-menu span { font-size:26px; }   /* the glyph, overrunning its 18px line box on purpose */
.rail-menu[aria-expanded="true"] { border-color:var(--s); color:var(--s); }
.rail-compact { display:none; font-family:var(--mono); font-size:11px; letter-spacing:.16em;
                text-transform:uppercase; color:var(--s); }
.rail-tree { display:flex; flex-direction:column; gap:3px; font-family:var(--mono);
             font-size:12px; line-height:1.5; border-top:1px solid rgba(var(--ink),.24);
             padding-top:15px; }
.rail-row { display:flex; gap:8px; white-space:pre; color:rgba(var(--ink),.6); }
/* Fixed cell for the marker: it steps ◀ █ ▒ ░ and then to nothing, and an empty cell
   one glyph narrower than a full one would twitch the whole tree on every move. It
   keeps the accent while it thins — the highlight leaving and the mark thinning are
   two separate signals, and they should not read as one. */
.rail-row .mark { display:inline-block; width:1ch; color:var(--s); }
.rail-row .label { letter-spacing:.1em; text-transform:uppercase; }
.rail-row .branch { color:rgba(var(--ink),.28); }
.rail-row.child { color:rgba(var(--ink),.42); }
/* The route a jump travels: each row it passes through lights in a softened accent,
   in turn, ending on the destination — then the whole trail releases. color-mix keeps
   it tied to --s, so it follows the live colour picker; an rgba literal would not.
   Declared BEFORE .on so an arriving row that still carries .is-path for a frame
   settles on the full accent rather than the soft one. */
.rail-row.is-path { color:color-mix(in srgb, var(--s) 58%, transparent); }
.rail-row.is-path .branch { color:color-mix(in srgb, var(--s) 38%, transparent); }

.rail-row.on, .rail-row.on .branch { color:var(--s); }
.rail-row:hover { color:var(--head); }
/* The design's tree is display-only. On a real page a position indicator that
   can't navigate wastes the affordance, so rows are links. */


/* Dark ink, for whichever screen puts the rail on the Primary ground. A modifier on
   the rail now rather than a descendant rule of §1, because the rail no longer lives
   inside any section. The swap is timed to the moment the ground is out. */
/* One rule owns every rail colour transition. `.rail-row` used to declare its own and
   never got it: equal specificity, and this line came later. */
.rail, .rail * { transition:color var(--t-rail) ease, border-color var(--t-rail) ease; }
.rail.on-light .rail-back, .rail.on-light .rail-menu { border-color:rgba(var(--dark),.3); color:rgba(var(--dark),.78); }
.rail.on-light .rail-back:hover, .rail.on-light .rail-menu:hover { border-color:#120c14; color:#120c14; }
.rail.on-light .rail-menu[aria-expanded="true"] { border-color:#120c14; color:#120c14; }
.rail.on-light .rail-back span:first-child,
.rail.on-light .rail-compact { color:#120c14; }
/* border-top-color alone was enough while the tree was only ever a column with a rule above
   it. Under 1000px it is a bordered dropdown on all four sides, and three of them would have
   stayed dark-ground on a light screen. */
.rail.on-light .rail-tree { border-color:rgba(var(--dark),.3); }
.rail.on-light .rail-row { color:rgba(var(--dark),.6); }
.rail.on-light .rail-row .branch { color:rgba(var(--dark),.28); }
.rail.on-light .rail-row.child { color:rgba(var(--dark),.42); }
.rail.on-light .rail-row.on, .rail.on-light .rail-row.on .branch { color:#120c14; }
.rail.on-light .rail-row .mark { color:#120c14; }
/* The route is normally drawn while the ground is out and the rail is in its light-ink
   theme, so this only matters for a move between two screens sharing a colour. */
.rail.on-light .rail-row.is-path { color:rgba(var(--dark),.62); }
.rail.on-light .rail-row.is-path .branch { color:rgba(var(--dark),.4); }
.rail.on-light .rail-row:hover { color:#120c14; }

/* Under 1000px the tree has no column to live in and collapses to the compact
   one-line form. The page is responsible for clearing it — .body needs padding-top. */
@media (max-width:1000px) {
  .rail { left:var(--gut-l); right:var(--gut-l); width:auto;
          flex-direction:row; align-items:center; gap:14px; }
  .rail-compact { display:block; }
  .rail-back { padding:6px 11px; }
  .rail-menu { display:inline-flex; }
  /* The bar no longer slides away with the scroll (screens.js, wireRailPin), so it now has
     content passing UNDER it rather than only above it — and it had no ground of its own,
     because a bar that leaves before the content arrives never needs one. This is that
     ground: full-bleed past the gutters, up over the page's top padding, and painted in
     --page-bg so it follows the screen's own colour the way .tabs.is-stuck does.
     z-index:-1 stays inside the rail's own stacking context (z-index:9), so it covers the
     page without ever rising over the buttons it sits behind. */
  /* --rail-gap is the clear space between the bar and whatever pins beneath it, declared ONCE
     here and read by screens.js when it publishes --rail-pin. The band then reaches past that
     gap rather than stopping level with it, so the rail's ground and the tab bar's own
     overlap instead of meeting — two edges that merely touch leave a hairline of moving
     content between them, and this one was exactly 1px and perfectly visible.
     It matters more than it looks: below 700px the tab bar scrolls sideways, which forces
     its overflow-y off `visible` and CLIPS the 100vh ground band it used to paint above
     itself. That job is this band's now, and it has to actually reach. */
  .rail { --rail-gap:10px; }
  .rail::before { content:''; position:absolute; z-index:-1;
                  left:calc(-1 * var(--gut-l)); right:calc(-1 * var(--gut-l));
                  top:calc(-1 * var(--pad-t)); bottom:calc(-1 * (var(--rail-gap) + 8px));
                  background:var(--page-bg,#070d0e); }
  /* The tree does not disappear here — it moves. Above 1000px it is a column that is always
     on screen; below, there is no column for it, so it becomes what the menu button opens.
     Same rows, same hrefs, same mark and path animation: nothing is duplicated, so nothing
     can fall out of step with the rail it is a view of. */
  .rail-tree { display:none; position:absolute; top:calc(100% + 11px); left:0;
               min-width:min(72vw,260px); padding:9px; border-top:0;
               background:var(--page-bg,#070d0e);
               border:1px solid rgba(var(--ink),.24); }
  .rail.menu-open .rail-tree { display:flex; }
  .rail-tree .rail-row { padding:7px 6px; }
}

/* ── the lightbox ─────────────────────────────────────────────────────────
   Above everything, rail included: while it is open it IS the page. The backdrop
   is the page base at near-opacity rather than pure black, so it reads as the same
   material the ground dissolves through rather than as a browser chrome overlay. */
#lightbox { position:fixed; inset:0; z-index:40; display:flex; align-items:center;
            justify-content:center; gap:clamp(8px,2vw,28px);
            padding:clamp(16px,3vw,48px);
            background:rgba(4,8,9,.94); opacity:0;
            transition:opacity 180ms ease; }
#lightbox.is-open { opacity:1; }
#lightbox[hidden] { display:none; }

/* The picture takes whatever room is left after the caption and the arrows, and is
   never scaled past its own pixels — a 767px analysis slide blown up to 1400 is
   mush, and these are the evidence. */
/* align-items:center, not the default stretch — a column flex container stretches its
   items across the cross axis, which silently blew a 767px analysis slide up to ~950px
   of mush. These are the evidence; they are never scaled past their own pixels. */
.lb-stage { position:relative; margin:0; display:flex; flex-direction:column; align-items:center;
            gap:14px; max-width:100%; max-height:100%; min-width:0; }
/* The still alone sizes the box; renderLightbox's fit gives the video the still's
   measured rect. No wrapper — putting the <img> inside one collapsed it, because
   `max-width:100%` against a shrink-to-fit parent has nothing to resolve against. */
.lb-vid { position:absolute; object-fit:contain;
          opacity:0; transition:opacity .35s ease; }
.lb-vid.is-on { opacity:1; }

.lb-stage img { align-self:center; max-width:100%;
                max-height:calc(100vh - clamp(120px,18vh,190px));
                width:auto; height:auto; object-fit:contain;
                outline:1px solid rgba(var(--ink),.16); background:#f4f4f2; }
.lb-stage figcaption { width:100%; display:flex; justify-content:space-between; align-items:baseline;
                       gap:24px; border-top:1px solid rgba(var(--ink),.14); padding-top:11px;
                       font-family:var(--mono); font-size:var(--t-mono-s,12px); line-height:1.5;
                       color:rgba(var(--ink),.62); }
.lb-count { color:var(--s); flex:none; }

.lb-nav, .lb-x { all:unset; cursor:pointer; font-family:var(--mono);
                 color:rgba(var(--ink),.55); border:1px solid rgba(var(--ink),.2);
                 display:flex; align-items:center; justify-content:center;
                 transition:color .2s ease, border-color .2s ease; }
.lb-nav { width:44px; height:64px; flex:none; font-size:18px; }
.lb-x { position:absolute; top:clamp(14px,2.4vh,26px); right:clamp(14px,2.4vw,30px);
        width:34px; height:34px; font-size:13px; }
.lb-nav:hover, .lb-x:hover, .lb-nav:focus-visible, .lb-x:focus-visible {
        color:var(--s); border-color:var(--s); }
.lb-nav[hidden] { display:none; }

/* A frame that opens is a frame you can click — say so. */
[data-lb] .fig .frame { cursor:zoom-in; }

@media (max-width:700px) {
  #lightbox { flex-direction:column; justify-content:center; }
  .lb-nav { width:64px; height:40px; }
  .lb-stage { order:-1; }
}


/* ── the gallery ──────────────────────────────────────────────────────────
 One large picture with its own text beside it: a marker for where you are in the
 set, an obvious Back and Next, and a way out to full screen. Every set of images
 on a project page is one of these.

 Decision: one big gallery next to text, clicking through image by image with each
   image's own text beside it — and no thumbnail index. It replaces BOTH earlier
 components: the.figs thumbnail grids in the case studies, and the overview screens'
 hero-plus-index-of-thumbnails. A grid showed every picture at a size where none of
 them could be read, and made the lightbox the only place the work was actually
 visible; the gallery makes the page itself the place. */
.gal { display:grid; grid-template-columns:minmax(0,1fr) minmax(230px,29%);
       gap:clamp(18px,2.2vw,34px); align-items:start; }

/* One box for the whole set, sized to the TALLEST picture in it and never re-sized
   between steps. Sizing each step to its own picture makes the frame jump under the
   cursor on every click of Next, which on an 11-step set is most of the interaction. */
.gal-stage { position:relative; width:100%; margin:0; min-width:0;
             max-height:min(64vh,660px);
             background:#f4f4f2 center/contain no-repeat;
             outline:1px solid rgba(var(--ink),.16); cursor:zoom-in; }
.gal-stage.on-dark { background-color:#060b0c; }

/* A moving figure lays its video over its own still. The still is the poster and stays
   painted underneath, so the fade-in has something to fade from and a video that never
   arrives leaves the page exactly as it shipped before. Fades on `playing`, not on
   `loadeddata` — the point is to reveal motion, not a first frame.

   object-fit is `contain`, matching the stage's own `center/contain` background: the
   stage is capped at min(64vh,660px) and letterboxes, so `cover` would crop the video
   against an uncropped still and the fade would visibly re-frame the picture. */
.gal-vid { position:absolute; inset:0; width:100%; height:100%; object-fit:contain;
           opacity:0; transition:opacity .45s ease; pointer-events:none; }
.gal-vid.is-on { opacity:1; }
.gal-stage:focus-visible { outline:1px solid var(--s); outline-offset:2px; }
/* The affordance, spelled out — a picture that opens is not obviously a picture that
   opens. Corner rather than centre: the middle of the frame is the work. */
.gal-stage::after { content:'⤢'; position:absolute; right:10px; bottom:8px;
                    font-family:var(--mono); font-size:13px; line-height:1;
                    padding:5px 7px; background:rgba(4,8,9,.62); color:rgba(255,255,255,.8);
                    opacity:0; transition:opacity .2s ease; pointer-events:none; }
.gal-stage:hover::after, .gal-stage:focus-visible::after { opacity:1; }

.gal-side { display:flex; flex-direction:column; gap:clamp(12px,1.5vh,18px);
            font-family:var(--mono); font-size:var(--t-mono-s,12px); line-height:1.6; }
/* The lede — the brief explanation that introduces the set — is authored in the markup
   and moved in here, so the whole column reads as one piece of text: what this is,
   then which picture you are on, then what that picture shows. */
.gal-lede { display:flex; flex-direction:column; gap:12px; }

/* Position as density, not as a number: the marker is the filled note among empty ones,
   the same ▓/░ vocabulary the rail marker steps through. The count stays alongside
   because it also carries the source index ("p04", "v1") where there is one. */
.gal-meter { display:flex; align-items:baseline; justify-content:space-between; gap:14px;
             border-top:1px solid rgba(var(--ink),.16); padding-top:11px; }
.gal-ticks { display:flex; flex-wrap:wrap; gap:2px; }
/* Bigger than the text beside it and on its own line height: at body size a row of
   ░ reads as a texture rather than as a control you can count and click. */
.gal-tick { all:unset; cursor:pointer; color:rgba(var(--ink),.26);
            font-size:15px; line-height:1; transition:color .18s ease; }
.gal-tick:hover, .gal-tick:focus-visible { color:rgba(var(--ink),.7); }
.gal-tick[aria-current="true"] { color:var(--s); }
.gal-count { flex:none; color:var(--s); white-space:nowrap; }

.gal-cap { margin:0; color:rgba(var(--ink),.74); }
.gal-cap em { color:var(--head,#f4f9f7); font-style:normal; }
/* A set of one has no meter, so the caption inherits its rule — the column still reads
   as lede, then divider, then what this picture shows. A figure with no caption at all
   takes neither, rather than leaving a rule with nothing under it. */
.gal-cap:empty { display:none; }
.gal-side.solo .gal-cap:not(:empty) { border-top:1px solid rgba(var(--ink),.16);
                                      padding-top:11px; }

.gal-btns { display:flex; gap:8px; flex-wrap:wrap; }
.gal-btn { all:unset; cursor:pointer; flex:1 1 auto; text-align:center;
           border:1px solid rgba(var(--ink),.24); padding:9px 12px;
           font-family:var(--mono); font-size:var(--t-mono-s,12px); letter-spacing:.06em;
           color:rgba(var(--ink),.78);
           transition:color .2s ease, border-color .2s ease; }
.gal-btn:hover, .gal-btn:focus-visible { color:var(--s); border-color:var(--s); }
.gal-zoom { flex:0 0 auto; }

/* A screen light enough for the dark rail needs the dark gallery ink too. */
.sec[data-ink-dark] .gal-tick { color:rgba(var(--dark),.3); }
.sec[data-ink-dark] .gal-cap { color:rgba(var(--dark),.76); }
.sec[data-ink-dark] .gal-btn { color:rgba(var(--dark),.78); border-color:rgba(var(--dark),.28); }
.sec[data-ink-dark] .gal-meter { border-top-color:rgba(var(--dark),.22); }
.sec[data-ink-dark] .gal-side.solo .gal-cap:not(:empty) { border-top-color:rgba(var(--dark),.22); }

/* Under 1000px the picture takes the full width and its text follows underneath —
 side by side, neither of them would have room to be read.

 But not ALL of its text. Stacked, the two-column order put the set's own title and
 standfirst *below* the picture, so a phone or tablet reader met an unexplained
 screenshot and had to scroll past it to find out what they were looking at. Side by
 side that never happens — the lede sits at the top of the right-hand column, level
 with the frame.

 `display:contents` on.gal-side rather than a DOM change in initGalleries, because
 the lede is *authored* inside.gal and *moved* into the side column by JS — undoing
 that per breakpoint would mean re-parenting on resize. Dissolving the wrapper instead
 promotes its four children (lede, meter, caption, buttons) to grid items of.gal, and
 `order:-1` lifts the lede above the frame. Nothing else moves: the remaining three
 keep DOM order below the picture.gal-side's typography is all inherited properties
 so it still reaches them, and.gal-side.solo.gal-cap still matches — a box is not
 generated, but the element and its selectors remain. */
@media (max-width:1000px) {
  .gal { grid-template-columns:1fr; gap:16px; }
  .gal-stage { max-height:52vh; }
  .gal-side { display:contents; }
  .gal-lede { order:-1; }
}

/* ── sticky tab bars ──────────────────────────────────────────────────────
   A case-study screen scrolls inside itself, so its tab bar rides to the top and
   stays there — the topic you are reading should not scroll out of reach of the
   topic you might switch to. It has to paint the ground colour, or the content
   passing underneath shows straight through it. */
/* A little top padding so content sliding under a pinned bar does not touch the labels —
   but only a little: it is dead space on every screen at rest, where the bar is just the
   next thing after the title. */
.sec .tabs { position:sticky; top:0; z-index:7;
             padding-top:clamp(8px,1.1vh,12px); }

/* The ground colour only once the bar is pinned. At rest it is a flat block sitting on top
   of the screen's wash gradient and its 12 hairlines, and it reads as a panel the design
   never had — the bar is just the next thing after the title there, with nothing to hide.
   Pinned, it is chrome over moving content and has to be opaque. */
.sec .tabs.is-stuck { background:var(--page-bg,#070d0e); }
/* The bar pins at the screen's top padding, not at 0 — sticky is measured from the
   scrollport's content edge. That leaves a band above it that scrolled content rides
   up into, so the bar carries the ground colour up through it, full-bleed past the
   body's gutters.

   ONLY once it is actually stuck. Unconditionally, this band sits above the bar at
   rest too — which is exactly where the screen's title is — and painted the bottom
   half of "How it was designed" out. screens.js sets .is-stuck from the scroll offset.

   100vh rather than the padding height: the bar pins at different offsets at different
   widths (62px wide, 101px under the collapsed rail), so a band sized to one of them
   leaves a gap above it at the other, and content rides up through the gap. Overshooting
   costs nothing — it is clipped by the viewport, and the rail outranks it on z-index. */
.sec .tabs.is-stuck::before { content:''; position:absolute; bottom:100%;
                     left:calc(-1 * var(--gut-l)); right:calc(-1 * var(--gut-r));
                     height:100vh; background:var(--page-bg,#070d0e); }

/* The rail USED to slide away with the scroll under 1000px, so by the time the tabs pinned
   the bar had long gone and they could take the top edge themselves. It stays now, so the
   top edge is spoken for and the tabs pin beneath it — otherwise the bar they are supposed
   to sit under is the thing they slide behind.
   --rail-pin is measured and published by screens.js rather than written as a constant here:
   the bar's height is the height of a bordered button with a clamped font, which is not a
   number this stylesheet can know, and the last time this offset was hardcoded (44px) it
   outlived the layout that produced it. */
@media (max-width:1000px) { .sec .tabs { top:var(--rail-pin,0px); } }

/* ── the scroll guard ─────────────────────────────────────────────────────
   Scrolling past the end of a screen does not move to the next one on its own.
   A strip grows from the edge naming what is next, and only a scroll that keeps
   going commits to it; stop pushing and it retracts. The point is that leaving a
   project should be a thing you did, not a thing that happened to you. */
/* Primary while you are deciding, Secondary once it locks — the state lives in the
   ground, as it did before the starfield arrived. The stars are the dark ink ON that
   ground, so they read against both. */
#nextcue { position:fixed; left:0; right:0; z-index:12; height:0; overflow:hidden;
           background:var(--p); color:#120c14; pointer-events:none;
           display:flex; align-items:center; justify-content:center;
           transition:height 120ms ease-out, background-color 90ms ease; }
#nextcue.is-armed { background:var(--s); }

/* Anchored to the growing edge at FULL height, so the grid behind it never changes
   as the strip opens — only how much of it you can see. */
/* Black stars — rgb(var(--dark)) is the vault's black, the same ink the opening screen
   puts on the Primary ground, so it holds against Primary and Secondary alike. */
.cue-stars { position:absolute; left:0; right:0; margin:0; z-index:0;
             font-family:var(--mono); font-size:calc(7px / 0.6); line-height:7px;
             color:rgb(var(--dark)); white-space:pre; overflow:hidden;
             user-select:none; pointer-events:none; }
#nextcue.at-end .cue-stars { bottom:0; }
#nextcue.at-start .cue-stars { top:0; }
/* The label sits on its own black plate: the starfield behind it is busiest exactly where
   the text is, and dark-on-Primary lost against dark stars. White on black clears both
   grounds and both states without needing a third accent. Square, like everything else
   on this page — the rounded pill is the landing's CTA grammar, not this one's. */
#nextcue .cue-text { position:relative; z-index:1;
                     background:rgb(var(--dark)); color:var(--head,#f4f9f7);
                     padding:7px 16px;
                     font-family:var(--mono); font-size:clamp(12px,.9vw,15px);
                     letter-spacing:.12em; text-transform:uppercase;
                     white-space:nowrap; }
#nextcue.at-end { bottom:0; }
#nextcue.at-start { top:0; }
#nextcue .cue-arrow { font-family:var(--mono); opacity:.7; }
@media (prefers-reduced-motion:reduce) { #nextcue { transition:none; } }
