/* ==========================================================================
   sneck - reactive.css - the state + motion layer (wave 35, lane R2)
   --------------------------------------------------------------------------
   The sheet that REACTS to LiveView's lifecycle: data-attribute states,
   Phoenix's own phx-* classes, :has() parent-state rules, the motion
   vocabulary (transition shorthands + entry keyframes), and the
   skeleton family. app.css owns the TOKENS; this sheet owns the
   REACTION. Surfaces adopt the attributes (notes/w35_r2_reactive.md):

     data-loading="true"   the busy surface (skeletons / dim / spinner)
     data-empty="true"     the empty collection (layout swap)
     data-error="true"     the error state (the bar slides in)
     data-selected         the multi-select member (parents react via :has())
     data-collapsed        the collapsed panel (grid-template transition)

   Everything here honors the reduced-motion kill-switch (app.css keeps
   the a11y-pinned original; the EXTENDED switch at the bottom of this
   file adds the delay reset the vocabulary below needs).
   ========================================================================== */

/* -- motion tokens ----------------------------------------------------------
   app.css owns --dur-fast/--dur-base/--ease (the language's two
   durations, one curve - §2 of the design language). This layer adds
   the FULL transition shorthands and the two extra curves the reactive
   system needs. Token additions are sanctioned by the R2 contract and
   reported to the integrator: --ease-out/--ease-spring/--ease-linear,
   --dur-slow, --transition-fast/base/slow. */

:root {
  /* The curve family: --ease-out IS the language curve; --ease-spring
     is the playful bounce for cards (a hair past 1 so it settles);
     --ease-linear belongs to progress/shimmer fills only. */
  --ease-out: var(--ease);
  --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
  --ease-linear: linear;

  /* The third duration: the rail collapse + surface swap (400ms). */
  --dur-slow: 400ms;

  /* The shorthands surfaces transition with - duration + curve in one
     var, so a motion pass is a one-token change. */
  --transition-fast: var(--dur-fast) var(--ease-out);
  --transition-base: var(--dur-base) var(--ease-out);
  --transition-slow: var(--dur-slow) var(--ease-out);
}

/* ==========================================================================
   THE STATE SYSTEM - data attributes as the reactive contract
   ========================================================================== */

/* -- [data-loading] ---------------------------------------------------------
   The busy state. Set by the surface (its assigns) or by app.js's page
   bridge; CSS does three jobs: dims the container's real content,
   keeps skeletons lit, and swaps buttons to the spinner (the button
   half lives with the phx-* classes below). */

[data-loading='true'] {
  cursor: progress;
}

/* Direct children only - deeper structure dims with its parent, and
   the surface opts elements OUT with [data-loading-keep] (toolbar
   chrome that must stay clickable while a column loads). body carries
   the page-level bridge's attribute; its ONE child (the app root) must
   never dim wholesale - the page-level busy look is the hairline
   below, nothing louder. */
[data-loading='true']:not(body) > * {
  transition: opacity var(--transition-base);
}

[data-loading='true']:not(body) > *:not(.skeleton):not([data-loading-keep]) {
  opacity: 0.45;
  pointer-events: none;
  user-select: none;
}

/* The page-level busy look (body[data-loading], set while a navigation
   or patch round-trip is in flight): one accent hairline growing in
   from the left edge - Linear's affordance, never a content dim. */
body[data-loading='true']::before {
  content: '';
  position: fixed;
  top: 0;
  left: 0;
  z-index: 100;
  width: 100vw;
  height: 2px;
  background: linear-gradient(90deg, var(--accent), var(--accent-dim));
  transform-origin: left;
  animation: page-progress var(--dur-slow) var(--ease-out) forwards;
}

@keyframes page-progress {
  from {
    transform: scaleX(0);
  }

  to {
    transform: scaleX(0.7);
  }
}

