@font-face {
  font-family: 'Newsreader';
  src: url('../assets/newsreader.woff2') format('woff2');
  font-weight: 200 800;
  font-display: swap;
}

:root {
  --bg: #050505;
  --font: 'Newsreader', Georgia, 'Times New Roman', serif;
  --starlight: #ECEEF3;
  --gold: #C6A664;
  /* matches the vein/electricity core colour already on the figure
     (js/electricity.js's coreCol) -- her ask: the site's own accent, not
     an unrelated one picked in isolation */
  --electric-blue: #4FA3FF;
  /* validated in a static PIL comp before building this live -- her call:
     "the scale and placement is good" */
  --figure-h: 80%;
  --figure-bottom: 7%;
  --shadow-x: 54%; /* nudged right of centre per her ask, 2026-09-21 */
}

* { box-sizing: border-box; }

.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

html, body {
  margin: 0;
  background: var(--bg);
  font-family: var(--font);
  /* iOS Safari's rubber-band overscroll bounce can expose html/body's own
     background ABOVE a position:fixed element -- .stage is fixed, so a
     real pull-down/bounce (something no headless browser simulates,
     which is why this never showed up in testing) reveals a flat, hard-
     edged rectangle of this background colour sitting above the actual
     photo. Confirmed by sampling her real screenshot: the rectangle
     measured (6,5,8), effectively identical to --bg's (5,5,5); the photo
     starts at (98,81,71) right where the hard edge is. This disables the
     bounce entirely rather than trying to paint over what it exposes. */
  overscroll-behavior-y: none;
}

/* body no longer clips overflow -- the hero used to be the entire page
   (single fixed frame, nothing to scroll), her new brief adds a real
   scroll sequence below it. Same lesson as softworks: this rule is only
   safe while nothing below the hero exists; the moment it does, remove
   it or scrolling silently breaks. .stage stays position:fixed as a true
   backdrop -- see .stage-spacer for why. */

/* Loading state, same technique as krystallissoftworks: a ~700B blurred
   JPEG of the real composite (baked from a live render, not guessed) shows
   from first paint via plain background-image -- no JS needed for this
   part, no blank void moment while the real assets are still in flight.
   The actual layers below start at opacity:0 and cross-fade in together
   once js/main.js confirms they've decoded, so the reveal is one soft
   blur-to-sharp transition rather than each image popping in whenever its
   own network request happens to finish. */
.stage {
  position: fixed;
  inset: 0;
  width: 100vw;
  /* svh, not plain vh -- matches krystallissoftworks' own .hero (the
     equivalent fixed-backdrop role there), her already-proven fix for
     this exact case: plain vh resolves against the LARGE (chrome-hidden)
     viewport, which can be taller than what's actually visible while
     Safari's toolbar is showing, especially mid-transition on load/
     scroll. NOTE: the SPACER below intentionally keeps plain vh --
     softworks found svh collapses to 0 on that specific element; the two
     roles behave differently, don't "fix" the spacer to match this. */
  height: 100svh;
  overflow: hidden;
  background-color: var(--bg);
  background-image:
    linear-gradient(rgba(0, 0, 0, 0.35), rgba(0, 0, 0, 0.35)),
    url("data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAA4KCw0LCQ4NDA0QDw4RFiQXFhQUFiwgIRokNC43NjMuMjI6QVNGOj1OPjIySGJJTlZYXV5dOEVmbWVabFNbXVn/2wBDAQ8QEBYTFioXFypZOzI7WVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVn/wAARCAASACADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDgdgXBB471IsSO4C0qxZXj05qSFFjIbIrSzaJukxscOJgM4A70XBZ2PIIWrbrG8eQ4zVaSLbjnrTaaiJO7Hj7kn0qr2ooo6A9x46L9adOTvoop9GJbo//Z");
  background-size: cover;
  background-position: center;
}

