/* ═══════════════════════════════════════════════════════════════════
   ip-motion.css — the "floaty premium" layer.

   Three families of motion, all opt-in via data-attributes or classes so
   nothing here changes a page until that page asks for it:

     [data-float]      idle buoyancy on a surface
     [data-magnetic]   cursor-following lift on a control
     .sk-link          animated underline/arrow link treatments

   Everything degrades to a static, fully-visible layout when JS is off
   or the OS asks for reduced motion — the hidden states are applied by
   ip-motion.js (via html.ipm-on), never by this file alone.
   ══════════════════════════════════════════════════════════════════ */

:root {
  /* One easing vocabulary. "Glide" is the workhorse — exponential ease-out,
     which is how real objects decelerate.

     There is deliberately no overshoot/bounce curve here. Where this layer
     wants spring physics it uses a real solver (Motion's `type: 'spring'`,
     see the SPRING_* constants in ip-motion.js), which settles against
     stiffness and damping. A cubic-bezier that overshoots is an imitation
     of that and reads dated. */
  --ipm-glide: cubic-bezier(0.22, 1, 0.36, 1);
  --ipm-exit:  cubic-bezier(0.4, 0, 1, 1);
}

/* ═══════════════════════════════════════════
   1. SCROLL REVEAL (upgrade of [data-reveal])

   The base template already reveals with a flat 18px translate. These
   add directional and staggered variants for editorial sections, and a
   slight scale so cards feel like they settle rather than slide.
   ═══════════════════════════════════════════ */
html.ipm-on [data-ipm-reveal] {
  opacity: 0;
  will-change: opacity, transform;
  transition:
    opacity   0.75s var(--ipm-glide),
    transform 0.85s var(--ipm-glide);
  transition-delay: var(--ipm-delay, 0s);
}
html.ipm-on [data-ipm-reveal="up"]    { transform: translate3d(0, 34px, 0) scale(0.985); }
html.ipm-on [data-ipm-reveal="down"]  { transform: translate3d(0, -28px, 0); }
html.ipm-on [data-ipm-reveal="left"]  { transform: translate3d(-38px, 0, 0); }
html.ipm-on [data-ipm-reveal="right"] { transform: translate3d(38px, 0, 0); }
html.ipm-on [data-ipm-reveal="scale"] { transform: scale(0.93); }
html.ipm-on [data-ipm-reveal="blur"]  { transform: translate3d(0, 20px, 0); filter: blur(9px); }

html.ipm-on [data-ipm-reveal].ipm-in {
  opacity: 1;
  transform: none;
  filter: none;
}
/* Once it has landed, stop promoting the layer — a permanent will-change
   on dozens of elements costs real memory on low-end Android. */
html.ipm-on [data-ipm-reveal].ipm-settled { will-change: auto; }

/* Spring entrances. Held at 0 opacity only while html.ipm-on is set AND
   the element has not yet been played — ip-motion.js stamps
   [data-ipm-played] unconditionally, including when Motion is missing or
   motion is reduced, so this can never strand content invisible. */
html.ipm-on [data-ipm-enter]:not([data-ipm-played]) { opacity: 0; }

/* Scroll parallax. The JS only writes --ipm-py; the transform lives here
   so an element with no scroll binding still renders in the right place. */
html.ipm-on [data-parallax] {
  transform: translate3d(0, var(--ipm-py, 0%), 0);
  will-change: transform;
}

/* ═══════════════════════════════════════════
   2. IDLE FLOAT

   The "floaty" quality the app is after: surfaces that breathe instead
   of sitting dead on the page. Deliberately slow (7–11s) and tiny
   (≤10px) — at that scale it reads as depth, not as animation.
   ═══════════════════════════════════════════ */
@keyframes ipmFloat {
  0%, 100% { transform: translate3d(0, 0, 0) rotate(0deg); }
  50%      { transform: translate3d(0, calc(-1 * var(--ipm-float-y, 8px)), 0) rotate(var(--ipm-float-r, 0deg)); }
}
html.ipm-on [data-float] {
  animation: ipmFloat var(--ipm-float-dur, 8s) ease-in-out infinite;
  animation-delay: var(--ipm-float-delay, 0s);
}
/* Pausing on hover stops a floating card from squirming out from under
   the cursor mid-click. */
