/* ===== ScrollExpand (React Bits, vanilla port) =====
   Ported from React Bits <https://reactbits.dev>, MIT + Commons Clause,
   Copyright (c) 2026 David Haz. Same licence terms as the ProfileCard and
   ButtonCarousel ports already in this project: usable as part of a website,
   not resellable as a component.

   Upstream drives one clip-path window over a full-bleed <img> and eases it
   open as the page scrolls. This port keeps that mechanism exactly and adds
   the two things this page needs it for:

   - the window does not start at a fixed 42%/58% of the stage, it starts on
     the TRIGGER CARD's thumbnail well and travels to the resting size, which
     is what makes the card look like it lifts out of the grid; and
   - once the window is full-bleed the media keeps going: a full-page
     screenshot is far taller than any viewport, so the last phase pans it
     top-to-bottom instead of stopping at a cropped middle.

   Everything positional is written from JS (js/scroll-expand.js) as inline
   styles on .se-frame / .se-media. This file only ships the resting look, so
   with JS off the trigger card is an ordinary project card with a screenshot
   in its well and nothing here ever appears. */

/* ============================================================
   1. The trigger card, in the grid
   ============================================================ */

/* The whole card is the click target — that is what the reader is pointing at
   — and the thumbnail well inside it is a real <button> so there is a keyboard
   and assistive route to the same thing. These are the button resets; the look
   still comes from .project-preview in style.css / liquid-glass.css. */
.projects-grid [data-scroll-expand] { cursor: pointer; }

.project-preview--shot {
  display: block;
  width: 100%;
  padding: 0;
  font: inherit;
  color: inherit;
  text-align: left;
  appearance: none;
  -webkit-appearance: none;
  cursor: pointer;
  /* Taller than the glyph wells next to it: this one has to read as a page,
     not as an icon. */
  height: 190px;
  overflow: hidden;
  isolation: isolate;
}

/* Top-anchored at the well's own width — deliberately NOT object-fit: cover.
   A 1440x8200 screenshot under `cover` shows a band from the middle of the
   page; anchored like this the well shows the site's header and the top of
   its hero, which is the thumbnail a reader can actually recognise.

   It is also what makes the hand-off seamless: js/scroll-expand.js opens its
   own window on exactly this rect at exactly this scale, so the first frame
   of the animation is pixel-identical to what the well is already showing. */
.project-preview--shot img {
  display: block;
  width: 100%;
  height: auto;
  border-radius: inherit;
  user-select: none;
  -webkit-user-drag: none;
  transition: transform 0.6s var(--ease);
}
/* Solo dove c'e' un puntatore vero che passa sopra — vedi la nota estesa in
   css/layout.css, sotto .spotlight-card:hover. */
@media (hover: hover) {
  .spotlight-card:hover .project-preview--shot img { transform: scale(1.03); }
}

/* ============================================================
   1b. "Click me"
   ============================================================
   Hover used to OPEN the card. It does not any more: with a row of these,
   moving the cursor across the grid kept throwing a full-screen stage at a
   reader who had not asked for one, and every fix for that was another guard
   stopping an interaction from firing.

   So hover advertises instead. The well pulses an amber rim and its caption
   swaps from what the thumbnail IS ("Pagina intera") to what to DO about it
   ("Clicca per aprire"), both on the same 1.5s beat so they read as one
   signal rather than two things happening at once.

   The glow is an outer box-shadow on a box with `overflow: hidden` — that is
   fine, overflow clips content, never an element's own shadow — and the well
   is inset far enough from the card's edge that the bloom lands inside it. */
@keyframes se-beckon {
  0%, 100% {
    box-shadow:
      inset 0 0 0 1px rgba(219, 107, 43, 0.12),
      0 0 0 0 rgba(219, 107, 43, 0);
  }
  50% {
    box-shadow:
      inset 0 0 0 1px rgba(219, 107, 43, 0.85),
      0 0 26px -3px rgba(219, 107, 43, 0.5);
  }
}

