/* ==========================================================================
   Purple Door Security — The Portal
   A 3D door in CSS. Parallax tilt on pointer/touch, light seeping around the
   frame, volumetric rays, a polished black-marble floor, and a swing-open
   transition that walks the camera through the doorway.

   3D contract (do not break):
     .scene      owns `perspective`  — the camera
     .stage      owns `preserve-3d`  — the world, tilted by --tilt-x/--tilt-y
     .doorway    owns `preserve-3d`  — the opening
     .door       owns `preserve-3d`  — hinged on its left edge
   Never put `filter`, `opacity < 1`, `mask` or `mix-blend-mode` on those four:
   any of them silently flattens the 3D context.

   NO mix-blend-mode ANYWHERE inside .stage — not just on the four. Blended
   layers inside preserve-3d get regrouped when the compositor takes over
   after first paint, and their backdrop changes: the crack glow and the
   floor reflection visibly faded on refresh (confirmed by before/after
   screenshots, 2026-07-19). Over this scene's near-black backdrops, plain
   painting of light colors is visually equivalent to screen — and stable.
   Layers that sit over bright backdrops carry ~15% higher alphas instead.

   PERFORMANCE: `filter: blur()` is used sparingly and only on modest
   elements, never a viewport-sized layer. Keep the atmosphere gradient-led;
   blur only softens edges. Only `opacity` and `transform` are ever animated.
   ========================================================================== */

.portal {
  position: relative;
  min-height: 100svh;
  display: grid;
  place-items: center;
  overflow: hidden;
  isolation: isolate;
  background:
    radial-gradient(120% 70% at 50% 34%, rgba(45, 18, 89, 0.5), transparent 66%),
    var(--pd-void);

  /* Tunables driven by JS + state classes */
  --door-w: clamp(190px, 30vmin, 330px);
  --door-h: calc(var(--door-w) * 2.15);
  --door-thickness: 18px;
  --door-angle: 0deg;
  /* Unitless on purpose. JS writes plain numbers and each consumer scales
     them into its own units — the stage into degrees, the door sheen / handle
     glint / floor hotspot into pixels. (calc cannot convert deg→px, so typed
     angle values here would lock the tilt to a single consumer.) */
  --tilt-x: 0;
  --tilt-y: 0;
  --zoom: 0px;
  --seep: 1; /* light intensity multiplier */
  --ray-reach: 1; /* how far the shafts throw */
}

/* --- Camera -------------------------------------------------------------- */
.scene {
  position: relative;
  z-index: 2;
  width: 100%;
  height: 100svh;
  display: grid;
  place-items: center;
  perspective: 1400px;
  perspective-origin: 50% 44%;
}

.stage {
  position: relative;
  transform-style: preserve-3d;
  transform: translateZ(var(--zoom)) rotateX(calc(var(--tilt-x) * 1deg))
    rotateY(calc(var(--tilt-y) * 1deg));
  transition: transform var(--pd-dur-camera) var(--pd-ease-camera) var(--pd-delay-camera);
  will-change: transform;
}

/* While the pointer is driving the tilt, JS removes the transition so the
   door tracks the cursor 1:1 instead of lagging behind it. JS is careful to
   drop this class before opening, or the camera push would snap. */
.stage.is-tracking {
  transition: none;
}