/* Portrait/phone override for the loading-state image above. The default
   one is a 32x18 JPEG (16:9, baked for a desktop/landscape viewport) --
   `background-size: cover` on a tall narrow phone screen has to stretch
   that landscape sliver across a much taller box, and with only 18 rows
   total an 8px JPEG compression block is ~44% of the image's own height,
   so it blows up into a visibly hard-edged rectangle once magnified that
   far (her catch: "weird rectangle at top of phone view screen"). Fix:
   a second LQIP baked at a real portrait aspect. FIRST attempt just
   downscaled the sharp source straight to 20x40 and read fine at a
   deliberately-pessimistic nearest-neighbor check -- but a real device
   showed it as "grainy... not smooth like the laptop" (her catch): JPEG
   compression noise on SHARP content, compressed into a handful of
   pixels, blows up into visible per-pixel grain once magnified 20-40x,
   even though it isn't a hard rectangle seam. REAL fix, the standard
   LQIP technique this was missing: apply a real Gaussian blur to the
   FULL-RES source FIRST (radius 40), THEN downscale to 24x52 -- the
   remaining pixels represent genuinely smooth gradients with almost no
   high-frequency content left for JPEG to turn into noise. */
@media (max-aspect-ratio: 3/4) {
  .stage {
    background-image:
      linear-gradient(rgba(0, 0, 0, 0.35), rgba(0, 0, 0, 0.35)),
      url("data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAoHCAkIBgoJCAkMCwoMDxoRDw4ODx8WGBMaJSEnJiQhJCMpLjsyKSw4LCMkM0Y0OD0/QkNCKDFITUhATTtBQj//2wBDAQsMDA8NDx4RER4/KiQqPz8/Pz8/Pz8/Pz8/Pz8/Pz8/Pz8/Pz8/Pz8/Pz8/Pz8/Pz8/Pz8/Pz8/Pz8/Pz8/Pz//wAARCAA0ABgDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDx9kYNzSgEdQcVaiIlcRsuXJxmtCeJZUNumxSvAJ71TsgSuZAXevHWirNuqJKUl+UjvRWigmiG7E1iiPfqVOcZOakk+z7UkDlpw2SBTLEPBeLHKoTjmrcCwxXc21QMjisp/EjWGzEls1upcxnDHrkUVo6Nc2tpds98jOr8YHaiu2Kg1qckptPQylt3VpSwLMoBDE1Np8RulkkVMOc7QT1qoLhmjlC7ixwABTbe8eCSMbSNvUVgnG+pb5lext6NFHfBUn++HKkUVnaZJOl8DFkF3zRVqdkQ0rlS1JW3LA4O7rVVvmuee5oorJbFy3NwMVv7QLwMDpRRRW1T4mY0/hR//9k=");
  }
}

.sky-img,
.city-canvas,
.city-depth-grade,
.lamp-glow-canvas,
.ground-shadow,
.ground-shadow-tail,
.figure-wrap,
.figure-reflection {
  opacity: 0;
  transition: opacity 700ms ease;
}

.stage.is-ready .sky-img,
.stage.is-ready .city-canvas,
.stage.is-ready .city-depth-grade,
.stage.is-ready .lamp-glow-canvas,
.stage.is-ready .ground-shadow,
.stage.is-ready .ground-shadow-tail,
.stage.is-ready .figure-wrap,
.stage.is-ready .figure-reflection {
  opacity: 1;
}

@media (prefers-reduced-motion: reduce) {
  .sky-img, .city-canvas, .city-depth-grade, .lamp-glow-canvas,
  .ground-shadow, .ground-shadow-tail,
  .figure-wrap, .figure-reflection { transition: none; }
}

.sky-img {
  position: absolute;
  /* oversized + JS-driven transform (mouse parallax + slow drift, see
     js/parallax.js) -- needs headroom on every side so panning never
     exposes an edge. object-fit:cover alone isn't enough once a
     translate is layered on top of it. */
  top: -8%;
  left: -8%;
  width: 116%;
  height: 116%;
  object-fit: cover;
  z-index: 1;
  will-change: transform;
}