/* The pulse is carried by the rim and by the badge's halo — never by the
   words. Fading "Clicca per aprire" in and out makes the one thing the reader
   is supposed to read the one thing that keeps going away, and small
   accent-coloured type does not survive being dimmed at all. So the caption
   arrives at full strength and stays there, and the beckoning happens around
   it. */
@keyframes se-beckon-badge {
  0%, 100% { box-shadow: 0 0 0 0 rgba(219, 107, 43, 0.55); }
  70%      { box-shadow: 0 0 0 7px rgba(219, 107, 43, 0); }
  100%     { box-shadow: 0 0 0 0 rgba(219, 107, 43, 0); }
}

@media (hover: hover) {
  .spotlight-card:hover .project-preview--shot {
    animation: se-beckon 1.5s var(--ease) infinite;
  }
}
/* Il fuoco da tastiera non dipende dal puntatore e resta fuori dalla media
   query: chi naviga a tab deve vedere dove si trova anche su un touch. */
[data-scroll-expand] .project-preview--shot:focus-visible {
  animation: se-beckon 1.5s var(--ease) infinite;
}

/* Focus gets the same invitation without the blinking: a keyboard user is
   already on the control and does not need to be waved at. */
[data-scroll-expand] .project-preview--shot:focus-visible {
  animation: none;
  outline: none;
  box-shadow:
    inset 0 0 0 1px rgba(219, 107, 43, 0.9),
    0 0 0 3px rgba(219, 107, 43, 0.35);
}

/* Fades under the thumbnail so the well reads as a page continuing past the
   cut rather than an image that happens to end there — and, at the very
   bottom, so the caption sitting on it is readable over any capture at all.
   The first stops are nearly solid on purpose: a site with a pale section at
   the wrong height was leaving "Clicca per aprire" as amber type on light
   grey. */
.project-preview--shot::after {
  content: '';
  position: absolute;
  inset: auto 0 0 0;
  height: 58%;
  border-radius: inherit;
  background: linear-gradient(to top,
    rgba(5, 5, 5, 0.97) 0%,
    rgba(5, 5, 5, 0.93) 20%,
    rgba(5, 5, 5, 0.55) 46%,
    rgba(5, 5, 5, 0) 100%);
  pointer-events: none;
}

.project-preview--shot .shot-cue {
  position: absolute;
  left: 14px;
  right: 14px;
  bottom: 12px;
  display: flex;
  align-items: center;
  gap: 8px;
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.62);
  pointer-events: none;
  transition: color 0.45s var(--ease);
  /* Above the ::after fade, not under it. Both are positioned children with
     `z-index: auto`, so they paint in DOM order and a generated ::after is
     always last — which put a 95%-black gradient on top of the very caption
     it exists to make readable. The whole point of that fade is to be the
     caption's backdrop, so the caption has to be lifted out of the default
     order explicitly. */
  z-index: 1;
}
/* ===== La scritta dentro l'anteprima: spenta =====
   Il suo compito — invitare ad aprire la pagina intera — e' passato alla
   scritta "push" accanto al titolo (.project-open in css/layout.css). In fondo
   all'immagine si leggeva come didascalia della foto e non come comando, ed
   era il punto della segnalazione.

   Una regola a se' e non un `display` dentro al blocco qui sopra: quel blocco
   dichiara gia' `display: flex` piu' in basso, e nel CSS vince l'ultima
   dichiarazione — messa dentro, questa non avrebbe fatto niente. Cosi' invece
   rimetterla e' cancellare una riga, e la regola originale torna intatta.

   E FUORI dal blocco @media (hover: hover) qui sotto, dove per sbaglio era
   finita la prima volta: quella condizione e' vera su un computer e falsa su
   un telefono, e la scritta spariva solo dove c'era un mouse — cioe' l'esatto
   contrario di dove il problema era stato segnalato.

   Nascosta e non cancellata: il markup non e' toccato, e l'accessibilita' non
   ne risente perche' il comando vero e' il bottone che la contiene, con la sua
   aria-label. */
.project-preview--shot .shot-cue { display: none; }

