/* ══════════════════════════════════════════════════════════════════════
   Nivasik — motion.css

   Apple's motion signature is almost entirely SEQUENCING, not effects: a
   panel opens and its contents arrive AFTER it, which is what tells you
   the contents belong to the panel. Sequencing costs no bytes and no
   frames, which is exactly why it is the right answer on the cheap
   Android on 3G that this product actually runs on.

   RULES
     · enter  -> --ease-out
     · exit   -> --ease-out TOO, at one duration step SHORTER than the enter.
       Not --ease-in, which this file used to specify: ease-in accelerates out
       of rest, so a dismissed modal hangs for the first ~80ms of its 200ms
       exit — exactly the moment the user has decided it should be gone.
     · on-screen reposition -> --ease-out
     · --ease-emphasis -> the sheet and the drawer, nothing else
     · --ease-spring   -> spent exactly ONCE in the product, on resolve

   Curves and durations are tokens in tokens.css. Nothing here invents a
   number: before this file, `140ms`, `.14s`, `0.15s` and `.16s` all
   coexisted — a 20ms spread nobody can perceive individually, but which
   guarantees that two animations firing together visibly desync.

   The global prefers-reduced-motion reset in layout.css collapses every
   animation here to 0.01ms. `animationend` still fires at that duration,
   so the close handlers that wait for it are never stranded. The one
   thing that reset CANNOT reach is ::view-transition-*, which is why the
   page-transition block at the bottom carries its own explicit gate.
   ══════════════════════════════════════════════════════════════════════ */

/* ── Keyframes ──────────────────────────────────────────────────────── */
@keyframes nvRise     { from { opacity: 0; transform: translateY(8px); } }
@keyframes nvFadeOut  { to   { opacity: 0; } }
@keyframes nvModalOut { to   { opacity: 0; transform: translateY(6px) scale(.99); } }
@keyframes nvSheetOut { to   { transform: translateY(100%); } }
@keyframes nvRowIn    { from { opacity: 0; transform: translateY(-6px); } }
@keyframes nvRowFlash { 0%, 15% { background: var(--accent-soft); }
                        100%    { background: transparent; } }
@keyframes nvBreach   { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.12); } }
@keyframes nvResolve  { from { transform: scale(.7); opacity: 0; } }

/* ── 1 · Panels are symmetric ───────────────────────────────────────────
   Everything used to glide in over 220–280ms and then TELEPORT away:
   both close paths called backdrop.remove() outright. Asymmetric motion
   is the clearest possible tell of unfinished work — it teaches the user
   that dismissing something is destructive. Exits are one step shorter
   than entries, which reads as decisive rather than sluggish. */
.modal-backdrop.closing            { animation: nvFadeOut  var(--dur-2) var(--ease-out) forwards; }
.modal-backdrop.closing .modal     { animation: nvModalOut var(--dur-2) var(--ease-out) forwards; }
@media (max-width: 600px) {
  /* 600, not 720: layout.css turns .modal-backdrop into an actual bottom sheet
     at 600px. Between 601 and 720 it is still a centred 560px dialog, and this
     rule was making it dive the full height of the viewport on close.
     --ease-out, not --ease-emphasis: emphasis is an ease-in-out that has moved
     0.9% at 10% of its 280ms, so the sheet hung ~28ms before it dropped. The
     iOS drawer curve the standards name is byte-identical to --ease-out, so
     the right curve was already in this file. */
  .modal-backdrop.closing .modal   { animation: nvSheetOut var(--dur-3) var(--ease-out) forwards; }
}

/* The contents arrive FROM the panel, 90ms behind it. This is the whole
   Apple trick; their own delay is 344ms, shortened here because on a slow
   device that long a wait reads as lag rather than as sequence. */
.modal > *  { animation: nvRise var(--dur-2) var(--ease-out) 90ms both; }
.modal > .modal-actions { animation-delay: 140ms; }