/* object-fit/object-position don't apply meaningfully to <canvas> (no
   natural size for a replaced-element property to act on) -- the cover
   crop + 50%/65% anchor is done in the shader instead, see
   js/compositor.js's mountCoverComposite. */
.city-canvas {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  z-index: 2;
  user-select: none;
}

/* depth cue -- her catch: the whole street reads at one flat brightness
   regardless of distance, which is both a hero-shot contrast problem (she
   competes with the background instead of owning the frame) and a real
   3D-perspective one (rain scatters more light the further back you look,
   so distant blocks should dim/desaturate, not just be dark everywhere).
   Radial, centred on the street's own vanishing point, not a flat band --
   "super targeted", not a wash over the whole plate. Dark navy, not
   black, echoing the scene's own cool ambient (same rule as
   .ground-shadow/.figure-grade: never neutral grey/black). Sits ABOVE
   .city-canvas so the architecture recedes, but BELOW .lamp-glow-canvas so
   the lamps still punch through the haze as real light sources would. */
.city-depth-grade {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 2;
  pointer-events: none;
  background: radial-gradient(ellipse 42% 55% at 54% 39%,
    rgba(6, 10, 22, 0.76) 0%,
    rgba(6, 10, 22, 0.40) 30%,
    rgba(6, 10, 22, 0.16) 55%,
    rgba(6, 10, 22, 0) 78%);
  mix-blend-mode: multiply;
}

/* additive lamp-glow layer, painted straight over the plate in js/lamps.js
   -- same z-index as .city-canvas, DOM order after it is what puts it on top */
.lamp-glow-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 2;
  pointer-events: none;
}

/* TWO layers, per the researched technique: a real contact shadow needs a
   hard, near-black core right at the touch point AND a wider soft pool --
   one gradient alone reads as a vague haze, not "she is standing here." */
.ground-shadow {
  position: absolute;
  left: var(--shadow-x);
  bottom: calc(var(--figure-bottom) - 1.4%);
  width: 38%;
  height: 9%;
  transform: translateX(-50%);
  z-index: 3;
  /* tinted with the scene's own ambient (cool blue-violet), not neutral
     black -- a grey/black shadow is one of the fastest "pasted-on" tells.
     Darkened further across the board (her ask, 2026-09-21: "much much
     better but more darkness under her right foot" plus general strength). */
  background: radial-gradient(ellipse at center,
    rgba(1, 2, 8, 1) 0%,
    rgba(1, 2, 8, 0.94) 25%,
    rgba(2, 4, 12, 0.68) 48%,
    rgba(2, 4, 12, 0.30) 72%,
    rgba(2, 4, 12, 0) 100%);
  filter: blur(4px);
  pointer-events: none;
}

/* the hard core -- small, dark, tight, directly under her, no blur softness
   eating its edge. This is what actually pins her to the ground; the wide
   layer above is just ambient falloff around it. */
.ground-shadow::after {
  content: "";
  position: absolute;
  left: 50%;
  bottom: 26%;
  width: 50%;
  height: 44%;
  transform: translateX(-50%);
  background: radial-gradient(ellipse at center,
    rgba(0, 0, 0, 1) 0%,
    rgba(0, 0, 0, 0.88) 55%,
    rgba(0, 0, 0, 0) 100%);
  filter: blur(1px);
}

/* extra emphasis under her RIGHT foot specifically (screen-left in this
   front-facing pose, measured at ~45% of stage width -- confirmed against
   the actual boot position in a render, not assumed) -- her direct ask,
   pushed further a second time ("more under her right foot"). */
.ground-shadow::before {
  content: "";
  position: absolute;
  left: 28%;
  bottom: 18%;
  width: 40%;
  height: 62%;
  transform: translateX(-50%);
  background: radial-gradient(ellipse at center,
    rgba(0, 0, 0, 1) 0%,
    rgba(0, 0, 0, 0.92) 45%,
    rgba(0, 0, 0, 0.4) 75%,
    rgba(0, 0, 0, 0) 100%);
  filter: blur(1.5px);
}