@media (hover: hover) {
  .spotlight-card:hover .project-preview--shot .shot-cue {
    color: #ff9448;
    opacity: 1;
  }
}

/* Two labels stacked in the same slot rather than one whose text is rewritten:
   a cross-fade is what makes the swap read as the card answering the cursor,
   and neither string can shift the other's position while it does. The hover
   label is pulled out of flow and offset by the badge's width plus the gap, so
   both start on the same left edge. */
.shot-cue__txt { transition: opacity 0.32s var(--ease); }
.shot-cue__txt--hover {
  position: absolute;
  left: 24px;
  opacity: 0;
  white-space: nowrap;
}
/* Questa coppia e' il "pulsante apri che appare scrollando" della lista: la
   scritta passava da "Pagina intera" a "Clicca per aprire" a ogni card che
   scorreva sotto il dito. */
@media (hover: hover) {
  .spotlight-card:hover .shot-cue__txt--rest { opacity: 0; }
  .spotlight-card:hover .shot-cue__txt--hover { opacity: 1; }
}

/* Says "there is more page below this crop", which is the one thing the
   thumbnail cannot show on its own. */
.project-preview--shot .shot-cue::before {
  content: '↓';
  flex: 0 0 auto;
  width: 16px;
  height: 16px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  border: 1px solid currentColor;
  font-size: 9px;
  line-height: 1;
  opacity: 0.85;
  transition: background 0.32s var(--ease), color 0.32s var(--ease), border-color 0.32s var(--ease);
}
@media (hover: hover) {
.spotlight-card:hover .project-preview--shot .shot-cue::before {
  background: var(--accent);
  border-color: var(--accent);
  color: #0a0a0a;
  opacity: 1;
  /* A ring expanding out of the badge on the same beat as the rim — the
     "tap here" ripple, borrowed from .pulse-shadow on the CTA button. */
  animation: se-beckon-badge 1.5s var(--ease) infinite;
}
}

/* The glyph is the fallback the other two project cards use, kept in the
   markup so a missing screenshot lands somewhere sensible instead of on an
   empty well. js/scroll-expand.js raises .se-unavailable when neither the
   capture nor its placeholder loads. */
.project-preview--shot .preview-glyph { display: none; }
.se-unavailable .project-preview--shot {
  height: 120px;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: default;
}
.se-unavailable .project-preview--shot img,
.se-unavailable .project-preview--shot .shot-cue,
.se-unavailable .project-preview--shot::after { display: none; }
.se-unavailable .project-preview--shot .preview-glyph { display: block; }

/* While the card is out on the stage its slot stays in the grid at full size
   — the other two must not reflow into the gap — but the card itself is
   faded out by JS, and nothing in it is clickable any more.

   `transition: none` is load-bearing, not tidying: the rule below puts a
   0.44s opacity transition on every card in the grid, and this card's opacity
   is written per frame by the travel tween. Left transitioned, every one of
   those writes would be chased rather than applied and the hand-off would lag
   half a second behind the window it is supposed to be hidden by. */
.spotlight-card.is-lifted {
  pointer-events: none;
  transition: none;
}

/* ============================================================
   2. The grid clearing out
   ============================================================
   --se-slide is written per-card by js/scroll-expand.js: -1 for the cards to
   the left of the focused one, +1 for those to its right, so the row parts in
   the direction each card already sits.

   `translate` rather than `transform`, because .spotlight-card:hover already
   owns `transform` (the 6px lift) in two stylesheets and the two must not
   fight over one property — same reason .reveal and .footer use it. */
.projects-grid > .spotlight-card {
  transition:
    translate 0.62s var(--ease-scene-in),
    opacity 0.44s var(--ease),
    filter 0.44s var(--ease);
}

/* Belt to the row test in _partGrid's braces: whatever leaves the row is
   clipped at the grid's own sides rather than being allowed to widen the page.
   `clip` rather than `hidden` because this must not become a scroll container
   — and it is the only overflow value that lets the other axis stay visible,
   which the cards' shadows need. */
