/* ==========================================================================
   WORKSPACE-LOADER.CSS — boot veil (stripped 2026-08-14)
   --------------------------------------------------------------------------
   Two things on screen and nothing else: the orbit grid (workspace-loader-grid
   .css) and the progress meter. The wordmark, the falling X, the glass
   fracture, the shards, the spall, the crush, the flash, the dust, the logo,
   the orbit rings and the halo are all gone — with them, ~700 lines of CSS and
   every keyframe they needed.

   The floor grid is the one decorative-looking thing kept, and it is not
   decoration: it is a copy of the workspace's own drifting plane
   (`.sim-viewer-bg::before` in css/workspace.css), matched stop for stop, so
   that when the veil fades the ground underneath is already in the same place
   at the same offset. Delete it and the grid pops into existence at handoff.
   If you want it gone anyway it is one rule and one line in the markup.

   JS contract (js/workspace-loader.js):
     .workspace-loader           overlay root, [hidden] once done
     [data-loader-bar]           width driven inline as N%
     --loader-progress           set on the root as N%  (the grid reads this)
     [data-loader-percent]       "07" text
     [data-loader-status]        status line; .is-swapping during text swap
     .is-exiting                 added at handoff; the veil crossfades out
     body.has-ws-loader / body.ws-intro-go   chrome gating (rules at bottom)
   ========================================================================== */

/* body.print3d-page > * demotes positioned direct body children to
   position:relative (variables.css stacking note) — restore explicitly. */
html body.print3d-page > .workspace-loader { position: fixed; z-index: 2147480000; }

.workspace-loader {
  position: fixed;
  inset: 0;
  z-index: 2147480000;
  /* Statement left, orbit right — mirrors the composition the veil hands over
     to. The two in-flow children are the only grid items; the floor is
     absolute and the meter is fixed, so both sit this out. */
  display: grid;
  /* The statement is the wider column on purpose. It carries a two-word
     headline at poster size in a face that may not be the condensed one (see
     .wsl-hero__head); the orbit is a square that reads fine at any size and
     loses nothing by being smaller. An even split starved the side that could
     not afford it.

     Both columns are minmax(0, …) so an over-long headline shrinks its own
     column instead of blowing the grid past 100vw. */
  grid-template-columns: minmax(0, 1.18fr) minmax(0, 0.82fr);
  align-items: center;
  column-gap: clamp(20px, 4vw, 76px);
  /* The meter is position:fixed, so it contributes nothing to this grid and
     `align-items: center` would happily centre the row underneath it. The
     bottom padding is the meter's own band, reserved by hand — it is what
     makes "centred" mean centred in the space the user can actually see. */
  padding:
    clamp(56px, 9vh, 110px)
    clamp(18px, 4vw, 60px)
    clamp(128px, 21vh, 210px)
    clamp(22px, 5.5vw, 92px);
  /* THE VEIL IS THE WORKSPACE'S OWN GROUND, not a lookalike.
     Copied verbatim from `html body.print3d-page > .sim-viewer-bg`
     (css/workspace.css) — same layers, same stops, same order. Both boxes are
     position:fixed inset:0, so both gradients resolve against the same
     100vw x 100vh area and land pixel-for-pixel on top of each other. With the
     break gone this no longer has to survive being cut into fragments, but it
     is still what makes the crossfade at handoff invisible.

     If .sim-viewer-bg's background changes, change this with it. */
  --veil-image:
    radial-gradient(ellipse at 50% 46%, rgba(var(--accent-blue-rgb, 127, 200, 194), 0.075), transparent 42%),
    radial-gradient(ellipse at 50% 62%, rgba(34, 49, 52, 0.34), transparent 54%),
    radial-gradient(ellipse at 50% 105%, rgba(var(--accent-purple-rgb, 184, 167, 122), 0.05), transparent 38%),
    linear-gradient(180deg, #080b0c 0%, #0d1214 50%, #090c0d 100%);
  background-color: #080b0c;
  background-image: var(--veil-image);
  opacity: 1;
  transition: opacity 0.9s cubic-bezier(0.22, 1, 0.36, 1);
}
.workspace-loader.is-exiting { opacity: 0; pointer-events: none; }
.workspace-loader[hidden] { display: none; }

/* retired layers from earlier designs — markup may still carry them */
.workspace-loader__void,
.workspace-loader__grain,
.workspace-loader__shadow,
.workspace-loader__halo,
.workspace-loader__shards,
.workspace-loader__glass { display: none; }

/* ==========================================================================
   THE GROUND — depth around the floor plane, without touching it
   --------------------------------------------------------------------------
   .workspace-loader__floor is a pixel copy of .sim-viewer-bg::before and has
   to stay one, or the crossfade at handoff shows a jump. So the atmosphere is
   built AROUND it in two layers that do not exist in the workspace and are
   gone before the veil lifts:

     ::before   the horizon — a wide, low pool of brand light where the plane
                recedes, plus a vignette that closes the corners down. This is
                what turns a grid drawn in perspective into a room.
     ::after    a slow light sweep across that plane, 19s, deliberately NOT
                tied to --loader-progress. Three things already run off that
                value (the ink, the meter, the cluster); a fourth would read as
                the same event happening four times.

   Both are absolute. A ::before left in flow on this element would become a
   grid item, take column 1 and shove the statement into column 2.
   ========================================================================== */
.workspace-loader::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  background:
    /* horizon pool, sitting on the vanishing line of the floor */
    radial-gradient(ellipse 62% 26% at 50% 78%,
      rgba(var(--accent-blue-rgb, 127, 200, 194), 0.10),
      rgba(var(--accent-blue-rgb, 127, 200, 194), 0.03) 46%,
      transparent 72%),
    /* a colder counter-light from high left, so the room has two sources */
    radial-gradient(ellipse 46% 40% at 6% 8%,
      rgba(150, 178, 186, 0.055), transparent 66%),
    /* vignette — closes the corners so nothing competes with the two columns */
    radial-gradient(ellipse 78% 74% at 50% 50%,
      transparent 42%, rgba(4, 6, 7, 0.42) 88%, rgba(3, 5, 6, 0.62) 100%);
  pointer-events: none;
}

