/* ============================================================
   Silver Bullet — texture pack
   Drop-in surface treatments, layered over the real project.
   Load AFTER tokens.css. Nothing here defines a colour of its own.

     0. motion tokens    shared timing, used by everything below
     1. .tx-grain        page-wide film grain
     2. .tx-vignette     edge falloff
     3. .tx-brushed      machined metal surface
     4. .tx-rim          cursor-reactive rim light      (needs JS)
     5. [data-texture="star"]   cursor-tracked star     (needs JS)
     6. .tx-sweep        specular sweep on scroll-in    (needs JS + ScrollTrigger)
     7. .tx-etched       type stamped into metal
   ============================================================ */


/* ---- motion tokens -------------------------------------------------
   Three durations and two curves, shared by every texture. This is the
   thing that makes a pile of separate effects feel like one system: if
   two elements move at different speeds for no reason, the page reads as
   assembled rather than designed.

   When this pack lands in the real project these belong in tokens.css.
   --ease-settle deliberately matches the value already in index.html. */

:root {
  --dur-quick: 0.18s;   /* a state change answering a click or hover */
  --dur-base:  0.42s;   /* the default: most things should use this */
  --dur-slow:  1.15s;   /* one orchestrated moment, used sparingly */

  --ease-settle: cubic-bezier(.25, 1, .5, 1);   /* fast out, long settle */
  --ease-metal:  cubic-bezier(.22, .61, .36, 1);/* weighty, no overshoot */
}


/* ---- shared building blocks ------------------------------------------
   Gradient lists held in custom properties so treatments can be stacked.
   background-image can only be declared once per element, so composing
   them as variables is what lets one element be brushed metal AND carry
   a star without either overwriting the other. */

:root {
  /* The metal. Neutral grey, no hue - metal is neutral, the environment
     carries the colour. Darker at the ends so a highlight has somewhere
     dimmer to sit against. */
  --tx-metal-ramp:
    linear-gradient(
      172deg,
      var(--chrome-500) 0%,
      var(--chrome-300) 16%,
      var(--chrome-100) 34%,
      var(--chrome-050) 46%,
      var(--chrome-400) 62%,
      var(--chrome-500) 100%
    );

  /* Machining marks. Felt, not counted - if you can see individual lines
     it has gone too far. */
  --tx-brushed-grain:
    repeating-linear-gradient(
      90deg,
      color-mix(in srgb, var(--chrome-050) 3%, transparent) 0px,
      color-mix(in srgb, var(--chrome-050) 3%, transparent) 1px,
      transparent 1px,
      transparent 4px
    ),
    repeating-linear-gradient(
      90deg,
      color-mix(in srgb, var(--ink) 11%, transparent) 0px,
      color-mix(in srgb, var(--ink) 11%, transparent) 1px,
      transparent 1px,
      transparent 9px
    );
}


/* ---- pointer plumbing -------------------------------------------------
   JS writes ONE pair of plain numbers, 0-100. Everything else is derived
   in CSS from those. Percentages for gradient positions, raw numbers for
   arithmetic - deriving both from one source keeps them in step. */

[data-texture], .tx-rim {
  /* --light is the site's existing signal, published by motion.js: a 0-100
     number blending pointer position with scroll, eased so it lags. The pack
     used to invent its own --mx-n; it now reads the one already there, so
     every metal element on the page moves off a single source.
     --light-y and --lit are extensions of that same engine. */
  --light: 50;
  --light-y: 50;
  --lit: 0;

  --mx: calc(var(--light, 50) * 1%);
  --my: calc(var(--light-y, 50) * 1%);
}


/* ---- film grain ----------------------------------------------------
   Put .tx-grain on <body>. Static, never animated: moving grain is
   ambient motion and repaints the whole viewport every frame. */

.tx-grain::after {
  content: "";
  position: fixed;
  inset: 0;
  z-index: 70;              /* above .intro-layer (60): grain covers everything */
  pointer-events: none;
  opacity: 0.05;                 /* the whole effect lives between .03 and .07 */
  mix-blend-mode: overlay;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)'/%3E%3C/svg%3E");
}