.projects-grid.se-parting {
  overflow-x: clip;
  overflow-y: visible;
  overflow-clip-margin: 60px;
}

.projects-grid.se-parting > .spotlight-card.is-parting {
  translate: calc(var(--se-slide, 1) * 62%) 0;
  opacity: 0;
  filter: blur(6px);
  pointer-events: none;
}

/* The section title would otherwise sit over the top edge of the stage. */
.projects .section-header {
  transition: opacity 0.44s var(--ease), translate 0.62s var(--ease-scene-in);
}
body.se-open .projects .section-header {
  opacity: 0;
  translate: 0 -28px;
  pointer-events: none;
}

/* Deliberately NO scroll lock on <body>.

   The obvious thing to do here is `body.se-open { overflow: hidden }`, and it
   is wrong twice over. Applying it to a document that is scrolled shifts the
   page under the reader — measured at ~450px on a phone — which is exactly the
   jump the stage exists to avoid, and it forces a relayout that, under mobile
   emulation, changed the fixed stage's own box mid-session and left the
   retract landing on coordinates from a different viewport.

   It is also unnecessary. The stage covers the viewport and already swallows
   every input that could scroll the page behind it: wheel (claimed in the
   capture phase), touch (`touch-action: none` below, plus the drag handler),
   and the keys that scroll (arrows, space, PageUp/Down, Home/End) in _onKey.
   In scene mode the body is not scrollable in the first place. */

/* ============================================================
   3. The stage
   ============================================================
   Appended to <body>, never inside the card: .spotlight-card carries a
   backdrop-filter, and a filter-like property makes its element the
   containing block for fixed descendants — a stage mounted inside it would
   be trapped in the card's own box instead of covering the viewport.

   z-index sits above everything the page already stacks: nav rail 90,
   footer laser 94, footer drawer 95, header 100. */
.se-stage {
  position: fixed;
  inset: 0;
  z-index: 200;
  display: none;
  /* Deliberately NOT `contain: strict`: size containment would resolve this
     box without its insets on the frame where it is unhidden. Layout and
     paint containment are the parts worth having. */
  contain: layout paint;
  /* The pan phase reads drags directly (see _onPointerDown); leaving the
     browser its own touch scrolling would fight every one of them. */
  touch-action: none;
}
.se-stage.is-mounted { display: block; }

/* Opaque, not a 96% scrim. The nav rail's labels are bright white type on
   the left edge of the viewport and they read straight through anything
   short of full coverage, which makes the stage look like a dialog laid over
   the page instead of a place of its own. */
.se-backdrop {
  position: absolute;
  inset: 0;
  background:
    radial-gradient(120% 90% at 50% 38%, rgba(28, 20, 14, 0.55), rgba(0, 0, 0, 0) 70%),
    #050505;
  opacity: 0;
  transition: opacity 0.62s var(--ease-scene-in);
}
.se-stage.is-open .se-backdrop { opacity: 1; }

/* The window. Everything about its geometry — clip-path and the corner radius
   inside it — is written from JS every frame. The value below is only what a
   half-loaded stage would show for a frame. */
.se-frame {
  position: absolute;
  inset: 0;
  clip-path: inset(21% 29% 21% 29% round 24px);
  will-change: clip-path;
  background: #0b0a09;
}

/* Base geometry, so the numbers JS writes stay simple: the element is exactly
   the stage's width, its own height follows the screenshot's aspect ratio
   (which for a full-page capture is many times the viewport), and the origin
   is its top-centre so that `translate(...) scale(z)` puts the top edge of
   the image exactly where JS asks for it at any zoom. */
.se-media {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: auto;
  transform-origin: 50% 0;
  will-change: transform;
  user-select: none;
  -webkit-user-drag: none;
}

/* Two darkening layers, pulling in opposite directions.

   The veil is the one upstream does not have, and it exists because upstream
   holds its title over a photograph while this holds it over the top of a
   real web page — headers, hero type, a bright call-to-action. At rest it
   lays a readable band under the title; by the time the frame is halfway open
   it is gone, because from there on the point is to see the page clearly. */