/* the "south" extension -- a longer, softer cast trailing toward the
   viewer/bottom of frame, not just a tight pool right at the contact
   point. A shadow this size, under overhead storm light, reads as
   stretching away from her rather than sitting as one small disc. */
.ground-shadow-tail {
  position: absolute;
  left: var(--shadow-x);
  bottom: 0;
  width: 34%;
  height: calc(var(--figure-bottom) + 2%);
  transform: translateX(-50%);
  z-index: 3;
  background: linear-gradient(to bottom,
    rgba(1, 2, 8, 0.88) 0%,
    rgba(1, 2, 8, 0.78) 40%,
    rgba(1, 2, 8, 0.50) 70%,
    rgba(1, 2, 8, 0) 100%);
  filter: blur(5px);
  pointer-events: none;
}

/* a real reflection, not just a shadow -- researched technique for wet
   surfaces (flip the subject, mask/fade it into the reflective plane).
   This street is DRENCHED, every light in it already reflects clearly, so
   a figure with no reflection at all is the single biggest "floating"
   tell on this particular plate.

   Box is IDENTICAL to .figure-wrap (same bottom/height vars) so percentage
   sizing on the img inside resolves to the same real size as the actual
   figure -- a smaller clipping box here would make height:100% on the img
   resolve against THAT smaller box instead of the stage, shrinking her
   reflection to a fraction of true scale. clip-path then trims the
   already-correctly-sized box down to just the sliver below her feet,
   which is purely visual and doesn't affect how percentages compute. */
.figure-reflection {
  position: absolute;
  left: 50%;
  bottom: var(--figure-bottom);
  height: var(--figure-h);
  transform: translateX(-50%);
  z-index: 3;
  /* visible sliver = figure-bottom / figure-h = 7/80 = 8.75% of this box */
  clip-path: inset(0 0 91.25% 0);
  -webkit-mask-image: linear-gradient(to bottom, rgba(0,0,0,0.42) 0%, rgba(0,0,0,0) 8.75%);
  mask-image: linear-gradient(to bottom, rgba(0,0,0,0.42) 0%, rgba(0,0,0,0) 8.75%);
  pointer-events: none;
}

.figure-reflection-canvas {
  display: block;
  height: 100%;
  width: auto;
  /* canvas has no intrinsic size for width:auto to derive from the way
     an <img> does -- aspect-ratio gives it one. 2:3 is the keyed
     figure's real pixel ratio (4096x6144), not a guess. */
  aspect-ratio: 2 / 3;
  transform: scaleY(-1);
  /* same weathering grade as .figure-canvas, so the reflection matches her */
  filter: blur(1.5px) brightness(0.924) contrast(1.14) saturate(0.87) sepia(0.05);
}

.figure-wrap {
  position: absolute;
  left: 50%;
  bottom: var(--figure-bottom);
  height: var(--figure-h);
  transform: translateX(-50%);
  z-index: 4;
}

.figure-canvas {
  display: block;
  height: 100%;
  width: auto;
  aspect-ratio: 2 / 3; /* real keyed figure ratio, 4096x6144 -- see the
    reflection canvas's own comment, canvas needs this for width:auto */
  user-select: none;
  /* real per-pixel weathering grade, not a flat overlay -- her ask,
     2026-09-21: "make her darker, more weathered... heavier than you had
     initially in mind." Researched technique: high contrast + desaturated
     shadows reads as gritty/raw (war-film grading), so contrast and
     saturate do the actual work; brightness pulls the exposure down;
     sepia adds a whisper of warmth into the shadows for the "split tone"
     half of that technique (warm shadow / cool highlight) -- the cool
     highlight half is already covered by .figure-grade's blue multiply
     below, so together they form one real split-tone, not just a darken.
     This is a render-time filter on the live <img> -- the source asset on
     disk is completely untouched, full original quality.
     Mellowed 50% back toward neutral, then darkness specifically eased
     another 15% (2026-09-21: "reduce the darkness by another 15%... i
     think its perfect") -- only brightness moves here (0.19 short of
     neutral -> 15% less short = 0.1615 short = 0.84); contrast/saturate/
     sepia are the grit/texture half of the grade, not "darkness", left
     as she approved them. 2026-09-21 later: "10% brighter" -> 0.84*1.1
     = 0.924, literal multiplier on the already-approved grade. */
  filter: brightness(0.924) contrast(1.14) saturate(0.87) sepia(0.05);
}