/* -- [data-empty] -----------------------------------------------------------
   The surface mounts this when its collection is empty; CSS swaps the
   layout to the empty state - the container becomes the centered stack
   the empty-state block (rule 9: one-line explanation + the single
   next action) renders into. --empty-min tunes the stage per surface. */

[data-empty='true'] {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--sp-3);
  min-height: var(--empty-min, 200px);
  text-align: center;
}

[data-empty='true'] > [data-empty-hint] {
  margin: 0;
  color: var(--muted);
  font-size: var(--size-sm);
  line-height: var(--lh-sm);
}

/* -- [data-error] -----------------------------------------------------------
   The error state. The bar slot is display:none at rest and slides in
   from the surface's top edge when the state lands; the flash error is
   the bar on standard surfaces, so it carries the same entry. The FORM
   shake keys off the same state (and Phoenix's .phx-error container -
   see the phx section). */

[data-error-bar] {
  display: none;
}

[data-error='true'] [data-error-bar] {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  padding: var(--sp-2) var(--sp-3);
  background: var(--danger-wash);
  border: 1px solid var(--danger-border);
  border-radius: var(--r-md);
  color: var(--danger-text);
  font-size: var(--size-sm);
  line-height: var(--lh-sm);
  animation: error-bar-in var(--transition-base);
}

.flash-error {
  animation: error-bar-in var(--transition-base);
}

@keyframes error-bar-in {
  from {
    opacity: 0;
    transform: translateY(calc(var(--sp-2) * -1));
  }
}

/* The form shake: three quick oscillations, gone in 400ms - feedback,
   not punishment. Triggers on the surface's data-error (its assigns
   set it beside the flash) and on Phoenix's .phx-error container. */
form[data-error='true'],
.phx-error form {
  animation: form-shake 400ms var(--ease-out);
}

@keyframes form-shake {
  0%,
  100% {
    transform: translateX(0);
  }

  20% {
    transform: translateX(calc(var(--sp-1) * -1));
  }

  40% {
    transform: translateX(var(--sp-1));
  }

  60% {
    transform: translateX(calc(var(--sp-1) * -0.5));
  }

  80% {
    transform: translateX(calc(var(--sp-1) * 0.5));
  }
}

/* -- [data-selected] --------------------------------------------------------
   The multi-select state. The ITEM carries the attribute (or the
   legacy .selected class - the board's server-rendered state); the
   PARENT reactions (:has()) live in their own section below. The item
   treatment composes with the surface sheets' own .selected rules -
   the accent edge is the shared floor, sheets may richen it. */

[data-selected] {
  background: var(--accent-soft);
  border-color: var(--accent-dim);
}

/* -- [data-collapse] / [data-collapsed] -------------------------------------
   The collapsible panel (rail groups, detail sidebar sections): the
   grid-template-rows 0fr <-> 1fr interpolation IS the animation - no
   max-height hacks, no JS measuring. The panel carries data-collapse;
   the data-collapsed STATE (present = collapsed) toggles it. The inner
   child's min-height/overflow are what make 0fr honest (grid floors an
   auto child at min-content without them). */

[data-collapse] {
  display: grid;
  grid-template-rows: 1fr;
  transition: grid-template-rows var(--transition-slow);
}

[data-collapse][data-collapsed] {
  grid-template-rows: 0fr;
}

[data-collapse] > * {
  min-height: 0;
  overflow: hidden;
}

/* ==========================================================================
   PHOENIX'S BUILT-IN CLASSES - leveraged, never re-implemented
   --------------------------------------------------------------------------
   LiveView adds phx-<event>-loading to the BOUND element while a push
   is in flight (phx-click-loading, phx-submit-loading on buttons and
   forms) and cycles phx-loading / phx-error on the view CONTAINER.
   The button half is where CSS earns its keep: the spinner appears,
   the label steps aside, hover freezes.
   ========================================================================== */

/* The busy button recipe: label goes transparent (the box keeps its
   size - no layout shift), the spinner takes over, further clicks are
   dead. .btn[data-loading] rides the same recipe so markup can drive
   the look without a push in flight (the ui.button component's
   loading prop). form.phx-submit-loading lights its submit button -
   LiveView classes the FORM, not the button. */