.workspace-loader::after {
  content: "";
  position: absolute;
  z-index: 3;                     /* over the floor, under everything read */
  left: -40%;
  right: -40%;
  bottom: -6vh;
  height: 62vh;
  background: linear-gradient(100deg,
    transparent 34%,
    rgba(var(--accent-blue-rgb, 127, 200, 194), 0.055) 47%,
    rgba(232, 252, 250, 0.05) 50%,
    rgba(var(--accent-blue-rgb, 127, 200, 194), 0.055) 53%,
    transparent 66%);
  -webkit-mask-image: linear-gradient(to top, #000 6%, rgba(0, 0, 0, 0.5) 46%, transparent 88%);
  mask-image: linear-gradient(to top, #000 6%, rgba(0, 0, 0, 0.5) 46%, transparent 88%);
  animation: wsGroundSweep 19s cubic-bezier(0.5, 0, 0.5, 1) infinite;
  pointer-events: none;
}
@keyframes wsGroundSweep {
  0%        { transform: translate3d(-46%, 0, 0); opacity: 0; }
  12%, 78%  { opacity: 1; }
  100%      { transform: translate3d(46%, 0, 0); opacity: 0; }
}

/* ==========================================================================
   THE GROUND PLANE — the boot screen's own 3D floor
   --------------------------------------------------------------------------
   Separate from .workspace-loader__floor on purpose, and the reason is
   geometry rather than taste. That element is a pixel copy of
   .sim-viewer-bg::before: 76vh tall, anchored at bottom:-20vh, rotateX(64deg)
   about its own bottom edge. Measure it and it projects to a ~180px band
   starting BELOW the fold on a 1000px viewport — it exists to be in the right
   place at the moment of handoff, not to be looked at. Brightening it to make
   a background would break the match it exists for.

   So this is a second plane, steeper and much closer to the camera, that is
   loader-only and fades before the crossfade. What is left when the veil
   lifts is the matched copy and nothing else.

   Absolute, so it never becomes a grid item on the two-column root.
   ========================================================================== */
.wsl-ground {
  position: absolute;
  z-index: 1;
  left: 0;
  right: 0;
  bottom: 0;
  height: min(54vh, 620px);
  /* A BAKED plane, not a live one.
     ------------------------------------------------------------------
     This started as a real 3D layer: 210vw x 104vh, perspective(900px)
     rotateX(68deg), a gradient mask and a drifting texture. Measured against
     the same page with the layer removed it cost about three quarters of the
     frame budget — 43fps down to 11 — and turning the animation off recovered
     almost none of it, so the cost was rasterising a large transformed and
     masked layer, not moving it. That is an unaffordable price on the one
     screen whose whole job is to stay smooth while the bundle parses.

     So the perspective is rendered once, offline, into a 102KB WebP: rows
     compressing toward the vanishing point, columns converging on it, the
     horizon dissolve baked into the alpha. At runtime it is one flat
     background image with no transform, no mask and no animation. The motion
     in this region comes from the sweep on .workspace-loader::after, which is
     a single composited translate.

     Regenerate with the script in the commit that added it if the accent
     colour ever changes — the teal is baked into the pixels. */
  background:
    url("../../img/loader/ground-grid.webp") center bottom / cover no-repeat;
  opacity: 0.9;
  transition: opacity 0.24s ease;
  pointer-events: none;
}
.workspace-loader.is-exiting .wsl-ground { opacity: 0; }

@media (max-width: 940px) {
  .wsl-ground { height: min(42vh, 380px); }
}

/* ---- the workspace's floor grid, on the veil --------------------------- */
.workspace-loader__floor {
  position: absolute;
  z-index: 2;
  left: 50%;
  bottom: -20vh;
  width: 160vw;
  height: 76vh;
  transform: translateX(-50%) perspective(820px) rotateX(64deg);
  transform-origin: 50% 100%;
  background-image:
    linear-gradient(rgba(151, 170, 171, 0.19) 1px, transparent 1px),
    linear-gradient(90deg, rgba(151, 170, 171, 0.16) 1px, transparent 1px);
  background-size: 84px 84px;
  opacity: 0.34;
  -webkit-mask-image: linear-gradient(to top, #000 4%, rgba(0, 0, 0, 0.72) 44%, transparent 90%);
  mask-image: linear-gradient(to top, #000 4%, rgba(0, 0, 0, 0.72) 44%, transparent 90%);
  /* keyframes live in css/workspace.css alongside the plane this copies */
  animation: simGridDrift 22s linear infinite;
  pointer-events: none;
}

/* (the matched plane carries no extra layer — see .wsl-ground above) */

/* ---- the stage: nothing but the orbit grid's host ---------------------- */
.workspace-loader__scene {
  position: relative;
  z-index: 5;
  grid-column: 2;
  display: grid;
  /* Both of these are load-bearing. `justify-self: center` made this box
     shrink-to-fit, and the orbit inside it is sized `width: min(100%, …)` —
     100% of a shrink-to-fit parent whose width comes from its child is a
     circular definition, which resolves to zero. The column then claimed its
     fr share while rendering nothing, which is what was squeezing the
     statement. Stretch fills the column; `justify-items: end` keeps the
     cluster on the outside edge where it was. */
  justify-self: stretch;
  justify-items: end;
  padding: 0;
}

/* ==========================================================================
   THE STATEMENT
   --------------------------------------------------------------------------
   Two lines of condensed caps, and the second one is the loading indicator.
   Not decorated with the progress — made OF it: the ink fills left to right
   off --loader-progress, the same value the meter and the orbit grid read, so
   the words finish printing exactly as the workspace finishes building and the
   full stop lands on 100. That is the whole idea; everything else here is
   restraint around it.
   ========================================================================== */
.wsl-hero {
  position: relative;
  z-index: 5;
  grid-column: 1;
  justify-self: start;
  min-width: 0;
  /* No max-width here. An em value would resolve against THIS element's font
     size (~16px), not the headline's, which is how the statement ended up
     boxed into 240px and guillotined. The grid column is the bound. */
  /* The root now reserves the meter's band in its own bottom padding, so this
     is optical balance only, not collision avoidance. */
  margin-bottom: clamp(4px, 1.5vh, 18px);
}

/* ---- eyebrow ------------------------------------------------------------ */
.wsl-hero__eyebrow {
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 0 0 clamp(14px, 2.2vh, 24px);
  font-family: 'Inter', system-ui, sans-serif;
  font-size: clamp(9px, 1vw, 12px);
  font-weight: 700;
  letter-spacing: 0.28em;
  text-transform: uppercase;
  white-space: nowrap;
  opacity: 0;
  animation: wsHeroFade 0.5s cubic-bezier(0.22, 1, 0.36, 1) 60ms forwards;
}
.wsl-hero__brand { color: var(--accent-blue, #7fc8c2); }
.wsl-hero__kicker { color: rgba(220, 228, 226, 0.42); }
/* draws itself open rather than fading in — the one flourish up here */
.wsl-hero__rule {
  flex: none;
  width: clamp(20px, 3vw, 46px);
  height: 1px;
  background: linear-gradient(90deg,
    rgba(var(--accent-blue-rgb, 127, 200, 194), 0.75),
    rgba(var(--accent-blue-rgb, 127, 200, 194), 0.12));
  transform-origin: 0 50%;
  transform: scaleX(0);
  animation: wsHeroRule 0.62s cubic-bezier(0.22, 1, 0.36, 1) 180ms forwards;
}
@keyframes wsHeroRule { to { transform: scaleX(1); } }

/* ---- the headline ------------------------------------------------------- */
.wsl-hero__head {
  margin: 0;
  /* Bebas is the only condensed face already self-hosted for this page, and
     condensed is what carries a two-word line at poster size. It ships one
     weight, so the poster weight comes from the size, not from a synthetic
     bold — which on a condensed face smears the counters shut. */
  font-family: 'Bebas Neue', 'Arial Narrow', 'Inter', system-ui, sans-serif;
  font-weight: 400;
  /* Sized so the longest line still fits the column in the WIDEST face this
     stack can resolve to, not the narrowest. Bebas is declared
     `font-display: optional` in fonts/fonts.css, which means a cold visit that
     loses the preload race renders this whole screen in the fallback and never
     swaps — so the layout cannot assume it got the condensed one. */
  /* 6.2vw, not 6.9: at 6.9 the longest line measured flush to the column
     edge in the fallback face, which means any wider fallback overflows the
     clip-path silently. This leaves it a little air. */
  font-size: clamp(38px, 6.2vw, 104px);
  line-height: 0.9;
  letter-spacing: 0.005em;
  text-transform: uppercase;
}

/* The mask for the rise. clip-path rather than overflow:hidden, and the
   difference matters twice: it clips the bottom ONLY, so a line set in a wider
   fallback face is never chopped mid-word, and it leaves the print tip's bloom
   room to spill sideways. (overflow-x: visible with overflow-y: hidden is not
   a thing — setting either axis to non-visible forces the other to auto.) */
.wsl-hero__line {
  display: block;
  clip-path: inset(-0.35em -0.6em 0 -0.6em);
}
.wsl-hero__word {
  display: inline-block;
  white-space: nowrap;
  color: var(--text, #f2f5f3);
  transform: translateY(1.05em);
  animation: wsHeroRise 0.66s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.wsl-hero__line:first-child .wsl-hero__word { animation-delay: 150ms; }
.wsl-hero__line--print .wsl-hero__word { animation-delay: 260ms; }
@keyframes wsHeroRise { to { transform: translateY(0); } }

/* ---- the ink ------------------------------------------------------------
   Filled portion runs brand teal into sand, with a white-hot head sitting
   exactly on the fill edge; everything ahead of it is an unprinted ghost.
   Stops that resolve out of order at 0% are clamped up by the browser, which
   is what leaves the line a clean ghost before the first byte lands. */
.wsl-hero__line--print { position: relative; }
.wsl-hero__ink {
  --p: var(--loader-progress, 0%);
  position: relative;
  background-image: linear-gradient(90deg,
    var(--accent-blue, #7fc8c2) 0%,
    var(--accent-purple, #b8a77a) calc(var(--p) * 0.72),
    #f4fffd calc(var(--p) - 0.6%),
    rgba(230, 219, 214, 0.13) calc(var(--p) + 0.4%),
    rgba(230, 219, 214, 0.13) 100%);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  -webkit-text-fill-color: transparent;
}

/* the print head, riding the fill edge */
.wsl-hero__tip {
  position: absolute;
  top: -0.04em;
  bottom: 0.02em;
  left: min(100%, var(--p));
  width: 2px;
  margin-left: -1px;
  background: linear-gradient(180deg,
    transparent, #ffe9d9 20%, #ffe9d9 80%, transparent);
  box-shadow:
    0 0 10px 1px rgba(var(--accent-blue-rgb, 127, 200, 194), 0.7),
    0 0 26px 5px rgba(var(--accent-blue-rgb, 127, 200, 194), 0.28);
  opacity: 0;
  animation: wsHeroFade 0.5s ease 420ms forwards;
  pointer-events: none;
}

/* ---- subtext ------------------------------------------------------------ */
.wsl-hero__sub {
  margin: clamp(16px, 2.6vh, 30px) 0 0;
  max-width: 30ch;
  font-family: 'Inter', system-ui, sans-serif;
  font-size: clamp(11px, 1.15vw, 15px);
  font-weight: 500;
  line-height: 1.65;
  color: rgba(220, 228, 226, 0.5);
  opacity: 0;
  animation: wsHeroFade 0.5s cubic-bezier(0.22, 1, 0.36, 1) 400ms forwards;
}

@keyframes wsHeroFade { to { opacity: 1; } }

/* ---- meter -------------------------------------------------------------- */
.workspace-loader__meter {
  position: fixed;
  z-index: 5;
  left: clamp(10px, 1.8vw, 30px);
  right: clamp(10px, 1.8vw, 30px);
  bottom: max(18px, calc(env(safe-area-inset-bottom, 0px) + 18px));
  display: grid;
  grid-template-areas:
    "status number"
    "track number";
  grid-template-columns: minmax(0, 1fr) clamp(96px, 12vw, 176px);
  align-items: end;
  justify-items: stretch;
  column-gap: clamp(18px, 3vw, 42px);
  row-gap: 16px;
  min-height: clamp(104px, 15vh, 154px);
  padding: clamp(20px, 3vh, 34px) clamp(22px, 3vw, 44px) clamp(18px, 2.6vh, 30px);
  opacity: 0;
  animation: wsLoaderRise 0.7s cubic-bezier(0.22, 1, 0.36, 1) 300ms forwards;
}
@keyframes wsLoaderRise {
  to { opacity: 1; transform: translateY(0); }
}

/* ==========================================================================
   PROGRESS — a filament of light, not a bar
   --------------------------------------------------------------------------
     rail      a 1px hairline across the full width, so there is somewhere for
               the light to travel without drawing a container around it
     filament  the filled run — transparent at the origin, brand teal through
               the middle, white at the head
     head      a RADIAL bloom centred on the tip; a blurred rectangle blurs to
               a grey slab with a visible right edge, a radial gradient has no
               edge to show
   ========================================================================== */
.workspace-loader__progress {
  grid-area: track;
  position: relative;
  width: 100%;
  height: 26px;
  align-self: end;
  margin-bottom: 2px;
  overflow: visible;
}

/* the rail — presence without a container */
.workspace-loader__progress::before {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  height: 1px;
  transform: translateY(-50%);
  background: linear-gradient(90deg,
    rgba(255, 255, 255, 0.02),
    rgba(255, 255, 255, 0.07) 40%,
    rgba(255, 255, 255, 0.07) 60%,
    rgba(255, 255, 255, 0.02));
  pointer-events: none;
}

.workspace-loader__bar {
  position: relative;
  display: block;
  height: 100%;
  width: 0%;
  min-width: 2px;
  transition: width 0.62s cubic-bezier(0.16, 1, 0.3, 1);
}

/* the filament */
.workspace-loader__bar::before {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  height: 2px;
  transform: translateY(-50%);
  border-radius: 2px;
  background: linear-gradient(90deg,
    rgba(var(--accent-blue-rgb, 127, 200, 194), 0) 0%,
    rgba(var(--accent-blue-rgb, 127, 200, 194), 0.28) 26%,
    rgba(var(--accent-blue-rgb, 127, 200, 194), 0.72) 62%,
    rgba(232, 252, 250, 0.95) 88%,
    #ffffff 100%);
  box-shadow:
    0 0 6px rgba(var(--accent-blue-rgb, 127, 200, 194), 0.45),
    0 0 18px rgba(var(--accent-blue-rgb, 127, 200, 194), 0.20);
  pointer-events: none;
}

/* The head. A wide, shallow ellipse whose core sits on the tip and whose mass
   trails BACKWARD, so it reads as light spilling in the direction of travel. */
.workspace-loader__bar::after {
  content: "";
  position: absolute;
  right: 0;
  top: 50%;
  width: clamp(150px, 17vw, 280px);
  height: 22px;
  transform: translate(6%, -50%);
  background:
    radial-gradient(ellipse 46% 50% at 93% 50%,
      rgba(255, 255, 255, 0.40) 0%,
      rgba(232, 252, 250, 0.18) 14%,
      rgba(var(--accent-blue-rgb, 127, 200, 194), 0.085) 34%,
      rgba(var(--accent-blue-rgb, 127, 200, 194), 0.03) 58%,
      transparent 82%);
  filter: blur(4px);
  animation: wsLoaderEndpointPulse 2.8s ease-in-out infinite;
  pointer-events: none;
}
@keyframes wsLoaderEndpointPulse {
  0%, 100% { opacity: 0.8; transform: translate(6%, -50%) scaleX(0.97); }
  50%      { opacity: 1;   transform: translate(6%, -50%) scaleX(1.04); }
}

/* a highlight drifting along the filled run — motion while the width waits */
.workspace-loader__progress::after {
  content: "";
  position: absolute;
  left: 0;
  top: 50%;
  width: min(100%, var(--loader-progress, 0%));
  height: 2px;
  transform: translateY(-50%);
  overflow: hidden;
  background:
    linear-gradient(90deg,
      transparent 0%,
      rgba(255, 255, 255, 0.5) 46%,
      rgba(255, 255, 255, 0.5) 54%,
      transparent 100%);
  background-size: 42% 100%;
  background-repeat: no-repeat;
  mask-image: linear-gradient(90deg, transparent, #000 55%, #000 92%, transparent);
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 55%, #000 92%, transparent);
  opacity: 0.55;
  animation: wsLoaderShimmer 2.6s cubic-bezier(0.45, 0, 0.55, 1) infinite;
  pointer-events: none;
}
@keyframes wsLoaderShimmer {
  0%   { background-position: -45% 0; }
  100% { background-position: 145% 0; }
}

.workspace-loader__percent {
  grid-area: number;
  justify-self: end;
  align-self: end;
  margin: 0;
  font-family: 'Inter', system-ui, sans-serif;
  font-size: clamp(54px, 7vw, 96px);
  font-weight: 760;
  line-height: 0.78;
  color: var(--text, #f2f5f3);
  text-shadow:
    0 0 22px rgba(255, 255, 255, 0.18),
    0 18px 50px rgba(0, 0, 0, 0.44);
  font-variant-numeric: tabular-nums;
}
.workspace-loader__status {
  grid-area: status;
  justify-self: start;
  align-self: end;
  margin: 0;
  padding-bottom: 3px;
  font-family: 'Inter', system-ui, sans-serif;
  font-size: clamp(10px, 1.2vw, 13px);
  font-weight: 680;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  text-align: left;
  color: rgba(220, 228, 226, 0.58);
  transition: opacity 0.18s ease, transform 0.18s ease;
}
.workspace-loader__status.is-swapping {
  opacity: 0;
  transform: translateY(3px);
}

/* ---- letting go ---------------------------------------------------------
   The veil crossfades and the content on it goes dark faster than the veil
   does. Declared as an animation rather than a transition because the meter's
   arrival animation fills forwards and would otherwise win. */
/* The two atmosphere layers go first and fast: what has to be left at the
   crossfade is the matched floor plane and nothing else. */
.workspace-loader.is-exiting::before,
.workspace-loader.is-exiting::after { opacity: 0; transition: opacity 0.24s ease; }

.workspace-loader.is-exiting .wsl-hero,
.workspace-loader.is-exiting .workspace-loader__scene,
.workspace-loader.is-exiting .workspace-loader__meter {
  animation: wsLoaderPowerDown 0.3s cubic-bezier(0.4, 0, 1, 1) forwards;
}
@keyframes wsLoaderPowerDown {
  from { opacity: 1; transform: scale(1); }
  to   { opacity: 0; transform: scale(0.988); }
}

/* ==========================================================================
   CHROME GATING — unchanged.
   `has-ws-loader` is set by workspace-loader.js at boot (pages without the
   loader, or with JS disabled, are never gated); `ws-intro-go` at handoff:
   the workspace chrome holds its entrance animations until the veil opens.
   ========================================================================== */
body.print3d-page.has-ws-loader:not(.ws-intro-go) .ws-header-left,
body.print3d-page.has-ws-loader:not(.ws-intro-go) .ws-credits > *,
html body.print3d-page.has-ws-loader:not(.ws-intro-go) > .ws-prompt-guide-launcher {
  animation-play-state: paused;
}
body.print3d-page.has-ws-loader:not(.ws-intro-go) .ws-dock,
body.print3d-page.has-ws-loader:not(.ws-intro-go) .ws-filmstrip,
body.print3d-page.has-ws-loader:not(.ws-intro-go) .ws-hero {
  opacity: 0;
}
body.print3d-page.ws-intro-go .ws-dock,
body.print3d-page.ws-intro-go .ws-filmstrip,
body.print3d-page.ws-intro-go .ws-hero {
  opacity: 1;
  transition: opacity 0.6s ease 0.15s;
}

/* --- small screens -------------------------------------------------------
   The floor plane is re-cut on small screens — mirrors the matching override
   for .sim-viewer-bg::before in media/workspace-media.css. That one uses a
   760px breakpoint, not the 720px the rest of this file uses, and it has to
   stay that way: the two planes must change shape on the same pixel or they
   drift apart between 720 and 760. */
@media (max-width: 760px) {
  .workspace-loader__floor {
    width: 220vw;
    height: 66vh;
    bottom: -17vh;
    background-size: 58px 58px;
  }
}

/* Below this the two columns are both too narrow: the headline starts
   breaking mid-phrase and the orbit loses its ring. Stack instead, statement
   first — it is the thing worth reading. */
@media (max-width: 940px) {
  .workspace-loader {
    grid-template-columns: minmax(0, 1fr);
    align-content: center;
    row-gap: clamp(16px, 3.5vh, 38px);
    padding: 0 clamp(18px, 6vw, 44px) clamp(96px, 18vh, 150px);
  }
  .wsl-hero {
    grid-column: 1;
    /* `max-width: 17em` here resolved against THIS element's font size, not
       the headline's — the exact trap the desktop rule above documents. At
       900px it capped the statement at 272px and the headline overhung it by
       295px. The column is the bound; nothing else needs to be. */
    justify-self: stretch;
    max-width: 100%;
    margin-bottom: 0;
  }
  .workspace-loader__scene {
    grid-column: 1;
    justify-items: center;      /* centred once it is the only thing in the row */
  }
  /* 9vw, not 10.5: at 10.5 the longest line clears the padding by under 40px
     in the fallback face, and any wider fallback overflows. */
  .wsl-hero__head { font-size: clamp(36px, 9vw, 68px); }
  .wsl-hero__sub { max-width: 34ch; }
}

/* Very short viewports have no room for both. The orbit is the decoration and
   the statement carries the brand, so the orbit is what goes. */
@media (max-height: 540px) and (max-width: 940px) {
  .workspace-loader__scene { display: none; }
}

@media (max-width: 720px) {
  .workspace-loader__meter {
    left: 12px;
    right: 12px;
    bottom: max(16px, calc(env(safe-area-inset-bottom, 0px) + 12px));
    grid-template-areas:
      "status"
      "track";
    grid-template-columns: minmax(0, 1fr);
    column-gap: 14px;
    row-gap: 10px;
    min-height: 104px;
    padding: 16px 96px 14px 14px;
  }
  .workspace-loader__progress { max-width: 100%; height: 18px; }
  .workspace-loader__percent {
    position: absolute;
    right: 16px;
    bottom: 16px;
    align-self: end;
    font-size: clamp(38px, 13vw, 52px);
    z-index: 2;
  }
  .workspace-loader__status {
    max-width: none;
    padding-bottom: 0;
    font-size: 10px;
    letter-spacing: 0.13em;
    line-height: 1.35;
  }
  .workspace-loader__bar::before { height: 2px; }
  .workspace-loader__bar::after { width: 120px; height: 18px; }
}

/* --- reduced motion ------------------------------------------------------ */
@media (prefers-reduced-motion: reduce) {
  .workspace-loader { transition-duration: 0.2s; }
  .workspace-loader__bar::after,
  .workspace-loader__progress::after {
    animation: none;
  }
  .workspace-loader__meter {
    animation-duration: 0.01ms;
    animation-delay: 0ms;
  }
  /* The floor stays — it is the ground, not choreography — but it holds still,
     matching what workspace.css does to .sim-viewer-bg::before here. If only
     one of the two planes drifts they slide out of phase and the handoff shows
     a jump. */
  .workspace-loader__floor { animation: none; }
  .workspace-loader::after { animation: none; opacity: 0.5; transform: none; }
  /* The reveal choreography goes; the ink fill stays, because it is the
     progress readout and not decoration. */
  .wsl-hero__eyebrow,
  .wsl-hero__sub,
  .wsl-hero__tip { animation: none; opacity: 1; }
  .wsl-hero__rule { animation: none; transform: scaleX(1); }
  .wsl-hero__word { animation: none; transform: none; }
  .workspace-loader__bar { transition: none; }
  .workspace-loader__bar::before {
    opacity: 0.36;
    filter: blur(10px);
  }
}


/* ==========================================================================
   WARM PALETTE (2026-08-18): the boot veil joins the hero's HorizonX scheme.
   The loader paints everything through --accent-blue/-rgb, so one scoped
   override retints the halo, floor grid, orbit, meter bar and brand mark to
   Exotic Orange. The hardcoded teal-white highlight moves to Dirty White and
   the purple fallback to Sidecar Yellow. Colors only.
   ========================================================================== */
.workspace-loader {
  --accent-blue: #F54F1B;
  --accent-blue-rgb: 245, 79, 27;
  --accent-purple-rgb: 243, 232, 188;
}
.workspace-loader .wsl-hero__head,
.workspace-loader [data-loader-percent] { color: #EFE9E9; }