html.ipm-on [data-float]:hover { animation-play-state: paused; }

/* ═══════════════════════════════════════════
   3. MAGNETIC CONTROLS

   ip-motion.js writes --ipm-mx/--ipm-my from the pointer; the element
   only ever reads them, so the transform stays declarative and the JS
   never touches style.transform directly.
   ═══════════════════════════════════════════ */
html.ipm-on [data-magnetic] {
  transform: translate3d(var(--ipm-mx, 0px), var(--ipm-my, 0px), 0);
  transition: transform 0.45s var(--ipm-glide);
}
html.ipm-on [data-magnetic].ipm-tracking {
  /* While the pointer is inside, follow it near-instantly. The slow
     transition above then handles the release, which is what makes it
     feel elastic rather than snappy. */
  transition: transform 0.12s linear;
}

/* Depth lift — a card that rises toward the cursor. */
html.ipm-on [data-lift] {
  transition:
    transform   0.4s var(--ipm-glide),
    box-shadow  0.4s var(--ipm-glide),
    border-color 0.3s ease;
}
html.ipm-on [data-lift]:hover {
  transform: translate3d(0, -6px, 0);
  box-shadow: var(--shadow-hover, 0 18px 40px rgba(15, 23, 42, 0.14));
}

/* Restrained variant for dense rows and list items. A settings row or a
   list entry rising 6px reads as the row jumping out of the list; 1px plus
   a background tint reads as "this one is under the cursor", which is all
   a row needs to say. */
html.ipm-on [data-lift-sm] {
  transition:
    transform  0.22s var(--ipm-glide),
    background 0.22s var(--ipm-glide),
    border-color 0.22s ease;
}
html.ipm-on [data-lift-sm]:hover {
  transform: translate3d(0, -1px, 0);
  background: color-mix(in srgb, var(--accent) 5%, transparent);
}

/* 3D tilt. The JS supplies the angles; keeping perspective in CSS means
   a browser without pointer events still gets a flat, correct card. */
html.ipm-on [data-tilt] {
  transform-style: preserve-3d;
  transform:
    perspective(900px)
    rotateX(var(--ipm-rx, 0deg))
    rotateY(var(--ipm-ry, 0deg))
    translate3d(0, var(--ipm-tz, 0px), 0);
  transition: transform 0.5s var(--ipm-glide);
}
html.ipm-on [data-tilt].ipm-tracking { transition: transform 0.1s linear; }

/* ═══════════════════════════════════════════
   4. ANIMATED LINKS

   Ported from Skiper UI's "Skiper 40" animated-link set (an inspired
   rebuild of the cursor.com link treatment). The originals ship as React
   + Tailwind; these are the same six behaviours as plain classes, since
   this app is Jinja templates with no build step.

     .sk-link            underline wipes in from the left
     .sk-link--out       …and wipes out to the right on leave
     .sk-link--center    …grows from the centre
     .sk-link--fill      block fill rises, text inverts (mix-blend)
     .sk-link--sweep     block fill sweeps across, text inverts
     .sk-link--arrow     adds the ↗ that fades up on hover
   ═══════════════════════════════════════════ */