/* ---- vignette ------------------------------------------------------
   Also on <body>. The same idea as the grain at a different scale: it
   stops the background reading as a flat screen and starts it reading as
   a lit object. Keep it below the threshold where you notice it. */

:where(.tx-vignette--in) { position: relative; }
.tx-vignette--in::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  background: radial-gradient(
    ellipse 80% 70% at 50% 45%,
    transparent 0%, transparent 58%,
    color-mix(in srgb, var(--ink) 55%, transparent) 100%
  );
}

.tx-vignette::before {
  content: "";
  position: fixed;
  inset: 0;
  z-index: 9998;
  pointer-events: none;
  background: radial-gradient(
    ellipse 75% 65% at 50% 45%,
    transparent 0%,
    transparent 55%,
    color-mix(in srgb, var(--ink) 60%, transparent) 100%
  );
}


/* ---- brushed metal -------------------------------------------------
   Separation comes from a lit top edge, never a drop shadow. */

.tx-brushed {
  background-image: var(--tx-brushed-grain), var(--tx-metal-ramp);
  border-top: 1px solid var(--chrome-050);
  border-bottom: 1px solid var(--ink);
}


/* ---- rim light on hover -------------------------------------------
   The restrained sibling of the star. Nothing glows: the lit top edge
   gets brighter, and the bright band in the metal leans toward the
   cursor. Highlights define edges - they never bleed.

   Use this where the star would be too much, which is most places. */

.tx-rim, .tx-rim-text {
  /* How far the band leans. Half the cursor's travel, so it tracks
     without feeling stuck to the pointer. */
  --tx-rim-lean: calc((var(--light, 50) - 50) * 0.45%);

  --tx-rim-ramp: linear-gradient(
    100deg,
    var(--chrome-500) calc(0%  + var(--tx-rim-lean)),
    var(--chrome-300) calc(28% + var(--tx-rim-lean)),
    var(--chrome-100) calc(44% + var(--tx-rim-lean)),
    var(--chrome-050) calc(50% + var(--tx-rim-lean)),
    var(--chrome-300) calc(64% + var(--tx-rim-lean)),
    var(--chrome-500) 100%
  );
}

.tx-rim {
  background-image: var(--tx-rim-ramp);
  border-top: 1px solid var(--chrome-400);
  border-bottom: 1px solid var(--ink);

  /* The edge is the whole effect, so it gets the transition. Pure CSS -
     no JS needed for this half. */
  transition: border-top-color var(--dur-base) var(--ease-metal);
}
.tx-rim:hover { border-top-color: var(--chrome-050); }

/* The same rim ramp painted inside letterforms instead of on a surface.
   Text gets no borders - the ramp leaning toward the cursor IS the effect. */
.tx-rim-text {
  background-image: var(--tx-rim-ramp);
  -webkit-background-clip: text;
  background-clip: text;
  color: var(--chrome-100);        /* readable if clipping fails */
}
@supports ((background-clip: text) or (-webkit-background-clip: text)) {
  .tx-rim-text {
    color: transparent;
    -webkit-text-fill-color: transparent;
  }
}

/* Brushed marks over a rim-lit ramp. Declared explicitly because both
   classes set background-image, and without this the later rule wins. */
.tx-brushed.tx-rim {
  background-image: var(--tx-brushed-grain), var(--tx-rim-ramp);
}


/* ---- the star ------------------------------------------------------
   A hot core, two crossed spikes, a small warm pool. Warm because it is
   the room reflecting off the surface, not the surface itself.

   Every size is multiplied by --lit, so at --lit:0 every layer has zero
   size and the star is simply gone.

   These layers MUST be declared here rather than on :root. A custom
   property has its var()s resolved where it is DECLARED - referencing
   --lit from :root, where it does not exist, makes the whole value
   invalid and silently kills background-image. */