/* colour-matches her to the plate's cool night grade -- sampled research
   on compositing ("the plate is the ground truth for black level/colour
   temperature") says a neutral/warm cutout on a cool scene is the other
   fast tell. mask-image clips the tint to her own alpha shape so it never
   spills onto the street. multiply darkens+cools; the soft top-down
   gradient reads as the storm's own light falling on her from above. */
.figure-grade {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  /* mellowed 50%, then eased another 15% (same ask as .figure-canvas above)
     -- alpha (how much this multiplies the dark colour in) reduced 15%
     per stop, hue left alone */
  background: linear-gradient(180deg, rgba(65, 103, 183, 0.31) 0%, rgba(38, 65, 133, 0.20) 45%, rgba(18, 26, 63, 0.14) 100%);
  mix-blend-mode: multiply;
  /* NOT figure-alpha.avif -- tried that first, verified live it doesn't
     work: this environment doesn't apply mask-image at all from an AVIF
     source. Switching to WebP alone wasn't the actual fix either --
     confirmed by screenshotting .figure-grade in isolation with a solid
     red fill: it rendered as a plain unmasked rectangle, no silhouette
     at all. Real cause: mask-mode's default (match-source) was resolving
     to "alpha" for this file, and a plain grayscale WebP has no alpha
     channel to read -- no alpha data reads as fully opaque everywhere,
     i.e. no masking effect whatsoever. mask-mode: luminance forces the
     browser to read the image's actual luminance instead of a
     (non-existent) alpha channel -- that's the real fix, not the format
     swap. tools/keyplate.py's save_mask still bakes the small dedicated
     WebP (~19KB vs the old combined webp's 902KB), that part was fine. */
  -webkit-mask-image: url("../assets/figure-mask.webp?v=1");
  mask-image: url("../assets/figure-mask.webp?v=1");
  -webkit-mask-mode: luminance;
  mask-mode: luminance;
  -webkit-mask-size: 100% 100%;
  mask-size: 100% 100%;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  pointer-events: none;
}

.arcs-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
}

/* faint unifying grain so the sharp cutout doesn't read as pasted onto a
   grainier photographic plate -- "noise/grain matching" from the same
   compositing research, kept subtle (low opacity, screen scale) */
.grain {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 5;
  opacity: 0.05;
  mix-blend-mode: overlay;
  pointer-events: none;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
  background-size: 180px 180px;
}

/* Loading beat over the ambient blur, same idea as krystallissoftworks'
   own .hero__mark -- her catch, "not seeing the logo upon load": there
   was nothing at all filling the loading beat here, just the blurred
   photo underneath. Visible by default (unconditional -- this is a
   single-page site, no return-trip case to suppress it for), fades out
   the instant .stage.is-ready lands, same trigger every other layer
   already uses. */
.load-mark {
  position: absolute;
  top: 42%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: clamp(120px, 22vw, 220px);
  height: auto;
  z-index: 6;
  opacity: 1;
  pointer-events: none;
  transition: opacity 500ms ease;
}
.stage.is-ready .load-mark { opacity: 0; }
@media (prefers-reduced-motion: reduce) {
  .load-mark { transition: none; }
}

/* reserves the hero's own screen height in normal document flow so the
   fixed .stage isn't scrolled straight through the instant real content
   exists below it -- same fix softworks needed the first time a fixed
   full-screen backdrop grew a second section (js/hero.js's own methods
   map: "the hero canvas was a normal in-flow element... scrolling past
   its own box made it disappear entirely", except here .stage was
   already position:fixed from day one, so this spacer is the only piece
   that was actually missing). */