/* --- The opening --------------------------------------------------------- */
.doorway {
  position: relative;
  width: var(--door-w);
  height: var(--door-h);
  transform-style: preserve-3d;
  border-radius: 4px 4px 0 0;

  /* This is the light behind the door. The door panel sits on top inset by a
     few px, so all that escapes is a sliver around the edges. */
  /* The hotspot used to sit at 50% 76% — low and bottom-weighted — so the
     gradient had already fallen to its darkest stop by the time it reached the
     top edge. That is exactly where the crack shows, which is why the top of
     the gap read as unlit while the halo and shafts around it stayed at full
     strength. Centred higher and with a shorter dark tail, the perimeter is
     now lit consistently the whole way round. */
  background:
    /* Concealed-hinge tabs: dark interruptions in the hinge-side sliver at
       real mortise heights. Background layers, not boxes — pseudo-elements
       here are coplanar with the door and depth-tie against it (that bug
       shipped once). No visible hinge hardware on purpose: exposed barrels
       on the secure side are a pull-the-pins defect. */
    linear-gradient(90deg, #140a26, #1c0f33 55%, rgba(28, 15, 51, 0)) 0 7.4% / 11px 5%
      no-repeat,
    linear-gradient(90deg, #140a26, #1c0f33 55%, rgba(28, 15, 51, 0)) 0 87% / 11px 5%
      no-repeat,
    /* Threshold saddle: a metal sill across the opening's base, bright lip
       up top where the light grazes it. The door's 6px bottom gap leaves
       its lower half visible with light spilling over. */
    linear-gradient(
        180deg,
        rgba(var(--pd-light-300-rgb), 0.85) 0% 20%,
        #b98fe0 34%,
        #7a54a8 58%,
        #472a6e 100%
      )
      0 100% / 100% 9px no-repeat,
    radial-gradient(
      132% 84% at 50% 58%,
      var(--pd-light-300),
      var(--pd-light-400) 22%,
      var(--pd-light-500) 44%,
      var(--pd-light-mid) 80%,
      var(--pd-purple-500) 100%
    );
  box-shadow:
    /* Relights the perimeter so the sliver is even top-to-bottom rather than
       inheriting the gradient's falloff. Zero spread deliberately: any spread
       here pushes a solid-alpha band inward before the blur starts, which
       draws a visible line just inside the gap instead of a soft bleed. */
    inset 0 0 calc(22px * var(--seep)) 0 rgba(var(--pd-light-300-rgb), 0.5),
    0 0 calc(46px * var(--seep)) 0 rgba(var(--pd-light-500-rgb), 0.24),
    0 0 calc(150px * var(--seep)) calc(10px * var(--seep)) rgba(190, 120, 224, 0.1);
  /* Door-synced: this is the CLOSING clock. Transitions read their timing
     from the destination state, so this base value governs the return to
     closed — the glow must die with the swing, not the camera. The opening
     direction is re-timed to the camera in the is-open block. */
  transition: box-shadow var(--pd-dur-door) var(--pd-ease-door);
}

/* Frame / architrave around the opening — three sides only. Casings do not
   run under a door: the jambs terminate at the floor and the opening gets a
   threshold saddle instead (painted into the doorway's background). The
   frame used to wrap the bottom too, which read as the frame sinking into
   the marble. */
.doorway__frame {
  position: absolute;
  inset: calc(var(--door-thickness) * -1) calc(var(--door-thickness) * -1) 0;
  border-radius: 8px 8px 0 0;
  background: linear-gradient(155deg, var(--pd-purple-800), var(--pd-purple-900) 60%, #120726);
  box-shadow:
    inset 0 0 0 1px rgba(var(--pd-purple-400-rgb), 0.22),
    inset 0 2px 0 rgba(var(--pd-purple-300-rgb), 0.14),
    var(--pd-shadow-lg);
  transform: translateZ(calc(var(--door-thickness) * -0.5));
}

/* The room beyond: recedes away from the viewer, so pushing the camera
   forward actually travels into something. */
.doorway__depth {
  position: absolute;
  inset: 0;
  transform: translateZ(-140px) scale(0.9);
  background: radial-gradient(
    100% 62% at 50% 72%,
    var(--pd-light-400),
    var(--pd-light-500) 40%,
    rgba(168, 95, 208, 0.85) 76%,
    rgba(78, 26, 107, 0.92) 100%
  );
  transition: transform var(--pd-dur-camera) var(--pd-ease-camera) var(--pd-delay-camera);
}

/*
 * The dimmer.
 *
 * Closed, the room beyond reads bright because it is small and surrounded by
 * near-black. Once the camera pushes through, that same gradient covers the
 * whole viewport — and a full screen of light-400 behind a form is genuinely
 * unpleasant, especially for anyone who opened this at night. So the room
 * dims as we arrive in it: the light stays *pretty* rather than staying
 * *bright*, and the contact panel lands on a comfortable dark surface.
 */
.doorway__depth::after {
  content: "";
  position: absolute;
  inset: 0;
  background: radial-gradient(
    100% 70% at 50% 60%,
    rgba(var(--pd-purple-900-rgb), 0.72),
    rgba(8, 6, 15, 0.92) 100%
  );
  opacity: 0;
  transition: opacity var(--pd-dur-camera) var(--pd-ease-camera) var(--pd-delay-camera);
}

/*
 * The crack.
 *
 * Sits between the frame (translateZ -9px) and the door (0), so its outward
 * bloom paints *onto the architrave* while the door still covers its middle —
 * leaving only the sliver lit.
 *
 * The inset shadows exist because the doorway's own radial gradient is bright
 * at its centre and falls off to Far Room at the edges — exactly where the
 * sliver shows. Without this the crack was rendering from the darkest stops of
 * the light source, which is why it read as a dull outline rather than a gap
 * with something luminous behind it.
 */
/*
 * Two blurred copies of the opening, at different scales.
 *
 * This is the third attempt at the halo and the first one built on the right
 * primitive. box-shadow with a spread produces a *band* — it has a defined
 * inner boundary and terminates rather than decaying, which is why it read as
 * a solid line drawn around the frame. Blurring a filled rectangle instead
 * gives a true gaussian falloff from every edge, which is what light actually
 * does.
 *
 * Two layers because real bloom is multi-scale: a tight, bright core hugging
 * the gap, plus a broad, faint wash that carries much further out. One layer
 * alone reads as either a hard rim or a vague fog.
 *
 * Vertical grading lives in each layer's gradient rather than in a mask — the
 * blur then smooths the grading too, so there is no seam anywhere. The bottom
 * stays restrained because the marble floor is already throwing a reflection
 * there.
 */
.doorway__seep {
  position: absolute;
  inset: 0;
  transform: translateZ(-4px);
  pointer-events: none;
  /* NO mix-blend-mode, deliberately — see the header's 3D contract. Blended
     layers inside preserve-3d get regrouped when compositing kicks in after
     first paint, and the crack glow visibly faded on refresh (confirmed by
     Ryan's before/after screenshots). Alphas below are compensated ~15%
     upward for the loss of screen's compounding over the bright doorway. */
  opacity: calc(0.9 * var(--seep));
  /* Door-synced for closing; camera-timed for opening (is-open block). */
  transition: opacity var(--pd-dur-door) var(--pd-ease-door);
}

.doorway__seep::before,
.doorway__seep::after {
  content: "";
  position: absolute;
  border-radius: 5px 5px 0 0;
}

/* Tight core: bright, close to the gap. */
.doorway__seep::before {
  inset: -3px;
  background: linear-gradient(
    to bottom,
    rgba(var(--pd-light-300-rgb), 0.72) 0%,
    rgba(var(--pd-light-400-rgb), 0.58) 48%,
    rgba(var(--pd-light-500-rgb), 0.33) 78%,
    rgba(var(--pd-light-500-rgb), 0.17) 100%
  );
  filter: blur(13px);
}

/* Broad wash: faint, reaches well across the architrave and beyond. */
.doorway__seep::after {
  inset: -12px;
  background: linear-gradient(
    to bottom,
    rgba(var(--pd-light-400-rgb), 0.35) 0%,
    rgba(var(--pd-light-500-rgb), 0.26) 50%,
    rgba(var(--pd-light-500-rgb), 0.12) 80%,
    rgba(var(--pd-light-500-rgb), 0.05) 100%
  );
  filter: blur(44px);
}

/* --- Volumetric shafts --------------------------------------------------- */
/*
 * Two rules make this read as light from BEHIND the door:
 *
 * 1. The shafts sit at negative Z — behind the door panel, not in front of it.
 *    Drawn in front, they painted *over* the door face and washed it out, which
 *    reads as a spotlight pointed at the door from the room you're standing in.
 *
 * 2. The mask punches a door-shaped hole in the middle. Depth-sorting alone is
 *    not trustworthy here — a blended, flattened layer inside a preserve-3d
 *    subtree is exactly the case browsers disagree about — so the geometry is
 *    made explicit rather than left to the compositor. Light can only ever
 *    appear *around* the door's silhouette, never across it.
 *
 * The radiance itself is one repeating-conic-gradient rather than a handful of
 * discrete blades. Ten rotated elements left obvious wedges of nothing between
 * them; a conic sweep is continuous by construction, so there is no gap to see.
 */
.rays {
  position: absolute;
  inset-block-start: 50%;
  inset-inline-start: 50%;
  width: calc(var(--door-w) * 7.2);
  height: calc(var(--door-w) * 7.2);
  margin-inline-start: calc(var(--door-w) * -3.6);
  margin-block-start: calc(var(--door-w) * -3.6);
  transform: translateZ(-30px) scale(var(--ray-reach));
  /* Door-synced for closing; camera-timed for opening (is-open block). */
  transition: transform var(--pd-dur-door) var(--pd-ease-door),
    opacity var(--pd-dur-door) var(--pd-ease-door);
  pointer-events: none;
  /* No blend (see 3D contract): over the near-black room, plain painting of
     these light colors is visually equivalent to screen — and stable. */
  opacity: calc(0.6 * var(--seep));

  /* The masks live on THIS element, not on the rotating ::before. A mask is
     applied in the element's own coordinate space, so on the ::before it spun
     with the shafts — a quarter of the way into the 120s turn, the portrait
     door-cutout was lying on its side: light leaked straight across the door
     panel while a band of empty air beside it sat masked. Parent-level, the
     hole stays glued to the door while the shafts turn beneath it.

     Layer 1 punches out the door; layer 2 fades the throw with distance —
     full strength only in the first ~15% of the radius, then a long dissolve
     to nothing by 76%, so shafts are brightest where they leave the gap and
     gone well before the container's edge. Intersecting means both must pass. */
  mask-image: radial-gradient(
      ellipse 8.6% 18% at 50% 50%,
      transparent 52%,
      #000 100%
    ),
    radial-gradient(
      closest-side,
      #000 15%,
      rgba(0, 0, 0, 0.55) 34%,
      rgba(0, 0, 0, 0.24) 52%,
      rgba(0, 0, 0, 0.08) 66%,
      transparent 76%
    );
  mask-composite: intersect;
  -webkit-mask-composite: source-in;
}

.rays::before,
.rays::after {
  content: "";
  position: absolute;
  inset: 0;
}

/* The shafts. No mask here — it would rotate with the animation (see parent). */
.rays::before {
  background: repeating-conic-gradient(
    from 0deg at 50% 50%,
    rgba(var(--pd-light-300-rgb), 0.3) 0deg 1.2deg,
    transparent 1.2deg 3.6deg,
    rgba(var(--pd-light-500-rgb), 0.2) 3.6deg 4.6deg,
    transparent 4.6deg 6.4deg,
    rgba(var(--pd-light-400-rgb), 0.13) 6.4deg 7.2deg,
    transparent 7.2deg 10deg
  );
  /* Softer than before: the shafts should lose definition as they spread, and
     a wider blur on a wider container reads as atmospheric diffusion. */
  filter: blur(11px);
  animation: rays-turn 120s linear infinite;
}

/* Bloom around the frame, so the shafts emerge from a source rather than
   starting in mid-air. The parent's masks carve the door hole and the distance
   falloff for this layer too. */
.rays::after {
  background: radial-gradient(
    closest-side,
    rgba(var(--pd-light-400-rgb), 0.22),
    rgba(var(--pd-light-500-rgb), 0.1) 38%,
    transparent 70%
  );
  filter: blur(18px);
  animation: ray-breathe 9s ease-in-out infinite alternate;
}

@keyframes rays-turn {
  to { transform: rotate(360deg); }
}

@keyframes ray-breathe {
  from { opacity: 0.6; }
  to   { opacity: 1; }
}

/* Haze in the air, so the shafts have something to be visible *in*. Also
   behind the door — atmosphere in front of it just greys the panel out. */
.fog {
  position: absolute;
  inset-block-start: -40%;
  inset-inline-start: 50%;
  width: calc(var(--door-w) * 5);
  height: calc(var(--door-h) * 2.2);
  margin-inline-start: calc(var(--door-w) * -2.5);
  transform: translateZ(-45px);
  pointer-events: none;
  opacity: calc(0.45 * var(--seep));
  /* Previously untransitioned — the haze snapped when --seep flipped.
     Door-synced for closing; camera-timed for opening (is-open block).
     Opacity only: the transform is owned by the fog-roll animation. */
  transition: opacity var(--pd-dur-door) var(--pd-ease-door);
  background:
    radial-gradient(38% 30% at 34% 62%, rgba(var(--pd-light-500-rgb), 0.09), transparent 72%),
    radial-gradient(46% 36% at 70% 44%, rgba(var(--pd-purple-300-rgb), 0.07), transparent 74%),
    radial-gradient(60% 44% at 50% 74%, rgba(190, 120, 224, 0.06), transparent 76%);
  animation: fog-roll 24s ease-in-out infinite alternate;
}

@keyframes fog-roll {
  from { transform: translateZ(-45px) translate3d(-2.5%, 1.5%, 0) scale(1); }
  to   { transform: translateZ(-45px) translate3d(3.5%, -2%, 0) scale(1.08); }
}

/* --- The door ------------------------------------------------------------ */
.door {
  position: absolute;
  /* Inset leaves the sliver of light escaping around all four edges — the
     6px bottom gap is the classic light-under-the-door, spilling over the
     threshold saddle painted into the doorway's background. */
  inset: 4px 5px 6px 6px;
  transform-origin: left center;
  transform: rotateY(var(--door-angle));
  transform-style: preserve-3d;
  transition: transform var(--pd-dur-door) var(--pd-ease-door);
  will-change: transform;
}

.door__face {
  position: absolute;
  inset: 0;
  backface-visibility: hidden;
  border-radius: 3px 3px 0 0;
  overflow: hidden; /* clips the moving sheen to the panel */
  background: linear-gradient(
      168deg,
      var(--pd-purple-600) 0%,
      var(--pd-purple-500) 34%,
      var(--pd-purple-700) 78%,
      var(--pd-purple-800) 100%
    );
  box-shadow:
    inset 0 0 0 1px rgba(var(--pd-purple-300-rgb), 0.18),
    inset 0 1px 0 rgba(var(--pd-purple-200-rgb), 0.22),
    inset -14px 0 32px -18px rgba(8, 6, 15, 0.9),
    var(--pd-shadow-md);
}

/*
 * Tilt-reactive sheen.
 *
 * The JS already writes --tilt-x/--tilt-y every frame for the stage rotation;
 * this reuses those same numbers, so the effect costs nothing it wasn't
 * already paying. A soft diagonal band slides *against* the tilt — fixed
 * light, moving object — which is what makes lacquered material read as
 * material instead of a printed picture of a door.
 *
 * ::before paints beneath the panels and handle, so the recesses interrupt
 * the sheen the way real mouldings interrupt a glancing light.
 *
 * Deliberately no transition on translate here: the sheen must track the
 * tilt 1:1, exactly like the stage does. The swing transition below is
 * scoped to the open state only.
 */
.door__face::before {
  content: "";
  position: absolute;
  inset: -12%;
  background: linear-gradient(
    115deg,
    transparent 34%,
    rgba(var(--pd-purple-200-rgb), 0.06) 45%,
    rgba(var(--pd-light-300-rgb), 0.12) 50%,
    rgba(var(--pd-purple-200-rgb), 0.06) 55%,
    transparent 66%
  );
  translate: calc(var(--tilt-y) * -2.6px) calc(var(--tilt-x) * 2px);
  will-change: translate;
  pointer-events: none;
}

/* As the door swings open, the sheen rakes across the face and exits on the
   handle side — the glossy panel catching the room's light while it moves. */
.portal.is-open .door__face::before {
  translate: 130% 0;
  transition: translate var(--pd-dur-door) var(--pd-ease-door);
}

/* Warm rim-light picked up along both gaps from the light behind */
.door__face::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: linear-gradient(
    90deg,
    rgba(var(--pd-light-500-rgb), calc(0.16 * var(--seep))) 0%,
    transparent 20%,
    transparent 87%,
    rgba(var(--pd-light-500-rgb), calc(0.12 * var(--seep))) 100%
  );
  pointer-events: none;
}

.door__back {
  position: absolute;
  inset: 0;
  backface-visibility: hidden;
  transform: rotateY(180deg) translateZ(1px);
  border-radius: 3px 3px 0 0;
  /* The back is lit head-on by the threshold light */
  background: linear-gradient(
    100deg,
    #c98fe0 0%,
    var(--pd-purple-700) 26%,
    var(--pd-purple-800) 100%
  );
  box-shadow: inset 0 0 0 1px rgba(var(--pd-light-500-rgb), 0.16);
}

/* Leading edge — gives the panel real thickness as it swings */
.door__edge {
  position: absolute;
  inset-block: 0;
  inset-inline-end: 0;
  width: var(--door-thickness);
  transform-origin: right center;
  transform: rotateY(-90deg);
  background: linear-gradient(180deg, var(--pd-purple-700), var(--pd-purple-900));
  box-shadow: inset 0 0 0 1px rgba(var(--pd-purple-300-rgb), 0.12);
}

/* Recessed panels */
.door__panel {
  position: absolute;
  inset-inline: 12%;
  border-radius: 2px;
  background: linear-gradient(160deg, rgba(8, 6, 15, 0.26), rgba(var(--pd-purple-200-rgb), 0.05));
  box-shadow:
    inset 0 0 0 1px rgba(8, 6, 15, 0.35),
    inset 0 1px 0 rgba(var(--pd-purple-200-rgb), 0.16),
    0 1px 0 rgba(var(--pd-purple-200-rgb), 0.07);
}

.door__panel--top    { inset-block: 7% 46%; }
.door__panel--bottom { inset-block: 58% 8%; }

/* Handle + backplate */
.door__handle {
  position: absolute;
  inset-inline-end: 11%;
  inset-block-start: 50%;
  width: 9%;
  min-width: 13px;
  aspect-ratio: 1 / 2.6;
  transform: translateY(-50%);
  border-radius: var(--pd-radius-pill);
  overflow: hidden; /* keeps the moving glint inside the pill */
  background: linear-gradient(150deg, var(--pd-light-300), var(--pd-light-500) 45%, #9a5fc4);
  box-shadow:
    0 0 12px rgba(var(--pd-light-500-rgb), 0.45),
    inset 0 -1px 2px rgba(78, 26, 107, 0.6);
}

/* A specular glint that wanders slightly with the tilt — the single brightest
   touch on the door, so even a small drift reads. Tiny element; no layer
   promotion needed. */
.door__handle::after {
  content: "";
  position: absolute;
  inset: 6% 16%;
  border-radius: inherit;
  background: radial-gradient(
    60% 36% at 50% 20%,
    rgba(var(--pd-light-300-rgb), 0.65),
    transparent 72%
  );
  translate: calc(var(--tilt-y) * -0.3px) calc(var(--tilt-x) * 0.35px);
}

/* Kick plate with the brand sliver */
.door__plate {
  position: absolute;
  inset-inline: 22%;
  inset-block-end: 3%;
  height: 3%;
  border-radius: 1px;
  background: linear-gradient(90deg, transparent, rgba(var(--pd-light-500-rgb), 0.4), transparent);
}

/* ==========================================================================
   Polished black marble floor
   A plane hinged at the base of the doorway and laid down toward the viewer.
   Everything on it is a gradient; the "reflection" is a mirrored smear of the
   door and its light, foreshortened for free by the 3D transform.
   ========================================================================== */
.floor {
  position: absolute;
  inset-block-start: var(--door-h);
  inset-inline-start: 50%;
  width: calc(var(--door-w) * 9);
  height: calc(var(--door-h) * 2.6);
  margin-inline-start: calc(var(--door-w) * -4.5);
  /* The door hinge's x-position on this plane: the doorway's left edge.
     Floor is 9 door-widths wide, doorway centred → 50% − (0.5/9). The light
     sweep anchors and rotates here. Recompute if the width ratio changes
     (the mobile block does). */
  --hinge-x: 44.4%;
  transform-origin: 50% 0;
  /* POSITIVE 79. With origin at the doorway base, rotateX(79deg) lays the
     plane down TOWARD the viewer, sweeping under the camera and filling the
     lower viewport. rotateX(-79deg) — which shipped with the first marble
     build and survived five rounds of "no marble in the foreground" — tips
     the plane AWAY instead, compressing the whole slab into a ~150px shelf
     receding behind the door, whose far edge was the recurring "hard line".
     If the floor ever reads as a narrow band again, check this sign first. */
  transform: rotateX(79deg);
  pointer-events: none;
  overflow: hidden;
  /* Dissolve the slab's silhouette. The plane is a rectangle, and its edges
     were drawing hard lines against the void — most visibly the horizon line
     running left and right of the door. An ellipse anchored at the doorway
     base keeps the stone solid where the light lives and melts it into the
     background with distance. Tall vertical radius (160%) so the foreground
     (viewer end) survives. Masks are pure alpha — depth-stable, unlike the
     blend modes this scene banned. */
  mask-image: radial-gradient(
    ellipse 58% 160% at 50% 0%,
    #000 42%,
    rgba(0, 0, 0, 0.55) 66%,
    transparent 92%
  );

  /* Polished stone. Shine is CONTRAST, not lightness: the base is kept
     near-black and the broad diffuse washes minimal, so the tight speculars —
     the mirrored door streak, the wandering hotspot, the polish breaks —
     carry the gloss. Brightening these broad layers makes the slab read as
     grey stone, not lit marble (it shipped that way once). */
  background:
    radial-gradient(
      42% 34% at 50% 0%,
      rgba(var(--pd-light-500-rgb), calc(0.035 * var(--seep))),
      transparent 72%
    ),
    /* Foreground ambient: a whisper of purple at the viewer end, so the
       stone separates from the void instead of dissolving into it. The
       marble must exist in FRONT of the door, not just behind it. */
    radial-gradient(90% 42% at 50% 100%, rgba(var(--pd-purple-500-rgb), 0.05), transparent 72%),
    radial-gradient(70% 50% at 50% 0%, #110c1b 0%, #0a0713 45%, #070510 78%, #05040c 100%);
  box-shadow: inset 0 40px 80px -40px rgba(var(--pd-light-500-rgb), calc(0.07 * var(--seep)));
}

/*
 * Light sweep: the blade of light the opening door throws across the marble.
 *
 * The geometry matters. The wedge's moving boundary is the door-edge shadow,
 * and the door rotates about its HINGE — so the wedge is anchored at the
 * hinge's floor point (--hinge-x, the doorway's left edge on this plane), not
 * the doorway centre. A centre-anchored scale reads as curtains parting; this
 * reads as a swinging panel. Closed, the band is rotated to hug the jamb line
 * and faded out; open, it rotates into place — the blade emerges at the
 * handle side and wipes leftward across the floor, exactly the path of the
 * panel's shadow (hinge left, swinging inward).
 *
 * Timing is the DOOR's — same duration, same easing, both directions — so the
 * light moves in lockstep with the panel and dies the instant it seats.
 * Transform + opacity only; the band is one rigid conic, rotated.
 *
 * ::before paints beneath the veins and polish, so the stone's texture
 * interrupts the light the way it should.
 */
.floor::before {
  content: "";
  position: absolute;
  inset: 0;
  transform-origin: var(--hinge-x) 0;
  transform: rotate(-26deg);
  opacity: 0;
  background: conic-gradient(
    from 168deg at var(--hinge-x) 0%,
    transparent 0deg,
    rgba(var(--pd-light-400-rgb), 0.16) 8deg,
    rgba(var(--pd-light-300-rgb), 0.3) 14deg,
    rgba(var(--pd-light-400-rgb), 0.16) 20deg,
    transparent 28deg
  );
  mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.9), transparent 76%);
  transition: opacity var(--pd-dur-door) var(--pd-ease-door),
    transform var(--pd-dur-door) var(--pd-ease-door);
}

/* Specular hotspot that slides against the tilt — the polished stone's answer
   to the door sheen. Reuses the per-frame tilt vars; transform-only, so no
   repaint of the (large) floor. */
.floor::after {
  content: "";
  position: absolute;
  inset: 0 14% 46% 14%;
  background: radial-gradient(
    50% 58% at 50% 12%,
    rgba(var(--pd-light-400-rgb), 0.06),
    transparent 72%
  );
  translate: calc(var(--tilt-y) * -5px) 0;
  will-change: translate;
  z-index: 2;
}

/*
 * Veining. An SVG turbulence texture embedded as a data URI — still one CSS
 * file, no JS, no request. Perlin turbulence is what marble actually needs:
 * repeating linear gradients at fixed intervals (the first approach) read as
 * a mechanical grid, because they are one.
 *
 * HOW THE VEINS ARE EXTRACTED — this went wrong once: thresholding the noise
 * ("show where value > t", a linear feColorMatrix) can only ever produce the
 * REGION above a level of a continuous field, which is an island — that build
 * rendered as white blotches. Veins are the field's ISOLINES: a narrow
 * band-pass around one level, traced by the feComponentTransfer table (all
 * zeros, single peak).
 *
 * POLARITY — this went wrong once too: the veins were first painted as pale
 * additive lines, which read as electric-white lightning announcing itself
 * across the whole slab. Black marble is the inverse: the veins are DARK, and
 * you only discover them where the gloss pools. So the vein/cloud tile is
 * painted in near-black plum and stacked ABOVE the light layers (reflection,
 * hotspot, sweep — see the z-index ladder on this element and its siblings):
 * the marks occlude the speculars rather than adding light. In unlit areas
 * they vanish into the stone, which is correct.
 *
 * Tuning: the peak's POSITION in the table sets which contour is traced
 * (off-median = sparser), table LENGTH sets vein thickness (more entries =
 * finer), peak VALUE × this element's opacity sets how deeply veins cut the
 * gloss. The 'c' filter is a broad dark mottle for clouded-stone variation.
 *
 * baseFrequency is anisotropic (x > y) so the grain elongates along the
 * floor's depth axis and the 3D foreshortening compresses it naturally; the
 * slight rotation keeps veins off-axis. stitchTiles + the explicit 720px
 * filter region make the tile seamless. Rasterized once at load, cached as an
 * ordinary background tile.
 */
.floor__veins {
  position: absolute;
  inset: 0;
  /* Above the reflection and hotspot (see ladder below) so the dark marks cut
     into the light pools. 0.55 URI peak × this ≈ 0.31 occlusion depth. Dial
     THIS, not the URI, for vein strength. */
  opacity: 0.56;
  z-index: 3;
  transform: rotate(-5deg) scale(1.18);
  --marble: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='720' height='720'><filter id='c' x='0' y='0' width='720' height='720' filterUnits='userSpaceOnUse'><feTurbulence type='fractalNoise' baseFrequency='0.005 0.0035' numOctaves='3' seed='4' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.016 0 0 0 0 0.012 0 0 0 0 0.027 0 0 0 0.4 -0.1'/></filter><filter id='v' x='0' y='0' width='720' height='720' filterUnits='userSpaceOnUse'><feTurbulence type='fractalNoise' baseFrequency='0.011 0.006' numOctaves='5' seed='7' stitchTiles='stitch'/><feComponentTransfer><feFuncA type='table' tableValues='0 0 0 0 0 0 0 0 0 0 0 0 0 0.55 0 0 0 0 0 0 0 0 0 0 0'/></feComponentTransfer><feColorMatrix values='0 0 0 0 0.016 0 0 0 0 0.012 0 0 0 0 0.027 0 0 0 1 0'/></filter><rect width='720' height='720' filter='url(%23c)'/><rect width='720' height='720' filter='url(%23v)'/></svg>");
  background-image: var(--marble);
  background-size: 720px 720px;
  /* Strongest where the light pools at the door, but they persist all the
     way to the viewer end at reduced strength — cutting them off at 62%, as
     this mask used to, is why the foreground read as void instead of stone.
     Foreshortening magnifies the near-field tile for free, so foreground
     veins are naturally broader and softer. */
  mask-image: linear-gradient(
    180deg,
    rgba(0, 0, 0, 0.95) 0%,
    rgba(0, 0, 0, 0.55) 45%,
    rgba(0, 0, 0, 0.3) 75%,
    rgba(0, 0, 0, 0.18) 100%
  );
}

/* The door's reflection: a mirrored band directly below the opening. */
.floor__reflection {
  position: absolute;
  inset-block-start: 0;
  inset-inline-start: 50%;
  width: calc(var(--door-w) * 1.02);
  height: calc(var(--door-h) * 1.15);
  margin-inline-start: calc(var(--door-w) * -0.51);
  /* z-index ladder for the slab: sweep (auto, bottom) → reflection 1 →
     hotspot 2 → veins 3. Light first, dark marks cutting it. (A "polish"
     band layer sat at 4 until the corrected plane geometry magnified its
     repeating gradient into visible striping — deleted, not tuned.) */
  z-index: 1;
  /* The strongest specular on the slab — the gloss signal. Dimmed less than
     the diffuse layers on purpose; if the floor needs further darkening,
     take it from the washes above, not from here. */
  opacity: calc(0.3 * var(--seep));
  /* Mirrored vertically: what sat at the door's foot is nearest the door. */
  background: linear-gradient(
      180deg,
      rgba(var(--pd-light-300-rgb), 0.55) 0%,
      rgba(var(--pd-light-500-rgb), 0.3) 18%,
      rgba(var(--pd-purple-500-rgb), 0.22) 46%,
      rgba(var(--pd-purple-700-rgb), 0.1) 72%,
      transparent 100%
    ),
    linear-gradient(90deg, transparent 0%, rgba(var(--pd-light-500-rgb), 0.14) 88%, transparent 100%);
  mask-image: linear-gradient(180deg, #000 0%, rgba(0, 0, 0, 0.55) 38%, transparent 88%);
  transition: opacity var(--pd-dur-camera) var(--pd-ease-camera) var(--pd-delay-camera);
}

/*
 * The floor behind the door. A separate plane rotating about its BOTTOM edge
 * (which sits on the doorway line), so everything above the origin recedes to
 * negative z — the door now stands ON the stone instead of at its back edge.
 * A separate element rather than extending .floor upward: .floor's origin,
 * masks, gradients, and children are all anchored to its top edge being the
 * doorway base, and re-anchoring every one of them invites exactly the class
 * of placement bug this floor has already suffered twice.
 * Depth-sorted behind the doorway and frame by preserve-3d; visible as a
 * dim strip flanking the door that dissolves with distance. Kept textureless:
 * it is unlit back there, and at that foreshortening no tile would read.
 */
.floor-back {
  position: absolute;
  /* Anchored by its BOTTOM to the stage's bottom edge — the same edge the
     front floor's top anchors to — so the two fold lines are the same line
     by construction, with no calc chain to round apart. */
  inset-block-end: 0;
  inset-inline-start: 50%;
  width: calc(var(--door-w) * 9);
  height: calc(var(--door-h) * 0.9);
  margin-inline-start: calc(var(--door-w) * -4.5);
  transform-origin: 50% 100%;
  transform: rotateX(79deg);
  pointer-events: none;
  /* FOLD CONTINUITY: the colors and glow at this plane's bottom edge must
     match what the front floor shows at its top edge, or the shared line
     reads as a gap in the stone (it did — Ryan's review). Hence the same
     #110c1b at the fold and a mirror of the front floor's doorway spill;
     physically it's the crack light bleeding both ways at the threshold. */
  background:
    radial-gradient(
      40% 34px at 50% 100%,
      rgba(var(--pd-light-500-rgb), calc(0.1 * var(--seep))),
      transparent 100%
    ),
    radial-gradient(70% 60% at 50% 100%, #110c1b 0%, #080611 55%, #050409 100%);
  mask-image: radial-gradient(
    ellipse 58% 140% at 50% 100%,
    #000 30%,
    rgba(0, 0, 0, 0.5) 60%,
    transparent 88%
  );
}

/* --- Trigger ------------------------------------------------------------- */
/* Deliberately a real <button> outside the 3D context: interactive elements
   inside a preserve-3d subtree get unreliable hit-testing across browsers. */
.door-trigger {
  position: absolute;
  inset-block-start: 50%;
  inset-inline-start: 50%;
  z-index: 8;
  display: block;
  width: min(var(--door-w), 46vw);
  height: min(var(--door-h), 62vh);
  transform: translate(-50%, -50%);
  padding: 0;
  border: 0;
  border-radius: 6px;
  background: transparent;
  text-decoration: none;
  cursor: pointer;
  transition: opacity var(--pd-dur-base) var(--pd-ease-standard);
}

.door-trigger:focus-visible {
  outline: 3px solid var(--pd-light-400);
  outline-offset: 10px;
}

/* --- OPEN STATE ---------------------------------------------------------- */
/*
 * Stepping through. The camera travels far enough that the frame passes the
 * viewer entirely — at perspective 1400px, a 900px push is roughly 2.8x, so
 * the architrave leaves frame on both sides and you end up beyond it.
 */
.portal.is-open {
  --door-angle: -104deg;
  --zoom: 900px;
  /* A restrained bloom. This used to be 2.2, which was a wall of light in the
     face at the exact moment the form needed to be readable. The scene should
     feel like it opened, not like someone hit a flash. */
  --seep: 1.3;
  --ray-reach: 1.7;
}

/* Arriving in the room dims it — see .doorway__depth::after. */
.portal.is-open .doorway__depth::after {
  opacity: 1;
}

/*
 * Light staging is asymmetric, and this rule is the OPENING half.
 *
 * Opening: the glow swells on the camera's clock, blooming as you approach —
 * these overrides restore the camera duration/ease/delay for that direction.
 * Closing: the base rules' door-synced transitions govern (transitions take
 * timing from their destination state), so every glow that extends past the
 * door's silhouette collapses in lockstep with the swing and is out the
 * instant the panel seats. Before this split, the close direction ran on the
 * camera clock too, and the halo kept burning ~1.2s after the door was shut.
 */
.portal.is-open .doorway,
.portal.is-open .doorway__seep,
.portal.is-open .rays,
.portal.is-open .fog {
  transition-duration: var(--pd-dur-camera);
  transition-timing-function: var(--pd-ease-camera);
  transition-delay: var(--pd-delay-camera);
}

/* The sweep rotates into place with the swing — see .floor::before. */
.portal.is-open .floor::before {
  opacity: 0.85;
  transform: rotate(0deg);
}

/* Unlatch flare: one outward breath of the glow the moment the door cracks —
   a pressure release, not a flash. Transform-only (the blur is static), so the
   compositor rotates a cached texture; opacity can't overshoot past 1, which
   is why this scales the bloom instead of brightening it. */
.portal.is-open .doorway__seep::before {
  animation: seep-flare 1100ms var(--pd-ease-entrance);
}

@keyframes seep-flare {
  0% {
    transform: scale(1);
  }
  28% {
    transform: scale(1.16);
  }
  100% {
    transform: scale(1);
  }
}

.portal.is-open .door-trigger {
  opacity: 0;
  pointer-events: none;
  transition-duration: var(--pd-dur-fast);
}

/* The room beyond opens up and swallows the frame as we arrive. */
.portal.is-open .doorway__depth {
  transform: translateZ(-40px) scale(1.6);
}

.portal.is-open .floor__reflection {
  opacity: 0.1;
}

/* --- Responsive ---------------------------------------------------------- */
@media (max-width: 40rem) {
  .portal {
    --door-w: clamp(160px, 44vw, 240px);
    --door-thickness: 12px;
  }

  .portal.is-open {
    --zoom: 620px;
  }

  .scene {
    perspective: 900px;
  }

  .floor {
    width: calc(var(--door-w) * 7);
    margin-inline-start: calc(var(--door-w) * -3.5);
    --hinge-x: 42.9%; /* 50% − (0.5/7) for the narrower floor */
  }

  .floor-back {
    width: calc(var(--door-w) * 7);
    margin-inline-start: calc(var(--door-w) * -3.5);
  }

  /* The rays container is two blurred, blended, animated textures — at 7.2×
     the door it's ~1700px² twice over on a phone GPU, and its outer reach is
     off-screen there anyway. 4.6× keeps everything a phone can show.

     The door-hole mask is sized in percentages OF THIS CONTAINER, so it must
     be recomputed for the new ratio (door is 1/4.6 of the width here vs 1/7.2
     on desktop) — otherwise the hole shrinks below the door's silhouette and
     light bleeds across its edges. Same 1.2× margin as the desktop values. */
  .rays {
    width: calc(var(--door-w) * 4.6);
    height: calc(var(--door-w) * 4.6);
    margin-inline-start: calc(var(--door-w) * -2.3);
    margin-block-start: calc(var(--door-w) * -2.3);
    mask-image: radial-gradient(
        ellipse 13.5% 28% at 50% 50%,
        transparent 52%,
        #000 100%
      ),
      radial-gradient(
        closest-side,
        #000 15%,
        rgba(0, 0, 0, 0.55) 34%,
        rgba(0, 0, 0, 0.24) 52%,
        rgba(0, 0, 0, 0.08) 66%,
        transparent 76%
      );
  }
}

@media (max-height: 34rem) and (orientation: landscape) {
  .portal {
    --door-w: clamp(120px, 22vh, 180px);
  }
}

/* --- Reduced motion ------------------------------------------------------ */
/* Keep the scene — remove the drift, the pulse, and the walk through the
   doorway. The door still opens (it's the only way to the form), just briskly
   and without the camera push, which is the part that provokes vestibular
   discomfort. */
@media (prefers-reduced-motion: reduce) {
  .rays::before,
  .rays::after,
  .fog {
    animation: none;
  }

  .portal.is-open {
    --zoom: 0px;
  }

  .door {
    transition: transform 240ms var(--pd-ease-standard);
  }

  .stage {
    transition: none;
  }
}

/* --- No JavaScript ------------------------------------------------------- */
/* The door is decoration; the content must not depend on it. Without JS the
   scene renders as a static illustration and the form sits in flow below it.

   The trigger stays present and clickable — it is an <a href="#contact">, so
   with no scripting it still jumps to the form. Hiding it (as this rule used
   to) made clicking the door do nothing at all, which is worse than no
   affordance: the page invites a click and then ignores it. */
.no-js .portal {
  min-height: auto;
  padding-block: var(--pd-space-8) var(--pd-space-9);
}

.no-js .scene {
  height: auto;
  padding-block: var(--pd-space-8) calc(var(--door-h) * 0.7);
}

/* --- Forced colors (Windows High Contrast) ------------------------------- */
@media (forced-colors: active) {
  .rays,
  .fog,
  .floor,
  .floor-back,
  .doorway__depth {
    display: none;
  }

  .door__face {
    border: 2px solid ButtonBorder;
  }

  .door-trigger {
    border: 2px dashed ButtonBorder;
  }
}

/* ==========================================================================
   DOOR HARDWARE: lever handle + overture
   (Winner of the A/B test, 2026-07-19 — the pill handle lost and the URL
   switch was removed. These rules override the base .door__handle pill
   styling later in the cascade; the concealed-hinge tabs live in .doorway's
   background stack.)

   The lever turns down BEFORE the door swings — a 380ms overture — and
   every downstream clock (door, sweep, sheen, flare, camera, reveal) shifts
   by the same amount via .is-open-scoped token overrides.

   Hardware proportions derive from a real 36"×80" door: lever 4.75" (13% of
   door width), rose 2.5" (7%), backset 2.75" (center ~7.6% from the edge —
   ON THE LOCK STILE, inboard of the panels which start at 12%). Mount height
   is 56% from the top by Ryan's eye (a 35" mount — the by-the-book 38" read
   as high against this door's mid-rail). A keyhole was tried and cut on
   review; visible hinge hardware was rejected as a physical-security defect
   (exposed barrels on the secure side = pull the pins, lift the door).
   ========================================================================== */

/* --- The rose: round mount replacing the pill --------------------------- */
.door__handle {
  width: 5.5%;
  min-width: 10px;
  aspect-ratio: 1;
  /* Rose center lands 7% from the door edge — real backset, on the stile.
     Height: the by-the-book 38" mount (52.5%) read as high against this
     door's mid-rail per Ryan's review; 56% ≈ a 35" mount, still ADA-range,
     visually seated in the rail. Perception beats the spec sheet here. */
  inset-inline-end: 4.2%;
  inset-block-start: 56%;
  border-radius: 50%;
  overflow: visible; /* the arm extends past the mount */
  background: radial-gradient(
    circle at 38% 32%,
    var(--pd-light-300),
    var(--pd-light-500) 55%,
    #9a5fc4 100%
  );
  box-shadow:
    0 0 10px rgba(var(--pd-light-500-rgb), 0.4),
    inset 0 -1px 2px rgba(78, 26, 107, 0.6);
}

/* --- The arm ------------------------------------------------------------- */
/* Repurposes the glint pseudo. Uses the `rotate` property (not transform)
   so the turn composes cleanly and fully overrides the pill glint's tilt
   translate. Origin sits at the rose's centre so it pivots like a spindle.
   236% of the 5.5% rose ≈ 13% of the door — a 4.75" lever, pointing inward
   across the stile as levers do. */
.door__handle::after {
  inset: auto;
  inset-block-start: 50%;
  inset-inline-end: 30%;
  width: 236%;
  height: 31%;
  translate: 0 -50%;
  border-radius: var(--pd-radius-pill);
  background: linear-gradient(180deg, var(--pd-light-300), var(--pd-light-500) 60%, #9a5fc4);
  box-shadow:
    0 1px 4px rgba(8, 6, 15, 0.5),
    0 0 10px rgba(var(--pd-light-500-rgb), 0.35);
  transform-origin: 91% 50%;
  rotate: 0deg;
  /* Close direction: the lever springs back only after the door has seated. */
  transition: rotate 320ms var(--pd-ease-standard) 1450ms;
}

/* Open: press the lever down first (negative = free end drops), snappy with
   a slight overshoot, while the door waits its 380ms. */
.portal.is-open .door__handle::after {
  rotate: -42deg;
  transition: rotate 340ms cubic-bezier(0.34, 1.3, 0.5, 1);
}

/* --- The latch: a small shadow that withdraws with the turn -------------
   Same idea as the concealed-hinge tabs (a dark occlusion painted into the
   sliver), but on the lock stile at handle height instead of the hinge
   side, smaller, and — unlike the hinges — it moves. The spring latch
   shares the lever's spindle, so it can't live in .doorway's shared
   background layer list the way the static hinge tabs do (animating one
   layer out of several in a single background shorthand is exactly the
   kind of multi-layer transition CSS handles unreliably); a dedicated
   pseudo-element with its own width transition is simpler and matches how
   every other moving part here (the lever arm, the seep halo) already
   works. */
.doorway::before {
  content: "";
  position: absolute;
  inset-inline-end: 0;
  inset-block-start: 56%; /* matches .door__handle's mount height */
  width: 6px;
  height: 3%;
  /* Origin at the box's OWN leading (door-interior) edge, not the frame
     edge it's pinned to: scaling from the frame side reads as the shadow
     shrinking AT the frame, still touching it, rather than pulling away
     from it — exactly backwards from a bolt withdrawing into the door. */
  transform-origin: 0% 50%;
  transform: translateY(-50%) scaleX(1);
  background: linear-gradient(270deg, #140a26, rgba(28, 15, 51, 0));
  /* Close direction: the bolt re-seats only once the lever springs back —
     same delay as .door__handle::after's own return transition. */
  transition: transform 320ms var(--pd-ease-standard) 1450ms;
}

/* Open: withdraws in lockstep with the lever's turn — same clock, no
   overture delay, so the two read as one mechanism rather than two. */
.portal.is-open .doorway::before {
  transform: translateY(-50%) scaleX(0);
  transition: transform 340ms cubic-bezier(0.34, 1.3, 0.5, 1);
}

/* --- The overture: everything door-clocked waits for the lever ----------- */
.portal.is-open .door,
.portal.is-open .floor::before,
.portal.is-open .door__face::before {
  transition-delay: 380ms;
}

.portal.is-open .doorway__seep::before {
  animation-delay: 380ms;
}

/* Camera and reveal shift by the same 380ms. Scoped to .is-open so the vars
   are only overridden for the opening direction — closing reads the base
   token values and is identical to variant A. */
.portal.is-open {
  --pd-delay-camera: 700ms;
  --pd-delay-reveal: 1880ms;
}

/* The concealed-hinge tabs live in .doorway's base background stack — one
   copy, no sync problem. See the comment on .doorway for geometry and the
   depth-tie lesson that put them there. */

/* --- Reduced motion: no overture, no delayed camera ----------------------- */
/* The global reduced-motion rule zeroes durations but NOT delays, so the
   overture would otherwise survive as a dead 380ms pause. */
@media (prefers-reduced-motion: reduce) {
  .portal.is-open .door,
  .portal.is-open .floor::before,
  .portal.is-open .door__face::before {
    transition-delay: 0ms;
  }

  .portal.is-open {
    --pd-delay-camera: 320ms;
    --pd-delay-reveal: 1500ms;
  }
}