/* ── 2 · Toasts leave ───────────────────────────────────────────────────
   The exit lives with the entrance, in components.css — nvToastOut mirrors
   nvToastIn (opacity + translateY + scale .98). This file used to redeclare
   `.toast.leaving` as a flat nvFadeOut, and since motion.css loads after
   components.css it won: the toast rose in with scale and translate, then
   left as a plain fade. Same object, two physics. Removed, not moved —
   components.css already has it right. */

/* ── 3 · A ticket arrives ───────────────────────────────────────────────
   A new row should announce itself once and then stop. The flash is on
   the cell background rather than the row so it does not fight the hover
   rule, and it decays over 1.4s — long enough to catch an eye that was
   looking elsewhere, short enough not to become wallpaper. */
.ticket-row.is-new,
tr[data-id].is-new       { animation: nvRowIn var(--dur-3) var(--ease-out); }
.ticket-row.is-new td,
tr[data-id].is-new td    { animation: nvRowFlash 1400ms var(--ease-out); }

/* ── 4 · Breach: a BOUNDED alarm ────────────────────────────────────────
   Three pulses, then silence. An infinite pulse becomes wallpaper inside
   a minute and keeps the compositor awake in somebody's pocket all day. */
.sla-ring.breached { animation: nvBreach 600ms var(--ease-out) 3; }

/* ── 5 · Resolve: the one spring in the product ─────────────────────────
   This is the moment the housekeeper is actually paid for. It is the only
   place --ease-spring is justified and the only place it appears. */
.sla-ring.resolved  { animation: nvResolve var(--dur-5) var(--ease-spring); }
.ticket-row.resolving { opacity: .6; transition: opacity var(--dur-3) var(--ease-out); }

/* ── 6 · The clock moves ────────────────────────────────────────────────
   The product's entire promise is a countdown, and it was a still image.
   --p is registered in tokens.css so it can actually tween; engines
   without @property ignore it and the ring steps, which is today's
   behaviour rather than a regression. */
/* No .sla-ring transition. Only --p is registered (tokens.css:25), so the --c
   half was inert by spec; and --p is written in exactly one place — the inline
   style in utils.js slaRing() — with no page-level clock anywhere, despite the
   comment that promised one. Had it fired it would have repainted a
   conic-gradient per frame across up to 100 rows. @property --p stays in
   tokens.css: index.html's hero ring genuinely animates it. */

/* ── 7 · Seventeen dashboard pages had no motion of their own ───────────
   One rule. The delay is capped at 8 steps so a 40-row list does not take
   two seconds to finish appearing. */
.stagger > * {
  animation: nvRise var(--dur-3) var(--ease-out) both;
  animation-delay: calc(var(--i, 0) * 40ms);
}

/* ── Interaction repairs ────────────────────────────────────────────────
   The table hover transition was declared on `tr` while the background
   actually changes on `td`. The tr transitioned a property that never
   changed and the td that did change had none, so every hover on the
   most-touched surface in the product was a hard flash. */
.table tbody tr td { transition: background-color var(--dur-1) var(--ease-out); }

/* The bottom-nav press declared its transition INSIDE :active, so it
   eased in over 80ms and then snapped back in a single frame on release.
   .btn already gets this right by declaring transform on the base. */
.nav-link { transition: background-color var(--dur-1) var(--ease-out),
                        color            var(--dur-1) var(--ease-out),
                        transform        var(--dur-1) var(--ease-out); }
/* The bottom-nav press lives in layout.css, which loads after this file. The
   copy that used to sit here never applied. */

/* ── Performance guards ─────────────────────────────────────────────────
   Promote only what moves, and only while it moves. Every selector here
   is a short-lived node, so the layer is reclaimed when it is removed.
   Deliberately NOT .ticket-row: there can be a hundred of those. */
.modal-backdrop, .toast, #moduleDrawer { will-change: transform, opacity; }