.stage-spacer {
  height: 100vh;
}

/* Her spec: black overlay at 0 opacity on load, fades to a slight
   darkening ~1.2s in, once js/sequence.js confirms boot(). Sits above
   the hero, below the text sequence -- a touch of cinematic weight
   before the words start, not a loading screen (that's .stage's own
   blurred background-image, a separate concern). */
.load-overlay {
  position: fixed;
  inset: 0;
  z-index: 6;
  background: #000;
  opacity: 0;
  pointer-events: none;
  transition: opacity 1400ms ease;
}
.load-overlay.is-active { opacity: 0.18; }

/* The scroll sequence -- her spec verbatim on the reveal mechanics (each
   line its own element, own trigger, 0.9s ease-out, opacity 0->1 +
   translateY 20px->0), tuned down from her first look: 100vh per line
   (~9.7 screens total top to bottom) read as "tiring to scroll", so the
   three short opening fragments (Yes. / Yes it's me. / Michelle
   Krystallis.) now share ONE beat with a quick internal stagger instead
   of three separate full-screen stops, and every section's height is
   cut roughly in half. Still scroll-triggered, still paced -- just not
   nine and a half screens of it. Text sits directly over the live
   scene, no box behind it (same rule as the softworks page copy) -- a
   text-shadow carries legibility instead. */
.sequence {
  position: relative;
  z-index: 2;
}

.line {
  min-height: 55vh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0 6vw;
  text-align: center;
}

.line--intro {
  min-height: 60vh;
  flex-direction: column;
  gap: 0.9rem;
}

.line p {
  margin: 0;
  color: var(--starlight);
  font: 400 clamp(1.35rem, 3.2vw, 2.3rem)/1.3 var(--font);
  text-shadow: 0 2px 18px rgba(0, 0, 0, 0.55);
  opacity: 0;
  transform: translateY(20px);
  transition: opacity 900ms ease-out, transform 900ms ease-out;
  will-change: opacity, transform;
}
.line p.is-visible { opacity: 1; transform: none; }

/* the quick internal stagger for the merged opening beat -- each of the
   three fragments delays a little more than the last, so they still
   read as three separate breaths, just inside one screen instead of
   three. Delay only applies once (on the transition into is-visible),
   so it costs nothing once revealed. */
.reveal-line--stagger:nth-of-type(1) { transition-delay: 0ms; }
.reveal-line--stagger:nth-of-type(2) { transition-delay: 220ms; }
.reveal-line--stagger:nth-of-type(3) { transition-delay: 440ms; }

/* the name gets a touch more weight -- it's the one line that's a
   statement rather than a fragment */
.reveal-line--name { font-weight: 500; }

/* Two links only (her correction -- the brief said "three", only two
   names ever followed). Centred, generous spacing, no descriptions, no
   icons -- an electric-blue underline is the only hover state, matching
   the vein colour already on the figure rather than an unrelated accent
   (gold was the first pass, her ask). */
.links {
  position: relative;
  z-index: 2;
  min-height: 40vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 2.4rem;
}

.links a {
  color: var(--starlight);
  font: 400 clamp(1.25rem, 2.2vw, 1.5rem)/1 var(--font);
  letter-spacing: 0.04em;
  text-decoration: none;
  text-shadow: 0 2px 14px rgba(0, 0, 0, 0.55);
  padding-bottom: 0.4rem;
  border-bottom: 1px solid transparent;
  opacity: 0;
  transform: translateY(20px);
  transition: opacity 900ms ease-out, transform 900ms ease-out, border-color 240ms ease;
  will-change: opacity, transform;
}
.links a.is-visible { opacity: 1; transform: none; }
.links a:hover, .links a:focus-visible { border-color: var(--electric-blue); }

