/*
 * @file Motion.css
 * @copyright X Laboratory B.V - All Rights Reserved
 * Unauthorized copying of this file, via any medium is strictly prohibited
 * Proprietary and confidential
 * @author Andre Schiele <andre@deltaxlab.com>
 * @author Claude code
 * @date 2026/08/27
 * @brief Transition choreography. Kept in its own file so the motion language
 *        can be tuned, or removed wholesale, without touching layout.
 *
 * The vocabulary is deliberately narrow:
 *   - every timing function is a steps() function, so motion is quantised;
 *   - the wipe uncovers content in horizontal bands, not as a fade;
 *   - a single raster line sweeps once per navigation;
 *   - grain appears only while a transition runs, never while the kiosk idles.
 *
 * There are two speeds, both written by App.js from the "motion" block of
 * config/app.json:
 *
 *   --motion-duration       the page change - wipe, raster, tiles
 *   --motion-text-duration  the text effect - heading decode and speckles
 *
 * Everything else is a fixed ratio of one of those two, so retiming means
 * changing one number in the configuration file, not editing this stylesheet.
 */

:root {
  /* Overwritten at startup from config/app.json. The value here is only the
     fallback for a stylesheet loaded without the application. */
  --motion-duration: 650ms;
  --motion-text-duration: 1000ms;

  --motion-veil: calc(var(--motion-duration) * 0.72);
  --motion-raster: calc(var(--motion-duration) * 0.9);
  --motion-arrive: calc(var(--motion-duration) * 0.55);
  --motion-rule: calc(var(--motion-duration) * 0.85);
  --motion-tile: calc(var(--motion-duration) * 0.45);
  --motion-tile-step: calc(var(--motion-duration) * 0.06);

  /* The offshore-wind pictogram layer of the wipe. App.js switches this to
     "none" when motion.veilPattern is false in config/app.json. */
  --veil-pattern: url("../assets/images/TransitionPattern.svg");
}

#app[data-veil="off"] {
  --veil-pattern: none;
}

/* ------------------------------------------------------------- the wipe */

/* A band curtain over the content region. Retracts upward in discrete steps,
   which reads as a scanned transmission rather than a dissolve.
   Three layers: offshore-wind pictograms, a scan-line rule, and the ground.
   The pictograms are what the visitor half-sees going past - a turbine, a
   monopile, a vessel - so the wipe belongs to this company rather than being
   generic sci-fi. */
#page::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 4;
  pointer-events: none;
  opacity: 0;
  background-image:
    var(--veil-pattern),
    repeating-linear-gradient(
      to bottom,
      var(--veil) 0,
      var(--veil) 3px,
      transparent 3px,
      transparent 6px
    ),
    linear-gradient(var(--veil-solid), var(--veil-solid));
  background-size: 440px 440px, auto, auto;
  background-repeat: repeat, repeat, no-repeat;
}

#app.is-transitioning #page::before {
  animation: veil-retract var(--motion-veil) steps(12, end) both;
}

@keyframes veil-retract {
  from { opacity: 1; clip-path: inset(0 0 0 0); background-position: 0 0, 0 0, 0 0; }
  99%  { opacity: 1; clip-path: inset(0 0 100% 0); background-position: -26px 34px, 0 0, 0 0; }
  to   { opacity: 0; clip-path: inset(0 0 100% 0); background-position: -26px 34px, 0 0, 0 0; }
}

/* ---------------------------------------------------------- raster line */

/* One pass of a thin signal line, top to bottom, in visible jumps. */
#page::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  height: 1px;
  z-index: 5;
  pointer-events: none;
  opacity: 0;
  background: linear-gradient(90deg, transparent, var(--signal) 18%, var(--signal) 82%, transparent);
}

#app.is-transitioning #page::after {
  animation: raster-sweep var(--motion-raster) steps(22, end) both;
}

@keyframes raster-sweep {
  from { opacity: 0.9; transform: translateY(0); }
  90%  { opacity: 0.9; }
  to   { opacity: 0; transform: translateY(100vh); }
}

/* ------------------------------------------------------------- speckles */

/*
 * The field of individually moving points. Movement is a transition, not an
 * animation: each speckle is given a new target when the page changes, travels
 * there and holds. Between page changes nothing is running at all.
 *
 * The stagger comes from each speckle's own --speckle-delay, which is what
 * makes the field come apart progressively rather than all at once.
 */
#speckles {
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  overflow: hidden;
  /* The field gathers towards the middle and thins out before the chrome, but
     not nearly as tightly as it once did: masked to a small circle it was
     invisible on an ordinary screen and only read on a very large one. */
  mask-image: radial-gradient(ellipse 78% 82% at 50% 44%, #000 30%, transparent 92%);
}