/* No content-visibility on table rows. Layout containment does not apply to
   internal table boxes, so this was a no-op — measured in Chrome 151: a child
   of an offscreen <tr> carrying the rule still reports visible, while the same
   rule on a <div> does not. Do NOT move it to td: size containment does not
   apply to table-cells either, so contain-intrinsic-size is ignored and every
   offscreen row collapses to its padding — 77,624px of document where there
   should be 171,000px. The ticket list is capped at 100 rows anyway. */

/* A backdrop blur is the most expensive composite you can ask of a cheap
   GPU, and it was being re-rasterised for the whole 220ms the opacity
   animated. Phones get a plain scrim: cheaper, and more legible in
   sunlight, which is where a receptionist actually stands. */
.modal-backdrop { background: var(--veil); }
/* The blur is desktop-only and this is its ONLY declaration — components.css
   used to declare a 4px blur unconditionally, so the phone paid for it and
   this rule merely upgraded it. */
@media (min-width: 721px) and (prefers-reduced-motion: no-preference) {
  .modal-backdrop { backdrop-filter: blur(4px) saturate(140%);
                    -webkit-backdrop-filter: blur(4px) saturate(140%); }
}

/* ── 8 · Pages do not flash ─────────────────────────────────────────────
   Twenty modules, each one a full document load that rebuilds the entire
   sidebar on arrival. Named elements present in BOTH documents are
   morphed rather than cross-faded, so the shell holds still even though
   the document genuinely changed.

   This block MUST carry its own reduced-motion gate: the global
   !important reset does not reach ::view-transition-* pseudo-elements.
   Firefox has no support and degrades silently to today's behaviour. */
@media (prefers-reduced-motion: no-preference) {
  @view-transition { navigation: auto; }
  .sidebar { view-transition-name: nv-sidebar; }
  .topbar  { view-transition-name: nv-topbar; }
  .content { view-transition-name: nv-content; }
  ::view-transition-old(nv-content) { animation: nvFadeOut 120ms var(--ease-out); }
  /* nvFade, not nvRise: .content and #root are the SAME element on all 41
     pages, so nvRise slid the entire page body up 8px on every sidebar click
     and then .nv-enter > * slid each child up 8px again inside it. The
     crossfade is what prevents the flash; the translation was decoration on
     the most-repeated action in the app. */
  ::view-transition-new(nv-content) { animation: nvFade var(--dur-2) var(--ease-out); }
}


/* ── 9 · Arrival, then stillness ────────────────────────────────────────
   The homepage animates as you scroll; the app animates once, as a page
   arrives, and then holds still. mountView's first paint rises in order
   (.nv-enter, set by nvMotion.enter in utils.js), numbers count up, and
   heading icons draw. From the second paint on the host carries
   .nv-settled: a realtime repaint rebuilds the DOM and would otherwise
   replay every .stagger entrance — on a busy property, a blink every few
   seconds, which is exactly what section 7 was quietly doing. Only a KPI
   whose value changed gets one bump. */
@keyframes nvDraw   { to { stroke-dashoffset: 0; } }
@keyframes nvBump   { 0%   { box-shadow: 0 0 0 0 var(--accent-soft); }
                      35%  { box-shadow: 0 0 0 5px var(--accent-soft); border-color: var(--accent); }
                      100% { box-shadow: 0 0 0 0 transparent; } }
@keyframes nvNumPop { 35% { transform: scale(1.06); } }
@keyframes nvShake  { 0%, 100% { transform: none; } 20% { transform: translateX(-6px); } 40% { transform: translateX(5px); }
                      60% { transform: translateX(-3px); } 80% { transform: translateX(2px); } }
@keyframes nvPopIn  { from { opacity: 0; transform: translateY(6px) scale(.96); } }

/* openModal appends the backdrop to <body>, while mountView writes --i and
   .nv-settled only inside #root — so a modal's .stagger children resolve
   var(--i, 0) to 0 and all rise at once, and .nv-settled can never reach them
   to stand them down. They paid 280ms for a stagger that does not stagger.
   The container keeps its own entrance from `.modal > *`. Two sites, not 31. */