.se-veil {
  position: absolute;
  inset: 0;
  pointer-events: none;
  /* Weighted to the band the title occupies and kept deliberately light
     elsewhere: this frame is a look at the site, and darkening it to the point
     where the type is comfortable would leave nothing to look at. The type
     carries the rest of the contrast itself, in its shadows. */
  background: linear-gradient(180deg,
    rgba(4, 4, 4, 0.10) 0%,
    rgba(4, 4, 4, 0.50) 36%,
    rgba(4, 4, 4, 0.50) 66%,
    rgba(4, 4, 4, 0.14) 100%);
  opacity: 0;
}

/* Upstream's scrim: the other direction, fading IN as the frame opens so the
   caption at the end of the pan has something to sit on. */
.se-scrim {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: linear-gradient(to top, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0.1) 45%, rgba(0, 0, 0, 0.35));
  opacity: 0;
}

/* ============================================================
   4. Type over the stage
   ============================================================ */

/* Held over the resting window and lifted away as the media takes the screen,
   exactly as upstream. Opacity and transform are written from JS. */
.se-title {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 14px;
  padding: 0 6%;
  text-align: center;
  color: #fff;
  pointer-events: none;
  opacity: 0;
  will-change: opacity, transform;
}
.se-title h3 {
  margin: 0;
  font-family: var(--font-serif);
  font-weight: 300;
  font-size: clamp(28px, 4.6vw, 66px);
  letter-spacing: -0.02em;
  line-height: 1.02;
  /* Two shadows, not one: the tight one is what actually separates the
     letterforms from whatever is directly behind them, the wide one seats the
     whole word on the image. */
  text-shadow: 0 1px 4px rgba(0, 0, 0, 0.72), 0 2px 40px rgba(0, 0, 0, 0.8);
}
/* On its own pill rather than bare on the image. Small accent-coloured type
   is the first thing a screenshot underneath destroys — the placeholder puts a
   pale header bar exactly where this line sits — and a shadow is not enough to
   save 11px letters at that weight. The pill works over any capture. */
.se-title .se-eyebrow {
  padding: 6px 14px;
  border-radius: 999px;
  background: rgba(8, 6, 5, 0.62);
  border: 1px solid rgba(255, 255, 255, 0.10);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--accent);
}

.se-hint {
  position: absolute;
  left: 0;
  right: 0;
  bottom: clamp(26px, 5vh, 58px);
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 10px;
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.55);
  pointer-events: none;
  will-change: opacity, transform;
}
.se-hint::after {
  content: '';
  width: 18px;
  height: 26px;
  border-radius: 10px;
  border: 1px solid rgba(255, 255, 255, 0.35);
  background: linear-gradient(currentColor, currentColor) center 6px/2px 5px no-repeat;
  animation: se-wheel-nudge 1.8s var(--ease) infinite;
}
@keyframes se-wheel-nudge {
  0%, 100% { background-position-y: 6px; opacity: 1; }
  50%      { background-position-y: 12px; opacity: 0.45; }
}

/* Upstream's `children`: the caption that arrives once the pan reaches the
   foot of the page. */
.se-overlay {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 18px;
  padding: 0 6% clamp(48px, 9vh, 110px);
  text-align: center;
  opacity: 0;
  will-change: opacity, transform;
}
.se-overlay p {
  max-width: 62ch;
  margin: 0;
  font-size: 15px;
  font-weight: 300;
  color: rgba(255, 255, 255, 0.72);
}
.se-overlay .tag-row { justify-content: center; margin: 0; }

/* ============================================================
   5. Chrome: progress rail and close
   ============================================================
   A full-page screenshot is a long pan, and without a read-out there is no
   way to tell how much of the page is left. */