.speckle {
  position: absolute;
  width: var(--speckle-size, 2px);
  height: var(--speckle-size, 2px);
  /* Not pure white: a neutral point reads warm against a blue-leaning ground,
     so the light sources are given the same cool cast as the rest of the
     palette. */
  background: var(--speckle-colour, #eef4fa);
  opacity: var(--speckle-opacity, 0.2);
  transform: translate(var(--speckle-dx, 0px), var(--speckle-dy, 0px));
  /* --speckle-duration is set only while the field is being stirred by
     scrolling, which wants a slower, unstaggered move than a page change. */
  transition:
    transform var(--speckle-duration, var(--motion-text-duration)) steps(7, end),
    opacity var(--speckle-duration, var(--motion-text-duration)) steps(5, end);
  transition-delay: calc(var(--motion-text-duration) * var(--speckle-delay, 0) * 0.5);
}

/* ------------------------------------------------------- content arrival */

/* The page block resolves in four opacity steps while rising a short way. No
   blur: it is expensive on a large display and reads as soft rather than
   mechanical. */
.page__node {
  animation: node-arrive var(--motion-arrive) steps(5, end) both;
}

@keyframes node-arrive {
  from { opacity: 0; transform: translateY(10px); }
  to   { opacity: 1; transform: none; }
}

/* The heading rule draws itself across, in steps, after the text resolves. */
.node__header::after {
  animation: rule-extend var(--motion-rule) steps(16, end) both;
  animation-delay: calc(var(--motion-duration) * 0.12);
}

@keyframes rule-extend {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}

/* Tiles land one after another. The stagger is capped: with a long menu the
   later tiles would otherwise arrive noticeably late. */
.tiles__item {
  animation: tile-arrive var(--motion-tile) steps(4, end) both;
}

.tiles__item:nth-child(1)  { animation-delay: calc(var(--motion-tile-step) * 1); }
.tiles__item:nth-child(2)  { animation-delay: calc(var(--motion-tile-step) * 2); }
.tiles__item:nth-child(3)  { animation-delay: calc(var(--motion-tile-step) * 3); }
.tiles__item:nth-child(4)  { animation-delay: calc(var(--motion-tile-step) * 4); }
.tiles__item:nth-child(5)  { animation-delay: calc(var(--motion-tile-step) * 5); }
.tiles__item:nth-child(6)  { animation-delay: calc(var(--motion-tile-step) * 6); }
.tiles__item:nth-child(n+7) { animation-delay: calc(var(--motion-tile-step) * 7); }

@keyframes tile-arrive {
  from { opacity: 0; transform: translateY(14px); }
  to   { opacity: 1; transform: none; }
}

/* ------------------------------------------------------------ tile press */

/* The tile the visitor touched flashes its edge once before the page changes,
   so the machine visibly acknowledges the input. */
.tile:active {
  border-color: var(--signal);
}

/* --------------------------------------------------------- reduced motion */

/* One switch turns the whole choreography off. The reset stylesheet already
   collapses durations; these rules additionally remove the wipe surfaces so
   nothing flashes. */
@media (prefers-reduced-motion: reduce) {
  #page::before,
  #page::after {
    animation: none !important;
    opacity: 0 !important;
  }

  .page__node,
  .tiles__item,
  .node__header::after {
    animation: none !important;
    opacity: 1 !important;
    transform: none !important;
  }
}


/* ------------------------------------------------- the heading pictograms */

/*
 * The heading dissolves through pictograms rather than block characters. Each
 * character becomes a cell; the cell the band is over hides its glyph and
 * shows a pictogram on top of it.
 *
 * The character is hidden with visibility, not removed, so its advance width
 * still applies and the heading cannot reflow while the band travels. Cells
 * are grouped into words with nowrap so line breaking still happens only at
 * spaces.
 */
.decode__word {
  white-space: nowrap;
}

.decode__cell {
  position: relative;
}

.decode__cell.is-masked .decode__char {
  visibility: hidden;
}

/*
 * Both kinds of overlay are white. The heading paints its own glyphs through a
 * background clipped to the text, so its colour is transparent and the noise
 * has to state its colour explicitly - and it has to be plain white, not the
 * signal colour, so the noise reads as the same material as the type.
 */