.nv-settled .stagger > *,
.modal .stagger > * { animation: none; }
/* --nv-i IS SET, and a grep of the CSS cannot see it. nvMotion.enter() in
   utils.js writes it on each child of #root (capped at 6) in the same call
   that adds .nv-enter, so the stagger is real. MEASURED 2026-09-19 in real
   headless Chrome on /v2/dashboard/index.html, frontdesk.html and folio.html:
   the first four children report --nv-i 0/1/2/3 and animation-delay
   0 / 0.06 / 0.12 / 0.18s on nvRise. A page that does not go through
   mountView — tickets.html is one — never gets .nv-enter either, so the
   var() default is never the thing being read. Do not "fix" this by deleting
   the mechanism; the audit that called it dead was reading only CSS. */
.nv-enter > * { animation: nvRise var(--dur-3) var(--ease-out) both; animation-delay: calc(var(--nv-i, 0) * 60ms); }
/* .nv-draw is gone with nvMotion.draw(): a 900ms stroke-draw on a 150ms delay,
   over every heading icon, replaying on every module open. The nvDraw keyframe
   below stays — the guest portal's tick is a real, once-per-visit use. */
/* A realtime KPI change is a system RESPONSE, and a response snaps. 900ms was
   3x the UI budget on a dashboard that repaints itself all shift, so on a busy
   property one bump was still running when the next started. */
.kpi.nv-bump { animation: nvBump var(--dur-3) var(--ease-out); }
.kpi.nv-bump .value { display: inline-block; animation: nvNumPop var(--dur-2) var(--ease-out); transform-origin: left center; }
.nv-shake { animation: nvShake 420ms var(--ease-out); }

/* The account menu is the one trigger-anchored popover in the shell, and it
   had no origin and no motion at all — display:none -> block, so a 190px panel
   materialised out of nothing. It grows from the chip it hangs off; on a phone
   layout re-pins it above the bottom nav, so its anchor becomes the bottom
   right. This must live in CSS, not in nav.js's inline style — an inline
   transform-origin would beat the media query below. */
/* The account menu popped IN with a keyframe and went OUT by display:none —
   the exact asymmetry this file exists to remove. A keyframe is also not
   interruptible, so a second tap on the chip restarted the pop instead of
   retargeting it. A transition on a class does both, and visibility (not
   display) keeps the menu out of hit-testing and out of the accessibility tree
   while it fades, which `display:block` would not.
   Origin is top right on EVERY width now: the menu used to be pinned to the
   bottom of the screen on a phone, and it is anchored under the chip that
   opens it, so scaling out of the bottom corner was scaling from nowhere. */
.user-menu {
  transform-origin: top right;
  opacity: 0;
  transform: translateY(6px) scale(.96);
  visibility: hidden;
  pointer-events: none;
  transition: opacity var(--dur-1) var(--ease-out),
              transform var(--dur-1) var(--ease-out),
              visibility 0s linear var(--dur-1);
}
.user-menu.is-open {
  opacity: 1;
  transform: none;
  visibility: visible;
  pointer-events: auto;
  transition: opacity var(--dur-2) var(--ease-out),
              transform var(--dur-2) var(--ease-out),
              visibility 0s;
}

/* ── 10 · Signing in ────────────────────────────────────────────────────
   The form arrives in reading order, and a refusal says so with its body —
   twice, then still. CSS only: an error element animates each time it goes
   from hidden to shown, which is exactly once per failed attempt. */
@media (prefers-reduced-motion: no-preference) {
  .auth-side > *, .auth-form-side > * { animation: nvRise var(--dur-3) var(--ease-out) both; }
  .auth-form-side > :nth-child(2), .auth-side > :nth-child(2) { animation-delay: 60ms; }
  .auth-form-side > :nth-child(3), .auth-side > :nth-child(3) { animation-delay: 120ms; }
  .auth-form-side > :nth-child(n+4) { animation-delay: 180ms; }
  body > .shell .card, body > .shell .form-card { animation: nvPopIn var(--dur-4) var(--ease-out) both; }
  body > .shell .form-card > *, body > .shell .card > * { animation: nvRise var(--dur-3) var(--ease-out) both; animation-delay: 120ms; }
  body > .shell .left-body > * { animation: nvRise var(--dur-4) var(--ease-out) both; }
  body > .shell .left-body > :nth-child(2) { animation-delay: 80ms; }
  body > .shell .left-body > :nth-child(n+3) { animation-delay: 160ms; }
  #aerr:not(.hidden), #serr:not(.hidden), body > .shell #err { animation: nvShake 420ms var(--ease-out); }
}