[data-texture="star"] {
  --tx-star-size: 1em;              /* em in type, overridden to px on surfaces */

  /* The light's colour, as four stops. Warm by default, because on cold
     metal the highlight is the room reflecting off the surface. A surface
     that is ALREADY warm has to restate these - see the amber CTA below. */
  --tx-star-hot:  var(--white);
  --tx-star-warm: var(--gold);
  --tx-star-mid:  var(--amber);
  --tx-star-pool: var(--amber-burnt);

  --tx-star-layers:
    radial-gradient(
      circle calc(var(--lit) * var(--tx-star-size) * 0.3) at var(--mx) var(--my),
      var(--tx-star-hot) 0%, var(--tx-star-hot) 20%, var(--tx-star-warm) 52%, transparent 82%
    ),
    radial-gradient(
      ellipse calc(var(--lit) * var(--tx-star-size) * 0.1)
              calc(var(--lit) * var(--tx-star-size) * 5.6) at var(--mx) var(--my),
      var(--tx-star-hot) 0%, var(--tx-star-warm) 9%,
      color-mix(in srgb, var(--tx-star-mid) 65%, transparent) 26%, transparent 68%
    ),
    radial-gradient(
      ellipse calc(var(--lit) * var(--tx-star-size) * 4.6)
              calc(var(--lit) * var(--tx-star-size) * 0.13) at var(--mx) var(--my),
      var(--tx-star-hot) 0%, var(--tx-star-warm) 9%,
      color-mix(in srgb, var(--tx-star-mid) 65%, transparent) 26%, transparent 68%
    ),
    radial-gradient(
      circle calc(var(--lit) * var(--tx-star-size) * 0.85) at var(--mx) var(--my),
      color-mix(in srgb, var(--tx-star-mid) 62%, transparent) 0%,
      color-mix(in srgb, var(--tx-star-pool) 30%, transparent) 45%, transparent 75%
    );
}

.tx-brushed[data-texture="star"] {
  --tx-star-size: 46px;
  background-image: var(--tx-star-layers), var(--tx-brushed-grain), var(--tx-metal-ramp);
}

.tx-metal-text {
  background-image: var(--tx-metal-ramp);
  -webkit-background-clip: text;
  background-clip: text;
  color: var(--chrome-100);      /* readable if clipping fails */
}
.tx-metal-text[data-texture="star"] {
  background-image: var(--tx-star-layers), var(--tx-metal-ramp);
}
@supports ((background-clip: text) or (-webkit-background-clip: text)) {
  .tx-metal-text { color: transparent; }
}


/* ---- specular sweep ------------------------------------------------
   A band of light rakes across once, when the element scrolls into view.
   Not a loop: one orchestrated moment. Only a transform animates, so it
   is cheap enough for a real page. */

.tx-sweep {
  position: relative;
  overflow: hidden;              /* clips the band to the element */
}

.tx-sweep::after {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  width: 34%;
  height: 100%;
  pointer-events: none;
  background: linear-gradient(
    90deg,
    transparent 0%,
    color-mix(in srgb, var(--gold) 20%, transparent) 38%,
    color-mix(in srgb, var(--gold) 75%, transparent) 50%,
    color-mix(in srgb, var(--gold) 20%, transparent) 62%,
    transparent 100%
  );
  /* --tx-sweep-pos is a plain number JS drives from -200 to 500. The skew makes
     the light rake at an angle instead of crossing flat. */
  transform: skewX(-18deg) translateX(calc(var(--tx-sweep-pos, -200) * 1%));
}


/* ---- etched type ---------------------------------------------------
   The inverse of metal type: letters pressed INTO the surface rather
   than made of it. Light from above means the top edge is in shadow and
   the bottom edge catches it.

   Both offsets have ZERO blur. A blurred text-shadow is glow, which the
   brief rules out; a 1px hard offset is an edge, which it encourages.
   Meant for labels, numbers and small caps - not headlines. */

.tx-etched {
  color: var(--steel-700);
  text-shadow:
    0 -1px 0 var(--ink),
    0  1px 0 color-mix(in srgb, var(--chrome-050) 18%, transparent);
}


/* ---- no hover, no cursor textures ------------------------------------ */
@media (hover: none) {
  [data-texture] { --lit: 0; }
}


/* ---- video ground — .tx-video-ground ------------------------------
   Footage as a background, suppressed until it reads as atmosphere
   rather than as a video playing behind the text.

   Three stacked pieces:
     .vg-css     a CSS drift ground. ALWAYS present.
     .vg-video   optional footage on top of it.
     .vg-crush   the vignette and bottom fade that kill the edges.

   The CSS layer underneath is the point. If the file is missing, still
   loading, blocked, or skipped on a phone, the background is still a
   background - it just stops being footage. Nothing ever falls to black.

   Suppression is the whole technique. Raw video behind text looks cheap;
   video at a third brightness with its edges deleted looks like a room. */