button.phx-click-loading,
button.phx-submit-loading,
form.phx-submit-loading button[type='submit'],
button[data-loading='true'],
.btn[data-loading='true'] {
  position: relative;
  color: transparent;
  pointer-events: none;
  cursor: progress;
}

/* The spinner: a 14px ring, currentColor-styled per variant below. */
button.phx-click-loading::after,
button.phx-submit-loading::after,
form.phx-submit-loading button[type='submit']::after,
button[data-loading='true']::after,
.btn[data-loading='true']::after {
  content: '';
  position: absolute;
  inset: 0;
  margin: auto;
  width: 14px;
  height: 14px;
  border-radius: var(--r-pill);
  border: 2px solid var(--accent-dim);
  border-top-color: var(--accent);
  animation: spin 700ms var(--ease-linear) infinite;
}

/* Primary buttons (mint fill): the accent ring would vanish on the
   accent - the ink pair carries it there. */
.btn-primary.phx-click-loading::after,
.btn-primary.phx-submit-loading::after,
.btn-primary[data-loading='true']::after,
.auth-submit.phx-click-loading::after,
.auth-submit.phx-submit-loading::after,
.auth-submit[data-loading='true']::after,
form.phx-submit-loading .btn-primary::after,
form.phx-submit-loading .auth-submit::after {
  border-color: var(--primary-disabled-bg);
  border-top-color: var(--accent-ink);
}

/* Hover is disabled while busy - the click already happened; the lift
   would promise a second one. These out-specify app.css's
   :hover rules on purpose (one extra class in the chain). */
.btn-primary.phx-click-loading:hover:not(:disabled),
.btn-primary.phx-submit-loading:hover:not(:disabled),
.btn-primary[data-loading='true']:hover:not(:disabled),
.auth-submit.phx-click-loading:hover:not(:disabled),
.auth-submit.phx-submit-loading:hover:not(:disabled),
.auth-submit[data-loading='true']:hover:not(:disabled) {
  background: var(--accent);
}

@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

/* DELIBERATELY ABSENT: the change-loading class on inputs. It lands
   the moment a field pushes (every keystroke with debounce); blanking
   or spinner-ing a field the user is typing into is the one
   regression this system must never ship. */

/* -- the socket drop ---------------------------------------------------------
   A quiet "Reconnecting..." ribbon pinned under the top edge - the
   page stays readable and scrollable while the socket retries, NEVER
   a full-screen block. Two hooks: this vendored LiveView build ships
   no .phx-disconnected class (its container classes cycle only
   phx-connected/phx-loading/phx-error), so app.js's state bridge
   mirrors the socket's onClose/onOpen into body[data-disconnected];
   the .phx-disconnected selector below is carried for LiveView builds
   that do set it on a container. */

body[data-disconnected]::after,
.phx-disconnected[data-phx-session]::after {
  content: 'Reconnecting...';
  position: fixed;
  top: var(--sp-2);
  left: 50%;
  transform: translateX(-50%);
  z-index: 100;
  padding: var(--sp-1) var(--sp-3);
  background: var(--bar-panel);
  border: 1px solid var(--warning-border);
  border-radius: var(--r-pill);
  color: var(--warning-text);
  font-size: var(--size-xs);
  font-weight: 600;
  box-shadow: var(--shadow-2);
  backdrop-filter: blur(4px);
  animation: fade-slide-down var(--transition-base);
}

/* ==========================================================================
   :HAS() PARENT-STATE - the modern reactive primitive (baseline 2023+)
   --------------------------------------------------------------------------
   The child carries the state; the PARENT restyles without a single
   per-row class toggle or server round-trip.
   ========================================================================== */

/* The card wrapper (the ui.card shell / the board's lane rollups):
   a selected child means the wrapper is a selection context - the
   accent hairline says so. Legacy .selected honored: the board's
   server-rendered multi-select speaks it. */