/* the closing line -- her spec: slower, longest pause before it appears.
   The pause is mostly earned by scroll distance (generous margin-top),
   the slightly longer transition on top of that is the "slower than the
   rest" she asked for specifically on this one line. */
.closing {
  position: relative;
  z-index: 2;
  min-height: 45vh;
  margin-top: 6vh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0 6vw;
  text-align: center;
}

.closing p {
  margin: 0;
  color: var(--starlight);
  font: 300 clamp(1.35rem, 2.3vw, 1.6rem)/1.4 var(--font);
  text-shadow: 0 2px 18px rgba(0, 0, 0, 0.55);
  opacity: 0;
  transform: translateY(20px);
  transition: opacity 1300ms ease-out, transform 1300ms ease-out;
  will-change: opacity, transform;
}
.closing p.is-visible { opacity: 1; transform: none; }

/* just the address -- no form, no icons, no footer nav */
.contact {
  position: relative;
  z-index: 2;
  min-height: 20vh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0 6vw 8vh;
}

.contact a {
  color: rgba(236, 238, 243, 0.62);
  /* first pass (0.9-1.05rem) left this the smallest text on the page and
     she caught it a second time on the last screen specifically -- real
     bump this round, not another timid one. Still a touch quieter than
     the body text via colour/weight, not via size, matching the "just
     the address, de-emphasised" intent without being hard to read. */
  font: 400 clamp(1.15rem, 1.8vw, 1.25rem)/1 var(--font);
  letter-spacing: 0.03em;
  text-decoration: none;
  border-bottom: 1px solid transparent;
  transition: color 240ms ease, border-color 240ms ease;
}
.contact a:hover, .contact a:focus-visible { color: var(--starlight); border-color: var(--electric-blue); }

/* Idle scroll cue -- same mechanism as krystallissoftworks (recurring,
   not one-and-done: any scroll hides it and re-arms the 3s idle timer).
   Only the hero's cue exists here; this site has one beat to nudge past,
   not five. */
.scroll-cue {
  position: fixed;
  left: 50%;
  bottom: clamp(1.6rem, 4vh, 3rem);
  z-index: 6;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.55rem;
  background: none;
  border: none;
  padding: 0.4rem;
  margin: 0;
  cursor: pointer;
  color: var(--starlight);
  opacity: 0;
  pointer-events: none;
  transform: translateX(-50%);
  transition: opacity 1100ms ease;
  -webkit-tap-highlight-color: transparent;
}
.scroll-cue.is-visible { opacity: 1; pointer-events: auto; }
.scroll-cue__label {
  /* flat 0.92rem measured 14.72px on a real phone -- the smallest text
     on the page sitting on the most prominent element (it's the first
     thing scrollable, before anything else fires) -- her catch, "letters
     too small on mobile". A real clamp so it scales instead of a fixed
     size, floor raised well above the old flat value. */
  font: 400 clamp(1.05rem, 4.5vw, 1.3rem)/1 var(--font);
  letter-spacing: 0.02em;
  white-space: nowrap;
  text-shadow: 0 1px 4px rgba(0, 0, 0, 0.85);
  transition: letter-spacing 320ms ease;
}
.scroll-cue__arrow {
  width: 1.1rem;
  height: 1.1rem;
  stroke: currentColor;
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-linejoin: round;
  fill: none;
  filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.8));
  animation: scroll-cue-drift 2.4s ease-in-out infinite;
}
.scroll-cue:hover .scroll-cue__label,
.scroll-cue:focus-visible .scroll-cue__label { letter-spacing: 0.06em; }
.scroll-cue:focus-visible { outline: none; }

@keyframes scroll-cue-drift {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(5px); }
}

@media (prefers-reduced-motion: reduce) {
  .sky-img { transition: none !important; }
  .load-overlay { transition: none; }
  .line p, .links a, .closing p {
    transition: none;
    opacity: 1;
    transform: none;
  }
  .scroll-cue { transition: opacity 300ms ease; }
  .scroll-cue__arrow { animation: none; }
}