.tx-video-ground {
  --tx-video-brightness: 0.32;   /* how much of the footage survives. Keep it low. */
  --tx-video-saturation:    0.55;   /* pulled toward neutral - the palette is cold */
  --tx-video-blur:   2px;    /* hides compression artefacts in dark gradients */

  position: relative;
  overflow: hidden;
  background: var(--bg);
  isolation: isolate;
}

.tx-video-ground > .vg-css,
.tx-video-ground > .vg-video,
.tx-video-ground > .vg-crush {
  position: absolute;
  inset: 0;
  pointer-events: none;
}

/* The ground that never fails. Two slow masses, driven by initTextures. */
.tx-video-ground > .vg-css {
  inset: -30%;
  opacity: .55;
  will-change: transform;
  background:
    radial-gradient(ellipse 46% 50% at 32% 42%,
      color-mix(in srgb, var(--petrol) 72%, transparent) 0%, transparent 70%),
    radial-gradient(ellipse 42% 46% at 70% 62%,
      color-mix(in srgb, var(--steel-900) 55%, transparent) 0%, transparent 70%);
}

.tx-video-ground > .vg-video {
  width: 100%;
  height: 100%;
  object-fit: cover;
  filter: brightness(var(--tx-video-brightness)) saturate(var(--tx-video-saturation)) blur(var(--tx-video-blur));
  /* Starts invisible and is faded in by JS only once it can actually play,
     so a slow or missing file never shows as a black rectangle. */
  opacity: 0;
  transition: opacity 1.2s var(--ease-metal);
}
.tx-video-ground > .vg-video.is-ready { opacity: 1; }

/* Edges deleted. This is what stops it reading as "a video is playing". */
.tx-video-ground > .vg-crush {
  background:
    radial-gradient(ellipse 78% 72% at 50% 45%,
      transparent 0%, transparent 38%,
      color-mix(in srgb, var(--bg) 92%, transparent) 100%),
    linear-gradient(to bottom,
      transparent 0%, transparent 45%, var(--bg) 100%);
}

/* Flip this on to see the footage undressed - demo aid, not for the site. */
.tx-video-ground.is-raw > .vg-video { filter: none; }
.tx-video-ground.is-raw > .vg-crush { display: none; }


/* ---- thermal ground — .tx-thermal --------------------------------
   Cold steel with warmth rising underneath and receding again.

   Driven by SCROLL POSITION, not by a clock. That is a deliberate choice,
   not a limitation: DESIGN.md permits exactly one element that moves with
   no input, and the ticker holds that slot. Bound to scroll, this is still
   when the visitor is - the same defence the reactive chrome already has.

   --tx-heat is a 0-1 value written by initThermal(). */

.tx-thermal {
  --tx-heat: 0;
  position: relative;
  overflow: hidden;
  isolation: isolate;
}

.tx-thermal > .th-cold,
.tx-thermal > .th-warm {
  position: absolute;
  /* -12%, not -30%. These only ever scale to 1.12, so 124% of the hero
     already covers it at full heat with room to spare — -30% was 160%,
     which is 2.5x the area of the hero for no reason anyone can see.

     Area is what matters here because these are composited layers: at
     -30% they were about 2300px square each, 42 Mpx and ~161MB of GPU
     texture between them, rasterised at the top of the page, which is
     exactly where the bullet intro is trying to run. Dropping to -12%
     takes roughly 40% off that.

     The gradients are positioned in percentages OF THIS ELEMENT, so
     changing its size rescales them. Checked against the previous
     render before committing to it. */
  inset: -12%;
  pointer-events: none;
  z-index: -1;                   /* behind the section's own content */
}