.sk-link {
  position: relative;
  display: inline-flex;
  align-items: center;
  text-decoration: none;
  color: inherit;
}
.sk-link::before {
  content: '';
  position: absolute;
  left: 0;
  bottom: -0.08em;
  width: 100%;
  height: 0.06em;
  background: currentColor;
  pointer-events: none;
  transform: scaleX(0);
  transform-origin: right;
  transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.sk-link:hover::before,
.sk-link:focus-visible::before {
  transform: scaleX(1);
  transform-origin: left;
}
/* Wipe in from the left, out to the right — the direction never reverses,
   so a fast hover-out doesn't look like it rewinds. */
.sk-link--out::before        { transform-origin: left; }
.sk-link--out:hover::before,
.sk-link--out:focus-visible::before { transform-origin: right; }

.sk-link--center::before,
.sk-link--center:hover::before,
.sk-link--center:focus-visible::before { transform-origin: center; }

/* Fill variants paint a block behind the text and let mix-blend-difference
   invert the glyphs, so one rule works on any background in either theme. */
.sk-link--fill,
.sk-link--sweep { padding: 0 0.4em; }
.sk-link--fill::before,
.sk-link--sweep::before {
  background: #fff;
  mix-blend-mode: difference;
  z-index: 1;
  bottom: 0;
}
/* The original animates height 0 → 1.4em. Same visual, driven by scaleY off
   the bottom edge instead: height is a layout property, so animating it
   re-runs layout and paint every frame, while a transform stays on the
   compositor. */
.sk-link--fill::before {
  height: 1.4em;
  transform: scaleY(0);
  transform-origin: bottom;
  transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.sk-link--fill:hover::before,
.sk-link--fill:focus-visible::before {
  transform: scaleY(1);
  transform-origin: bottom;
}

.sk-link--sweep::before {
  top: 0;
  bottom: auto;
  height: 100%;
  transform-origin: left;
}

.sk-link__arrow {
  margin-left: 0.32em;
  width: 0.58em;
  height: 0.58em;
  flex-shrink: 0;
  opacity: 0;
  transform: translateY(3px);
  transition: opacity 0.3s var(--ipm-glide), transform 0.3s var(--ipm-glide);
}
.sk-link:hover .sk-link__arrow,
.sk-link:focus-visible .sk-link__arrow {
  opacity: 1;
  transform: translateY(0);
}
.sk-link--sweep:hover .sk-link__arrow { transform: translateX(0) rotate(45deg); }
.sk-link--sweep .sk-link__arrow       { transform: translateX(-3px) rotate(45deg); }

/* ═══════════════════════════════════════════
   5. SHIMMER

   Ported from Bklit UI's shimmering-text (used there as a chart loading
   label). Same job here: a loading string that reads as "working" without
   a spinner. Masks a moving highlight over the text itself.
   ═══════════════════════════════════════════ */
@keyframes ipmShimmer {
  0%   { background-position: 120% 0; }
  100% { background-position: -20% 0; }
}
.ipm-shimmer {
  background: linear-gradient(
    100deg,
    var(--text-muted) 0%,
    var(--text-muted) 38%,
    var(--text-primary) 50%,
    var(--text-muted) 62%,
    var(--text-muted) 100%
  );
  background-size: 220% 100%;
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
  animation: ipmShimmer 2.4s linear infinite;
}

/* Guard rail. This technique paints the text with a gradient and then makes
   the glyphs themselves transparent — so if anything wins the background
   declaration, the result is not "no shimmer", it is *invisible text*.
   base.html ships exactly such a rule:
     body button:not(…) { background: var(--glass-btn-bg) !important; }
   which beats this one on any <button> and would blank the label.
   Rather than escalate to !important and strip a button of its glass mid-
   load, buttons opt out of the clip and simply dim. Put the class on a
   <span> inside the button to get the real effect. */
button.ipm-shimmer,
.ipm-shimmer:is(button, [type="button"], [type="submit"]) {
  background: none;
  -webkit-text-fill-color: currentColor;
  animation: none;
  opacity: 0.68;
}

/* A sheen that travels across a surface — for skeletons and CTAs. */
@keyframes ipmSheen {
  0%   { transform: translateX(-120%) skewX(-18deg); }
  100% { transform: translateX(220%)  skewX(-18deg); }
}
.ipm-sheen { position: relative; overflow: hidden; isolation: isolate; }
.ipm-sheen::after {
  content: '';
  position: absolute;
  inset: 0;
  width: 45%;
  pointer-events: none;
  background: linear-gradient(
    90deg,
    transparent,
    color-mix(in srgb, #fff 26%, transparent),
    transparent
  );
  animation: ipmSheen 3.6s var(--ipm-glide) infinite;
  animation-delay: 1.2s;
}
[data-theme="dark"] .ipm-sheen::after {
  background: linear-gradient(
    90deg,
    transparent,
    color-mix(in srgb, #fff 12%, transparent),
    transparent
  );
}

/* ═══════════════════════════════════════════
   6. ATMOSPHERE

   A slow-drifting accent wash. Used behind the auth panel and the hero
   to give the page depth without a hard gradient block.
   ═══════════════════════════════════════════ */
@keyframes ipmDrift {
  0%   { transform: translate3d(0, 0, 0) scale(1); }
  33%  { transform: translate3d(3%, -4%, 0) scale(1.08); }
  66%  { transform: translate3d(-3%, 3%, 0) scale(0.96); }
  100% { transform: translate3d(0, 0, 0) scale(1); }
}
.ipm-aurora {
  position: absolute;
  border-radius: 50%;
  pointer-events: none;
  filter: blur(70px);
  opacity: 0.5;
  animation: ipmDrift var(--ipm-drift-dur, 22s) ease-in-out infinite;
  animation-delay: var(--ipm-drift-delay, 0s);
}
[data-theme="dark"] .ipm-aurora { opacity: 0.34; }

/* ═══════════════════════════════════════════
   6a. SMOOTH INPUT

   Skiper 106. The original mirrors the field's value into a motion layer and
   springs each character in. That technique is deliberately NOT used here:
   it needs a shadow copy of what the user typed, which breaks IME
   composition, fights password managers, and would put a rendered duplicate
   of credentials in the DOM. Not a trade worth making on a real auth form.

   What carries over is the feel — the field itself reacts. The caret column
   springs, the frame lifts on focus, and the placeholder slides out of the
   way rather than cutting. No value mirroring, so autofill, IME and
   password managers all behave exactly as they did.

   Opt in with .ipm-input on the wrapper. Never applied automatically, and
   never to type=password.
   ═══════════════════════════════════════════ */
.ipm-input {
  position: relative;
  transition:
    border-color 0.22s var(--ipm-glide),
    box-shadow   0.28s var(--ipm-glide),
    transform    0.28s var(--ipm-glide);
}
.ipm-input:focus-within {
  transform: translate3d(0, -1px, 0);
  box-shadow: 0 0 0 3px var(--accent-soft), 0 8px 22px rgba(15, 23, 42, 0.09);
}
.ipm-input input::placeholder,
.ipm-input textarea::placeholder {
  transition: opacity 0.22s var(--ipm-glide), transform 0.22s var(--ipm-glide);
}
.ipm-input:focus-within input::placeholder,
.ipm-input:focus-within textarea::placeholder {
  opacity: 0.45;
  transform: translateX(4px);
}
/* Sweep that runs once as the field takes focus — the "settling" beat. */
.ipm-input::after {
  content: '';
  position: absolute;
  left: 0; right: 0; bottom: 0;
  height: 2px;
  background: linear-gradient(90deg, transparent, var(--accent), transparent);
  transform: scaleX(0);
  opacity: 0;
  pointer-events: none;
  transition: transform 0.42s var(--ipm-glide), opacity 0.42s var(--ipm-glide);
}
.ipm-input:focus-within::after { transform: scaleX(1); opacity: 0.9; }

@media (prefers-reduced-motion: reduce) {
  .ipm-input,
  .ipm-input::after,
  .ipm-input input::placeholder,
  .ipm-input textarea::placeholder { transition: none; }
  .ipm-input:focus-within { transform: none; }
}

/* ═══════════════════════════════════════════
   6b. GOO / SQUIRCLE FILTER

   Skiper 63. Blur the source, then push alpha through a steep colour matrix
   so partially-transparent edges snap back to opaque: neighbouring shapes
   bleed into each other and read as one soft blob while they are close,
   then separate. Pure SVG filter, no JS, no re-render cost.

   The filter element itself lives in base.html; this is the opt-in hook.
   ═══════════════════════════════════════════ */
.ipm-goo { filter: url(#ipmGoo); }
/* The filter rasterises its subtree, which kills subpixel text rendering.
   Only ever put it on a wrapper whose children are shapes — never on text. */
@media (prefers-reduced-motion: reduce) {
  .ipm-goo { filter: none; }
}

/* ═══════════════════════════════════════════
   7. THEME VIEW TRANSITION

   Ported from Skiper UI's Skiper 26. The theme swap is driven by
   setThemeMode() in base.html, which supplies the origin as
   --ip-vt-x / --ip-vt-y / --ip-vt-r; this only describes the wipe.

   The old snapshot is held still underneath and the new one is revealed
   over it, so the page appears to re-light from the control that was
   pressed rather than cross-fading as a whole.
   ═══════════════════════════════════════════ */
::view-transition-old(root),
::view-transition-new(root) {
  /* Kill the UA's default cross-fade — running it underneath the clip
     produces a muddy double-exposure through the semi-transparent
     surfaces this app uses everywhere. */
  animation: none;
  mix-blend-mode: normal;
}
::view-transition-old(root) { z-index: 1; }
::view-transition-new(root) { z-index: 2; }

@keyframes ipThemeWipe {
  from { clip-path: circle(0 at var(--ip-vt-x, 50%) var(--ip-vt-y, 50%)); }
  to   { clip-path: circle(var(--ip-vt-r, 150vmax) at var(--ip-vt-x, 50%) var(--ip-vt-y, 50%)); }
}
html.ip-vt-busy::view-transition-new(root) {
  animation: ipThemeWipe 0.52s var(--ipm-glide) both;
}

/* ═══════════════════════════════════════════
   7b. PRO-TIER PATTERNS, REBUILT

   Skiper UI's Pro components are behind a licence, so none of their source
   was available. These are original implementations of the same well-known
   interaction patterns, written from the public demo descriptions — the
   same footing Skiper themselves cite for Skiper 40 ("an inspired rebuild
   with our own taste"). No Pro code was obtained or copied.
   ═══════════════════════════════════════════ */

/* ── Scroll progress rail (pattern: "Anime js scrollbar", skiper1) ──
   A hairline at the top of the viewport tracking read position. Driven by
   --ipm-scroll (0→1) from ip-motion.js; scaleX only, so it never lays out. */
#ipmScrollRail {
  position: fixed;
  top: 0; left: 0; right: 0;
  height: 2px;
  z-index: 100020;
  pointer-events: none;
  transform: scaleX(var(--ipm-scroll, 0));
  transform-origin: left;
  background: linear-gradient(90deg,
    var(--accent), color-mix(in srgb, var(--accent) 55%, #f5a524));
  opacity: 0;
  transition: opacity 0.25s var(--ipm-glide);
}
#ipmScrollRail.is-on { opacity: 0.9; }

/* ── Dynamic island toast (pattern: "Dynamic island", skiper2) ──
   The toast enters as a narrow capsule and expands to its content width,
   the way the island does. clip-path + scale rather than width/height, so
   the text inside never reflows mid-animation. */
@keyframes ipmIslandIn {
  0%   { opacity: 0; transform: translateY(-14px) scale(0.86); clip-path: inset(0 40% 0 40% round 999px); }
  55%  { opacity: 1; }
  100% { opacity: 1; transform: translateY(0) scale(1);        clip-path: inset(0 0 0 0 round 14px); }
}
html.ipm-on .ip-toast {
  animation: ipmIslandIn 0.46s var(--ipm-glide) both;
  will-change: transform, clip-path;
}
html.ipm-on .ip-toast.is-out { animation: none; }

/* ── Bouncy accordion (pattern: "Bouncy accordion", skiper103) ──
   grid-template-rows 0fr→1fr is the one way to animate a panel to its
   natural height without measuring it in JS or animating height itself. */
html.ipm-on details.ipm-acc > .ipm-acc__body {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 0.42s var(--ipm-glide);
}
html.ipm-on details.ipm-acc[open] > .ipm-acc__body { grid-template-rows: 1fr; }
/* Closing runs while [open] is still set — the UA hides the content the
   instant the attribute goes, so there would be nothing left to animate.
   This has to beat the [open] rule above, hence the extra class. */
html.ipm-on details.ipm-acc[open].is-closing > .ipm-acc__body { grid-template-rows: 0fr; }
html.ipm-on details.ipm-acc > .ipm-acc__body > * {
  overflow: hidden;
  min-height: 0;
}

/* ── Auto-scale input (pattern: "Auto Scale input", skiper105) ──
   The field acknowledges each new height instead of silently snapping.

   Deliberately NOT `transition: height`. Both autosizers in this app
   (autoResizePlani in base.html, planiAutosize in command_center.js) work
   by setting height to auto and then reading scrollHeight — so animating
   height means the measurement that drives the resize is taken while the
   thing being measured is still moving. That risks the field settling at
   the wrong height or oscillating, and it made this file's own
   offsetHeight growth check read an intermediate value.

   The height therefore still snaps, as it always did; the acknowledgement
   is a box-shadow pulse, which is compositor-safe and cannot feed back
   into the measurement. */
html.ipm-on textarea.ipm-autoscale.ipm-grew {
  animation: ipmAutoScalePulse 0.32s var(--ipm-glide);
}
@keyframes ipmAutoScalePulse {
  0%   { box-shadow: 0 0 0 0 var(--accent-soft); }
  60%  { box-shadow: 0 0 0 4px var(--accent-soft); }
  100% { box-shadow: 0 0 0 0 rgba(0,0,0,0); }
}

/* ── Command search (pattern: "Vercel Command Search", skiper92) ──
   A single highlight that slides between rows, instead of each row
   painting its own background. The rail is positioned by JS from the
   active row's offset; only transform and height animate. */
#cmdPalette .cmd-results { position: relative; }
#cmdPalette .ipm-cmd-rail {
  position: absolute;
  left: 6px; right: 6px; top: 0;
  height: var(--ipm-rail-h, 0px);
  border-radius: 9px;
  background: var(--accent-soft);
  transform: translateY(var(--ipm-rail-y, 0px));
  transition:
    transform 0.26s var(--ipm-glide),
    height    0.26s var(--ipm-glide),
    opacity   0.18s linear;
  opacity: 0;
  pointer-events: none;
  z-index: 0;
}
#cmdPalette .ipm-cmd-rail.is-on { opacity: 1; }
/* Rows sit above the rail and stop painting their own active fill. */
#cmdPalette .cmd-item { position: relative; z-index: 1; }
html.ipm-on #cmdPalette .cmd-item.active { background: transparent; }

@media (prefers-reduced-motion: reduce) {
  #ipmScrollRail { transition: none; }
  html.ipm-on .ip-toast { animation: none; }
  html.ipm-on details.ipm-acc > .ipm-acc__body,
  /* .ipm-grew, not .ipm-autoscale — the pulse is declared on the two-class
     selector, so guarding the one-class one loses the cascade and the
     animation would still run. ip-motion.js also refuses to add the class
     under reduced motion, but it samples the setting once at load, so a
     user who changes it mid-session is relying on this rule. */
  html.ipm-on textarea.ipm-autoscale.ipm-grew,
  #cmdPalette .ipm-cmd-rail { transition: none; animation: none; }
}

/* ═══════════════════════════════════════════
   8. REDUCED MOTION / NO-JS

   The single most important block in the file. Anything that loops, and
   anything that starts invisible, is neutralised. `!important` is used
   deliberately — these must beat every inline delay and variant above.
   ═══════════════════════════════════════════ */
@media (prefers-reduced-motion: reduce) {
  html.ipm-on [data-ipm-reveal],
  html.ipm-on [data-ipm-reveal].ipm-in {
    opacity: 1 !important;
    transform: none !important;
    filter: none !important;
    transition: none !important;
  }
  html.ipm-on [data-float],
  .ipm-shimmer,
  .ipm-sheen::after,
  .ipm-aurora {
    animation: none !important;
  }
  html.ipm-on [data-ipm-enter]:not([data-ipm-played]) { opacity: 1 !important; }
  html.ipm-on [data-parallax] { transform: none !important; will-change: auto; }
  /* setThemeMode() already skips startViewTransition under reduced motion, so
     this only matters if the setting changes mid-transition. */
  html.ip-vt-busy::view-transition-new(root) { animation: none !important; }
  html.ipm-on [data-magnetic],
  html.ipm-on [data-tilt],
  html.ipm-on [data-lift],
  html.ipm-on [data-lift-sm] {
    transform: none !important;
    transition: none !important;
  }
  .sk-link::before,
  .sk-link__arrow { transition: none !important; }
  .ipm-shimmer {
    -webkit-text-fill-color: currentColor;
    background: none;
    color: var(--text-muted);
  }
}