/* ── 11 · The guest portal ──────────────────────────────────────────────
   The one page a guest sees. Tiles arrive in order and answer the thumb;
   the chosen one stays lit; the tick on "received" draws itself. */
.gp-tile { transition: transform var(--dur-1) var(--ease-out), border-color var(--dur-1) var(--ease-out),
                       background-color var(--dur-1) var(--ease-out); }
.gp-tile:active { transform: scale(0.97); transition-duration: 70ms; }
.gp-tile.is-picked { border-color: var(--accent) !important; background: var(--accent-soft); }
.gp-check { display: block; width: 64px; height: 64px; margin: 0 auto 6px; color: var(--accent); }
.gp-check circle, .gp-check path { fill: none; stroke: currentColor; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
@media (prefers-reduced-motion: no-preference) {
  .gp-tile { animation: nvPopIn var(--dur-3) var(--ease-out) both; animation-delay: calc(var(--i, 0) * 45ms + 60ms); }
  .gp-in { animation: nvRise var(--dur-3) var(--ease-out) both; }
  .gp-in [data-sub] { animation: nvRise var(--dur-2) var(--ease-out) both; animation-delay: calc(var(--i, 0) * 35ms + 80ms); }
  .gp-check circle { stroke-dasharray: 176; stroke-dashoffset: 176; animation: nvDraw 650ms var(--ease-out) forwards; }
  .gp-check path { stroke-dasharray: 36; stroke-dashoffset: 36; animation: nvDraw 380ms var(--ease-out) 520ms forwards; }
  #success.gp-in > * { animation: nvRise var(--dur-3) var(--ease-out) both; animation-delay: calc(var(--i, 0) * 90ms + 250ms); }
}

/* ══════════════════════════════════════════════════════════════════════
   Skeletons
   42 dashboard pages shipped `<div class="empty"><h3>Loading…</h3></div>` as
   the first thing in #root, so every page was a word on an empty screen and
   then a hard pop into a full dashboard. A skeleton in the shape of what is
   coming turns that into one movement instead of two states.

   It lives in each page's markup, not in JS, and that is the point: measured
   on a 2.5 Mbps connection the scripts do not run for ~2s, so a JS-painted
   skeleton still left the bare word on screen for the entire wait — exactly
   the part that needed fixing. In the HTML it is there at first paint.

   It fades in on a DELAY. A page whose data is already in the row store paints
   in well under 100ms, and a skeleton that appears and vanishes inside that
   window reads as a flicker — worse than the bare word it replaced. Nothing is
   painted for the first 180ms; if the content wins that race the skeleton is
   never seen at all.
   ══════════════════════════════════════════════════════════════════════ */
@keyframes nvSkelIn    { to { opacity: 1; } }
@keyframes nvSkelSweep { from { transform: translateX(-100%); } to { transform: translateX(100%); } }

.nv-skel { opacity: 0; animation: nvSkelIn var(--dur-2) var(--ease-out) 180ms forwards; }
.nv-skel__bar {
  border-radius: var(--radius-sm);
  background-color: var(--surface-2);
  /* The sweep is a pseudo-element that TRANSLATES. The first cut animated
     background-position on the bar itself, with a comment claiming it forced
     no paint per frame — which was wrong: background-position repaints the
     element every frame, and nine bars do it on every page load on the
     slowest device in the building. transform is composited, so the sweep
     costs the GPU and nothing else. (AUDIT.md §5: transform and opacity only.) */
  position: relative;
  overflow: hidden;
  isolation: isolate;
}
.nv-skel__bar::after {
  content: ''; position: absolute; inset: 0;
  background-image: linear-gradient(90deg, transparent 0%, var(--surface) 50%, transparent 100%);
  animation: nvSkelSweep 1.4s linear infinite;
  will-change: transform;
}
.nv-skel__head { height: 30px; width: min(320px, 60%); margin-bottom: 10px; }
.nv-skel__sub  { height: 13px; width: min(460px, 84%); margin-bottom: var(--sp-5); }
.nv-skel__kpis { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr));
                 gap: var(--sp-4); margin-bottom: var(--sp-4); }