/* Promoted ONLY while the section is on screen.

   inset: -30% makes each of these 160% of the hero in both axes — two
   layers of roughly 2300x2300 CSS px. Carrying will-change permanently
   kept both of them rasterised and resident for the life of the page,
   including while the hero was thousands of pixels off screen. Measured
   on index.html: 20 composited layers / 102.5 Mpx / ~391MB of GPU
   texture, of which these two were 42 Mpx and ~161MB. Dropping the
   promotion when they leave the viewport takes the page to 17 layers /
   51.9 Mpx / ~198MB. Half the memory, no visual change whatsoever.

   They still need promoting while they ARE on screen — the transform is
   driven per scroll frame off --tx-heat, and un-promoted that would
   re-raster a 2300px-square gradient every frame. This is a scope fix,
   not a removal. initThermal() toggles the class. */
.tx-thermal.is-onstage > .th-cold,
.tx-thermal.is-onstage > .th-warm {
  will-change: transform, opacity;
}

.tx-thermal > .th-cold {
  opacity: .55;
  background: radial-gradient(ellipse 55% 55% at 42% 45%,
    color-mix(in srgb, var(--petrol) 72%, transparent) 0%, transparent 72%);
  transform: scale(calc(1 + var(--tx-heat) * 0.06));
}

/* Never bright. This is the suggestion of heat, not a glow - the warm ramp
   because it is the environment, not the surface. */
.tx-thermal > .th-warm {
  opacity: calc(var(--tx-heat) * 0.42);
  background: radial-gradient(ellipse 40% 42% at 62% 66%,
    color-mix(in srgb, var(--amber-burnt) 58%, transparent) 0%,
    color-mix(in srgb, var(--maroon) 38%, transparent) 45%, transparent 74%);
  transform: scale(calc(1 + var(--tx-heat) * 0.12));
}


/* ---- frosted bar — .tx-frost -------------------------------------
   A bar that is invisible over the top of the page and frosts once the
   visitor scrolls, so content passes UNDER it, blurred rather than hidden.

   Cost control, which is the whole reason this is written in two states:
   backdrop-filter is one of the most expensive things a browser composites,
   and it is worst over playing video. At the top of the page the property
   is not applied AT ALL - not transparent, not zero-blur, absent. It only
   exists while .is-lifted is on, which is only while you are scrolled.

   Toned down from the reference: 7px rather than 10px, and the fill is the
   page's own background rather than white, so it mutes instead of milking. */

.tx-frost {
  --tx-frost-blur: 7px;
  --tx-frost-fill: 58%;        /* how much of --bg sits in the pane */
}

/* The pane is a pseudo-element, and that is load-bearing - do NOT move the
   backdrop-filter back onto .tx-frost itself.

   An element carrying backdrop-filter becomes a BACKDROP ROOT: any nested
   backdrop-filter can then only sample what is painted inside that root,
   which is nothing. The bar contains the Capabilities dropdown, which is
   frosted in its own right, so with the filter on the bar the panel's blur
   silently became a no-op the moment the bar lifted - frosted at the top of
   the page, plain glass everywhere else. Same content, same panel CSS; the
   only difference was .is-lifted.

   Painting the pane on ::before keeps the filter off the bar, so the bar is
   no longer a backdrop root and the panel samples the page normally. The
   pseudo has no descendants, so it roots nothing that matters. */
.tx-frost::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;                 /* behind the nav's own content */
  pointer-events: none;
  background-color: transparent;
  transition:
    background-color var(--dur-base) var(--ease-metal),
    backdrop-filter  var(--dur-base) var(--ease-metal);
}

.tx-frost.is-lifted::before {
  background-color: color-mix(in srgb, var(--bg) var(--tx-frost-fill), transparent);
  -webkit-backdrop-filter: blur(var(--tx-frost-blur)) saturate(108%);
  backdrop-filter: blur(var(--tx-frost-blur)) saturate(108%);
}

/* The lit bottom edge appears with the pane. A hairline, not a shadow:
   the bar is defined by its edge, the way every other surface here is. */
.tx-frost::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 1px;
  background: var(--border);
  opacity: 0;
  transition: opacity var(--dur-base) var(--ease-metal);
  pointer-events: none;
}
.tx-frost.is-lifted::after { opacity: 1; }

/* Touch devices pay the most for a backdrop blur and benefit least, so they
   get a plainer, more opaque pane instead of a blurred one. */
@media (hover: none), (prefers-reduced-transparency: reduce) {
  .tx-frost.is-lifted::before {
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
    background-color: color-mix(in srgb, var(--bg) 94%, transparent);
  }
}