.card-wrapper:has(.card.selected),
.card-wrapper:has([data-selected]) {
  border-radius: var(--r-md);
  box-shadow: inset 0 0 0 1px var(--accent-dim);
}

/* The column being dragged THROUGH: SortableJS lifts the card
   (.sortable-drag - the element that follows the pointer) and the
   contract's .dragging is honored for the ui-era drag hooks. The
   column holding the flight glows its water. The DROP target's glow
   stays keyed on the ghost (.cards:has(.sortable-ghost) in app.css -
   the ghost always sits in the hovered container). */
.column:has(.issue-card.dragging),
.column:has(.issue-card.sortable-drag) {
  border-color: var(--accent-dim);
  background: var(--accent-soft);
}

/* The form group whose input is invalid - the label turns warning.
   Interaction-gated ON PURPOSE: a naked :has(input:invalid) would
   flag every required-but-untouched field from page load (the
   pristine-form false flag). :user-invalid (baseline 2023+, the same
   era as :has()) fires only after the user has edited; the :focus
   twin is the fallback for engines without it and the live signal
   while fixing. */
.form-group:has(input:user-invalid) .form-label,
.form-group:has(input:invalid:focus) .form-label {
  color: var(--warning-text);
}

.form-group:has(input:user-invalid) input,
.form-group:has(input:invalid:focus) input {
  border-color: var(--warning);
}

/* ==========================================================================
   THE MOTION VOCABULARY
   --------------------------------------------------------------------------
   Entry animations are @keyframes + a data-attribute trigger. Exit is
   LiveView's DOM removal (the patch lifecycle) - CSS animates what
   ARRIVES; what leaves, leaves (the calm doctrine: no exit theater).
   ========================================================================== */

@keyframes fade-slide-up {
  from {
    opacity: 0;
    transform: translateY(var(--sp-2));
  }
}

@keyframes fade-slide-down {
  from {
    opacity: 0;
    transform: translateY(calc(var(--sp-2) * -1));
  }
}

@keyframes scale-in {
  from {
    opacity: 0;
    transform: scale(0.96);
  }
}

@keyframes slide-in-right {
  from {
    opacity: 0;
    transform: translateX(var(--sp-4));
  }
}

@keyframes fade {
  from {
    opacity: 0;
  }
}

/* The named entries: cards, modals and popovers ride fade-slide-up /
   scale-in; slide-in-right is the toast/banner direction (R4's
   offline + install banners, the notifications surface's calls).
   [data-motion] is the accepted ALIAS - R4's surfaces adopted that
   name before the vocabulary landed; data-animate is canonical, the
   alias rides the same keyframes so both spellings move identically. */
[data-animate='fade-slide-up'],
[data-motion='fade-slide-up'] {
  animation: fade-slide-up var(--transition-base);
}

[data-animate='scale-in'],
[data-motion='scale-in'] {
  animation: scale-in var(--transition-base);
}

[data-animate='slide-in-right'],
[data-motion='slide-in-right'] {
  animation: slide-in-right var(--transition-base);
}

[data-animate='fade'],
[data-motion='fade'] {
  animation: fade var(--transition-base);
}

/* Stagger: the parent opts its children in; each child enters 15ms
    after the one before it, capped at 120ms so a long list still
    converges (backwards fill holds the pre-entry state during the
    delay - the step that makes stagger read as choreography and not
    a wave of pop-ins). */
[data-animate-stagger] > * {
  animation: fade-slide-up var(--transition-base) backwards;
}

[data-animate-stagger] > *:nth-child(2) {
  animation-delay: 15ms;
}

[data-animate-stagger] > *:nth-child(3) {
  animation-delay: 30ms;
}

[data-animate-stagger] > *:nth-child(4) {
  animation-delay: 45ms;
}

[data-animate-stagger] > *:nth-child(5) {
  animation-delay: 60ms;
}