.se-progress {
  position: absolute;
  right: clamp(16px, 2.4vw, 34px);
  top: 50%;
  translate: 0 -50%;
  width: 3px;
  height: min(300px, 34vh);
  border-radius: 3px;
  /* Dark track plus a soft dark halo: at full bleed this rail sits directly
     on the screenshot, which for most sites is a light background. */
  background: rgba(0, 0, 0, 0.45);
  box-shadow: 0 0 0 5px rgba(0, 0, 0, 0.22);
  opacity: 0;
  transition: opacity 0.4s var(--ease);
  pointer-events: none;
}
.se-stage.is-panning .se-progress { opacity: 1; }
.se-progress i {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  height: 18%;
  border-radius: inherit;
  background: var(--accent);
  transform-origin: 50% 0;
}

/* The close control is the nav rail's HUD chip, not a ✕ in a circle.

   Everything it is made of — the hexagonal socket glow, the bevelled rim, the
   raised glass face — already exists in css/option-wheel.css for the audio
   toggle, and js/hud-3d.js already knows how to bank a .hud-plate toward the
   pointer. So this rule set is only the two things that are actually local to
   this button: where it sits, and the perspective its 3D is drawn in. The
   chip itself is shared, which is also why it cannot drift out of style with
   the one in the rail. */
.se-close {
  position: absolute;
  top: clamp(16px, 2.6vh, 30px);
  right: clamp(16px, 2.4vw, 34px);
  width: 52px;
  height: 52px;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
  color: var(--accent);
  perspective: 460px;
  -webkit-tap-highlight-color: transparent;
  opacity: 0;
  transition: opacity 0.4s var(--ease);
}
.se-stage.is-open .se-close { opacity: 1; }
.se-close:hover { color: #ffb27a; }

.se-close .hud-x {
  width: 18px;
  height: 18px;
  /* The equaliser bars in the rail carry a `box-shadow` glow; an SVG stroke
     needs drop-shadow to get the same lit-from-within look. */
  filter: drop-shadow(0 0 7px currentColor);
}

.se-close:focus-visible { outline: none; }
.se-close:focus-visible .hud-plate__edge {
  background: var(--accent);
  opacity: 1;
}

/* Sits under the close button and says what ends this. */
.se-esc {
  position: absolute;
  top: calc(clamp(16px, 2.6vh, 30px) + 58px);
  right: clamp(16px, 2.4vw, 34px);
  font-family: var(--font-mono);
  font-size: 9px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.55);
  text-shadow: 0 1px 8px rgba(0, 0, 0, 0.95), 0 0 2px rgba(0, 0, 0, 0.8);
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.4s var(--ease);
}
.se-stage.is-open .se-esc { opacity: 1; }

/* ============================================================
   6. Fallbacks
   ============================================================ */

/* Nothing here animates itself, so reduced motion only needs the two
   decorative loops silenced — js/scroll-expand.js drops the travel and the
   easing on its own. */
@media (prefers-reduced-motion: reduce) {
  .se-hint::after { animation: none; }
  /* The invitation still has to be legible without flashing, so the pulse
     collapses to the lit end of its own cycle rather than switching off. */
  @media (hover: hover) {
    .spotlight-card:hover .project-preview--shot {
      animation: none;
      box-shadow:
        inset 0 0 0 1px rgba(219, 107, 43, 0.85),
        0 0 26px -3px rgba(219, 107, 43, 0.5);
    }
    .spotlight-card:hover .project-preview--shot .shot-cue::before { animation: none; }
  }
  .shot-cue__txt { transition: none; }
  .project-preview--shot img { transition: none; }
  .projects-grid > .spotlight-card,
  .projects .section-header { transition: none; }
}

/* Under the 3-column breakpoint the cards are already stacked, so there is no
   row to part — the stage still opens, it just does it from wherever the card
   happens to be. */
@media (max-width: 980px) {
  .project-preview--shot { height: 220px; }
  .se-progress { right: 10px; }
  /* The portrait resting frame is ~82% of a phone's width; the eyebrow has to
     fit inside it rather than run out past both edges. */
  .se-title .se-eyebrow {
    font-size: 9.5px;
    letter-spacing: 0.16em;
    padding: 5px 11px;
  }
  .se-hint { font-size: 10px; letter-spacing: 0.14em; }
}