/* ---- halo — .tx-halo ------------------------------------------------
   A contained field of light behind ONE element. Three characters share
   the same body and differ only in how the light behaves:

     .tx-halo--lung    swells and subsides          CSS only
     .tx-halo--orbit   light travels around it      CSS only
     .tx-halo--pulse   a cardiac lub-dub            needs initHalo()

   Siloed by falling off, not by clipping: the field fades to nothing well
   inside its own box, so it never becomes a glow spreading along a bar.

   All three are COLD. A warm halo would be a second warm element in the
   same viewport as the ticker's amber CTA, and warmth appears once. Cold
   also reads better here - steel light is telemetry, not a valentine.

   Inside a frosted bar a halo sits ON TOP of the pane: backdrop-filter
   blurs what is behind an element, never its own children. */

@property --tx-spin {
  syntax: '<angle>';
  initial-value: 0deg;
  inherits: false;
}

.tx-halo {
  --tx-halo-pad-x: 34px;        /* how far the field reaches past the element */
  --tx-halo-pad-y: 18px;        /* keep (height/2 + this) inside its container */
  --tx-halo-rate: 9s;           /* one full cycle */
  --tx-halo-low: 0.26;          /* opacity at rest */
  --tx-halo-high: 0.60;         /* opacity at full */
  --tx-beat: 0;                 /* 0-1, written by initHalo() for --pulse */
  position: relative;
}

.tx-halo::before {
  content: "";
  position: absolute;
  inset: calc(var(--tx-halo-pad-y) * -1) calc(var(--tx-halo-pad-x) * -1);
  z-index: -1;                  /* behind the element, above any frosted pane */
  pointer-events: none;
  border-radius: 999px;
  will-change: opacity, transform;

  /* Wide and shallow by default, matching a wordmark. Colour carries well
     out before it falls away so the field belongs to the WHOLE element
     rather than pooling under its middle. */
  background: radial-gradient(
    ellipse 60% 66% at 50% 50%,
    color-mix(in srgb, var(--petrol) 88%, transparent) 0%,
    color-mix(in srgb, var(--petrol) 55%, transparent) 34%,
    color-mix(in srgb, var(--steel-900) 34%, transparent) 56%,
    transparent 74%
  );
  opacity: var(--tx-halo-low);
}

/* -- lung: one breath. alternate means the exhale is the inhale reversed,
      which is what stops the loop having a seam. -- */
.tx-halo--lung::before {
  animation: tx-halo-breathe var(--tx-halo-rate) ease-in-out infinite alternate;
}
@keyframes tx-halo-breathe {
  from { opacity: var(--tx-halo-low);  transform: scale(1); }
  to   { opacity: var(--tx-halo-high); transform: scale(1.07); }
}

/* -- orbit: the light travels. The ELEMENT never rotates, only the
      gradient's angle - rotating a wide, short box swings its corners far
      outside its container and the light escapes. The conic origin sits
      outside the box so its hard pinch is never visible, and a radial mask
      is what keeps it siloed. -- */