[data-animate-stagger] > *:nth-child(6) {
  animation-delay: 75ms;
}

[data-animate-stagger] > *:nth-child(7) {
  animation-delay: 90ms;
}

[data-animate-stagger] > *:nth-child(8) {
  animation-delay: 105ms;
}

[data-animate-stagger] > *:nth-child(n + 9) {
  animation-delay: 120ms;
}

/* ==========================================================================
   THE MODAL + SHEET ENTRIES - the globals moved from app.css (wave 35 R2)
   --------------------------------------------------------------------------
   app.css keeps the tokens and the component boxes; the ANIMATION
   wiring lives here with the rest of the motion vocabulary. The modal
   entry is the contract's scale-in (0.96 -> 1) over the fading
   backdrop; phones (<768px) trade it for the bottom-sheet rise.
   ========================================================================== */

.modal-backdrop {
  animation: fade var(--dur-fast) var(--ease);
}

.modal-panel {
  animation: scale-in var(--transition-base);
}

@keyframes sheet-in {
  from {
    transform: translateY(var(--sp-7));
    opacity: 0;
  }
}

/* The phone sheet list mirrors app.css's responsive block (the same
   classes, the same body-prefix discipline). If a popover class joins
   THAT list, it joins this one - flagged for the integrator. */
@media (max-width: 767px) {
  .modal-panel,
  body .lp-popover,
  body .flt-popover,
  body .cycle-menu,
  body .os-menu,
  body .template-pop,
  body .vmenu,
  body .bcol-menu,
  body .nb-panel,
  body .rel-popover {
    animation: sheet-in var(--transition-base);
  }
}

/* ==========================================================================
   SKELETON SCREENS - the CSS-only shimmer (no JS)
   --------------------------------------------------------------------------
   One base recipe (the animated gradient wash) + the three shape
   variants the surfaces actually show while loading: the board
   column's cards, the list's initial fetch rows, the detail modal's
   content, the notifications page. NOTE (integration): R1's component
   library landed its own namespaced family (.ui-skel-line/-block/
   -circle + ui-shimmer in ui.css) - the two coexist cleanly (no shared
   names); convergence is the integrator's call. This family is the
   reactive-layer contract's (.skeleton, per notes/w35_r2_reactive.md).
   ========================================================================== */

.skeleton {
  display: block;
  width: 100%;
  border-radius: var(--r-sm);
  background-color: var(--wash-2);
  background-image: linear-gradient(
    90deg,
    transparent,
    color-mix(in srgb, var(--text) 5%, transparent),
    transparent
  );
  background-size: 200% 100%;
  background-repeat: no-repeat;
  animation: skeleton-shimmer 1600ms var(--ease-linear) infinite;
}

/* The shapes: a text line (full-height words), a card block (board
   column placeholder), a table row (list fetch placeholder). Heights
   are the surfaces' resting row heights - skeletons that change the
   layout when the content lands are worse than no skeletons. */
.skeleton-text-line {
  height: 12px;
  border-radius: var(--r-pill);
}

.skeleton-card {
  height: 84px;
  border-radius: var(--r-md);
}

.skeleton-table-row {
  height: 36px;
  border-radius: var(--r-sm);
}

@keyframes skeleton-shimmer {
  from {
    background-position: 200% 0;
  }

  to {
    background-position: -200% 0;
  }
}

/* ==========================================================================
   THE EXTENDED KILL-SWITCH
   --------------------------------------------------------------------------
   app.css keeps the original block (the a11y suite pins it there);
   this copy EXTENDS it with the animation-delay reset the stagger
   vocabulary needs - a delayed 0.01ms animation is still a delay.
   Both blocks agree on the doctrine: durations collapse, iteration
   counts clamp to 1, the shimmer/spinner freeze at a static wash.
   ========================================================================== */

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-delay: 0ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    transition-delay: 0ms !important;
    scroll-behavior: auto !important;
  }
}