.nv-skel__kpi  { height: 96px; }
.nv-skel__rows { display: flex; flex-direction: column; gap: 10px; }
.nv-skel__row  { height: 56px; }
@media (max-width: 900px) { .nv-skel__kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 600px) { .nv-skel__kpis { grid-template-columns: minmax(0, 1fr); } }

/* Reduced motion keeps the SHAPE — it is information about what is loading —
   and drops only the sweep and the fade. */
@media (prefers-reduced-motion: reduce) {
  .nv-skel { opacity: 1; animation: none; }
  .nv-skel__bar::after { animation: none; background-image: none; }
}


/* ══════════════════════════════════════════════════════════════════════
   The other two accessibility signals
   apple-design §14: reduced motion is one of THREE independent settings a
   person can turn on, and this product answered only the first. The other
   two are not motion — they are about the material — but they are asked for
   by the same people for the same reason, and a translucent bar that stays
   blurred after someone asks for less transparency is the same failure as a
   modal that keeps sliding after they ask for less motion.
   ══════════════════════════════════════════════════════════════════════ */

/* "Make translucent surfaces frostier/solid: raise background opacity, drop
   the blur." Four surfaces in this product are translucent: the topbar, the
   modal backdrop, and the backdrop's blur variant. */
@media (prefers-reduced-transparency: reduce) {
  .topbar {
    background: var(--surface) !important;
    backdrop-filter: none !important;
    -webkit-backdrop-filter: none !important;
  }
  .modal-backdrop {
    backdrop-filter: none !important;
    -webkit-backdrop-filter: none !important;
  }
  #moduleDrawer, #moreSheet { backdrop-filter: none !important; -webkit-backdrop-filter: none !important; }
}

/* "Near-solid backgrounds with a defined, contrasting border." The borders
   here are deliberately quiet — at this setting quiet is the problem. */
@media (prefers-contrast: more) {
  .topbar {
    background: var(--surface) !important;
    backdrop-filter: none !important;
    -webkit-backdrop-filter: none !important;
  }
  .card, .card-elev, .modal, .btn, .input, .select,
  #moduleDrawer, #moreSheet, .toast {
    border-color: var(--text-soft) !important;
  }
  .badge, .tier-lock { border: 1px solid var(--text-soft); }
}

/* ── Dark ↔ light is a brightness jump ──────────────────────────────────
   apple-design §14: "avoid … abrupt brightness jumps (ease dark↔light theme
   changes)". Flipping the switch repainted the entire screen in one frame —
   every surface, every border, every piece of text at once. At night that is
   a flash in the face.

   Scoped to a class that exists only for the length of the change. A blanket
   `transition: background-color` on * would tax every hover for the rest of
   the session to pay for something that happens twice a day; .nv-theming is
   added on pointer-down on the switch and removed when the transition ends.
   transition-property is explicit — never `all` — and only colour is listed,
   so nothing moves.
   ══════════════════════════════════════════════════════════════════════ */
.nv-theming, .nv-theming *, .nv-theming *::before, .nv-theming *::after {
  transition-property: background-color, color, border-color, fill, stroke, box-shadow !important;
  transition-duration: var(--dur-2) !important;
  transition-timing-function: var(--ease-out) !important;
  transition-delay: 0ms !important;
}
/* Someone who asked for less motion did not ask for a flash instead; the
   cross-fade is the gentler option here, so it stays, just shorter. */