.tx-halo--orbit {
  --tx-halo-rate: 64s;
  --tx-halo-strength: 0.5;
}
.tx-halo--orbit::before {
  opacity: var(--tx-halo-strength);
  background: conic-gradient(
    from var(--tx-spin) at 50% 140%,
    transparent 0%,
    color-mix(in srgb, var(--petrol) 85%, transparent) 12%,
    color-mix(in srgb, var(--steel-900) 45%, transparent) 26%,
    transparent 46%,
    color-mix(in srgb, var(--petrol) 55%, transparent) 68%,
    transparent 88%,
    transparent 100%
  );
  -webkit-mask-image: radial-gradient(ellipse 50% 50% at 50% 50%,
    #000 0%, rgba(0,0,0,.7) 42%, transparent 74%);
  mask-image: radial-gradient(ellipse 50% 50% at 50% 50%,
    #000 0%, rgba(0,0,0,.7) 42%, transparent 74%);
  animation: tx-halo-spin var(--tx-halo-rate) linear infinite;
}
@keyframes tx-halo-spin { to { --tx-spin: 360deg; } }

/* -- pulse: two beats, then a rest four times longer than either. The REST
      is what makes it read as a pulse rather than a throb. -- */
.tx-halo--pulse::before {
  opacity: calc(var(--tx-beat) * var(--tx-halo-high));
  transform: scale(calc(1 + var(--tx-beat) * 0.07));
}

@media (prefers-reduced-motion: reduce) {
  .tx-halo::before { animation: none; }
}


/* ---- glare — .tx-glare -------------------------------------------
   A moving specular band laid OVER an element, rather than replacing its
   surface. This is the version for anything that already has a colour it
   needs to keep - the amber CTAs especially. Applying .tx-rim to those
   would swap the accent for chrome and there would no longer be an amber
   element in the viewport at all.

   Two separate signals do two separate jobs:
     --lit    fades the band in. 0 unless the pointer is on the element,
              so this is hover-only and invisible the rest of the time.
     --light  slides it across, so the band tracks where the cursor is. */

.tx-glare {
  position: relative;
  overflow: hidden;             /* the band is clipped to the button */
}

.tx-glare::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  opacity: var(--lit, 0);
  transition: opacity var(--dur-quick) var(--ease-metal);

  background: linear-gradient(
    100deg,
    transparent 30%,
    color-mix(in srgb, var(--white) 26%, transparent) 47%,
    color-mix(in srgb, var(--white) 42%, transparent) 50%,
    color-mix(in srgb, var(--white) 26%, transparent) 53%,
    transparent 70%
  );

  /* Travels further than the cursor does, so it reads as a reflection
     swinging across a surface rather than a highlight glued to the mouse. */
  transform: translateX(calc((var(--light, 50) - 50) * 1.5%));
}

/* Secondary buttons are transparent, so the same band at full strength
   reads as a wash rather than as light on a surface. Halve it. */
.cta-secondary.tx-glare::after { opacity: calc(var(--lit, 0) * 0.55); }


/* ---- the star, on a button -------------------------------------------
   Sized in px rather than em: button text is 15px, and an em-scaled star
   would be a pinprick. The layers are a background-image, so they are
   clipped to the button and the spikes cannot escape it. */

.cta[data-texture="star"],
.cta-secondary[data-texture="star"] {
  --tx-star-size: 24px;
  background-image: var(--tx-star-layers);
}

/* The amber CTA is already bright AND already warm, so a white-and-gold
   star has almost nothing to contrast against - the first attempt was
   nearly invisible on it. Restating the light as cold gives it hue
   contrast as well as luminance, which is what makes it read. */
.cta[data-texture="star"] {
  --tx-star-hot:  var(--white);
  --tx-star-warm: var(--steel-050);
  --tx-star-mid:  var(--steel-100);
  --tx-star-pool: var(--petrol);
}


/* ---- torch — .tx-torch -------------------------------------------
   The pointer becomes a light source and the surface under it is lit.
   No hot core, no spikes, no rhythm - one soft pool that arrives slowly
   and leaves slowly.

   This is the gentle end of the cursor treatments. The star announces
   itself; the torch just reveals what was already there. On something as
   small as a button the star reads as an event, which is too much for a
   surface you are only passing over on the way to clicking it.

   Speed is the whole character. --lit is ramped by JS over the duration
   in data-lit-in / data-lit-out, so "slowly illuminates" is a number in
   the markup rather than a value buried in here. */

.tx-torch {
  --tx-torch-size: 120px;
  --tx-torch-color: var(--gold);
  position: relative;
  overflow: hidden;
}

.tx-torch::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  opacity: var(--lit, 0);

  /* A length, not a percentage: percentages are invalid for a circle
     radius and would silently kill the whole declaration. */
  background: radial-gradient(
    circle var(--tx-torch-size) at var(--mx) var(--my),
    color-mix(in srgb, var(--tx-torch-color) 50%, transparent) 0%,
    color-mix(in srgb, var(--tx-torch-color) 26%, transparent) 38%,
    color-mix(in srgb, var(--tx-torch-color) 8%, transparent) 66%,
    transparent 82%
  );
}

/* On the amber CTA the light has to be cold or it has nothing to contrast
   against - the surface is already bright and already warm. */
.cta.tx-torch { --tx-torch-color: var(--steel-050); }