.decode__noise,
.decode__glyph {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  color: var(--noise-colour, #f2f7fb);
  -webkit-text-fill-color: var(--noise-colour, #f2f7fb);
  pointer-events: none;
}

/* Block characters: monospace, because that is where the block-drawing glyphs
   are reliably present and evenly weighted. */
.decode__noise {
  font-family: var(--font-mono);
  font-size: 0.82em;
  line-height: 1;
  white-space: pre;
}

/* Drawn from the attribute, so the block character is not a text node and
   cannot leak into the heading's textContent. */
.decode__noise::before {
  content: attr(data-noise);
}

/* Stamped pictograms: a block of the same visual weight as a block character,
   with the pictogram punched through it. */
.decode__glyph {
  top: 48%;
  width: 0.78em;
  height: 0.78em;
  overflow: visible;
}

/* ------------------------------------------------------- the wordmark X */

/*
 * The orbit-X is never scrambled - it is the logo. It does take part in the
 * arrival, though: a short stepped flicker, so the whole lockup reads as one
 * transmission settling rather than the word animating beside a static mark.
 */
/* No fill mode on purpose. A stepped timing function is not guaranteed to
   present its final keyframe, so holding the animated value after the run
   could leave the logo dimmed. Without a fill the element simply returns to
   its normal full opacity the moment the animation ends. */
#app.is-decoding .wordmark__symbol--image {
  animation: symbol-acquire var(--motion-text-duration) steps(5, end);
}

@keyframes symbol-acquire {
  0%   { opacity: 0.12; }
  35%  { opacity: 1; }
  55%  { opacity: 0.45; }
  75%  { opacity: 1; }
  100% { opacity: 1; }
}

@media (prefers-reduced-motion: reduce) {
  #app.is-decoding .wordmark__symbol--image {
    animation: none !important;
    opacity: 1 !important;
  }

  /* The field stays composed but stops moving. */
  .speckle {
    transition: none !important;
    transform: none !important;
  }
}

#app[data-motion="off"] .wordmark__symbol--image {
  animation: none !important;
  opacity: 1 !important;
}

#app[data-motion="off"] .speckle {
  transition: none !important;
  transform: none !important;
}


/* --------------------------------------------------------- wordmark zap */

/*
 * The occasional glitch on the title mark. Bands snap between positions -
 * steps(1), never eased - so it reads as electrical rather than as something
 * sliding. Each band runs the same keyframes at its own offsets and its own
 * speed, which is what makes the three of them zig-zag against each other
 * instead of moving as a block.
 *
 * Nothing here runs unless .is-zapping is on the heading, and Wordmark.js
 * takes it off again after a few hundred milliseconds.
 */
@keyframes wordmark-zap {
  0%   { opacity: 0; transform: translateX(0); }
  9%   { opacity: 1; transform: translateX(var(--zap-a)); }
  24%  { opacity: 1; transform: translateX(var(--zap-b)); }
  33%  { opacity: 0; transform: translateX(0); }
  46%  { opacity: 1; transform: translateX(var(--zap-b)); }
  61%  { opacity: 1; transform: translateX(var(--zap-a)); }
  70%  { opacity: 0; transform: translateX(0); }
  84%  { opacity: 1; transform: translateX(var(--zap-c)); }
  100% { opacity: 0; transform: translateX(0); }
}

/* The real mark cuts out under the displaced bands. Direct children only:
   the copies inside the bands carry the same classes. */
@keyframes wordmark-flicker {
  0%, 100% { opacity: 1; }
  9%, 24%  { opacity: 0.42; }
  33%      { opacity: 1; }
  46%, 61% { opacity: 0.5; }
  70%      { opacity: 1; }
  84%      { opacity: 0.36; }
}

.node__title--wordmark.is-zapping > .wordmark__symbol,
.node__title--wordmark.is-zapping > .wordmark__text {
  animation: wordmark-flicker 380ms steps(1, end);
}

.node__title--wordmark.is-zapping .wordmark__band {
  animation-name: wordmark-zap;
  animation-timing-function: steps(1, end);
  animation-fill-mode: none;
}

.node__title--wordmark.is-zapping .wordmark__band--1 {
  --zap-a: 0.62rem;
  --zap-b: -0.34rem;
  --zap-c: 0.2rem;
  animation-duration: 380ms;
}

.node__title--wordmark.is-zapping .wordmark__band--2 {
  --zap-a: -0.8rem;
  --zap-b: 0.46rem;
  --zap-c: -0.24rem;
  animation-duration: 320ms;
}

.node__title--wordmark.is-zapping .wordmark__band--3 {
  --zap-a: 0.36rem;
  --zap-b: -0.68rem;
  --zap-c: 0.44rem;
  animation-duration: 420ms;
}

/* Motion off, or a visitor who has asked for less of it, gets the clean mark
   and nothing else. */
#app[data-motion="off"] .wordmark__band {
  display: none;
}

@media (prefers-reduced-motion: reduce) {
  .node__title--wordmark.is-zapping .wordmark__band,
  .node__title--wordmark.is-zapping > .wordmark__symbol,
  .node__title--wordmark.is-zapping > .wordmark__text {
    animation: none;
  }
}