@media (prefers-reduced-motion: reduce) {
  .nv-theming, .nv-theming *, .nv-theming *::before, .nv-theming *::after {
    transition-duration: var(--dur-1) !important;
  }
}

/* ── The mark arrives ─────────────────────────────────────────────────────────
   The bar draws itself outward, then the N drops out of it. Things hang from
   the spine — which is what the mark means, so the motion is the idea rather
   than decoration on top of it. Both parts land on the same frame and stop
   dead; nothing overshoots, because this plays behind a receptionist at 8am.

   transform and opacity only, one-shot (so the linear-for-infinite rule does
   not apply), longest element 280ms + 90ms delay — inside the 400ms that is
   reserved for sheets and pages.

   transform-box:fill-box is load-bearing: without it each child transforms
   about the SVG's origin instead of its own box, and the bar scales out of the
   top-left corner. */
.nv-mark            { display: inline-block; line-height: 0; flex: none; }
.nv-mark svg        { display: block; overflow: visible; }
.nv-mark .nv-bar,
.nv-mark .nv-n      { transform-box: fill-box; }
.nv-mark .nv-bar    { transform-origin: 50% 50%; }
.nv-mark .nv-n      { transform-origin: 50% 0; }

@keyframes nvBarIn { from { opacity: 0; transform: scaleX(.55); } to { opacity: 1; transform: none; } }
@keyframes nvNIn   { from { opacity: 0; transform: translateY(-6px) scaleY(.84); } to { opacity: 1; transform: none; } }

.nv-mark--in .nv-bar { animation: nvBarIn var(--dur-3) var(--ease-out) both; }
.nv-mark--in .nv-n   { animation: nvNIn   var(--dur-3) var(--ease-out) 90ms both; }

/* Reduced motion is handled by the global reset in layout.css, which drops
   animation-duration to 0.01ms. `both` means the fill still lands on the `to`
   state, so the mark is fully visible and simply does not travel. That is the
   documented behaviour here and it is correct — do not add a competing rule. */

/* ── A chart arrives with its data ──────────────────────────────────────
   Bars grow from the baseline on the page's FIRST paint and never again.

   The gate is .nv-enter, which is the whole reason this is safe. nvMotion.
   enter() adds it once per document load and removes it after 1400ms, and it
   refuses to add it at all under prefers-reduced-motion or in a hidden
   document — so this inherits the correct answer to both without restating
   it, and a realtime repaint (which does not re-add the class) cannot replay
   it. That replay is exactly why the old count-up and stroke-draw were taken
   out: each of the 41 modules is a full document load, so anything on the
   arrival path fires dozens of times a shift.

   scaleY on the rect, not a height animation: height is layout, and a bar
   chart animating its height is thirty elements reflowing sixty times a
   second. transform-box: fill-box makes the origin the rect's own box rather
   than the SVG viewport, which is what lets it grow from its own baseline. */
.nv-enter svg rect[fill]:not([width="9"]) {
  transform-box: fill-box;
  transform-origin: bottom;
  animation: nvBarGrow var(--dur-4) var(--ease-out) both;
}
@keyframes nvBarGrow { from { transform: scaleY(0); } }

/* Columns arrive left to right, capped so a 30-day span does not take a
   second and a half to finish. Six steps of 30ms is a sweep, not a queue. */
.nv-enter svg rect:nth-of-type(6n+1)  { animation-delay: 0ms; }
.nv-enter svg rect:nth-of-type(6n+2)  { animation-delay: 30ms; }
.nv-enter svg rect:nth-of-type(6n+3)  { animation-delay: 60ms; }
.nv-enter svg rect:nth-of-type(6n+4)  { animation-delay: 90ms; }
.nv-enter svg rect:nth-of-type(6n+5)  { animation-delay: 120ms; }
.nv-enter svg rect:nth-of-type(6n)    { animation-delay: 150ms; }
