/* toys.css - opt-in premium "visual candy" library for directory-forge.
   Loaded only on sites whose design brief sets visual_toys=true (see base.html,
   linked right after assets/style.css so this file inherits that compiled
   stylesheet's :root tokens (--accent, --elev-*, --lift-y, --ease-*, --dur-*,
   ...) via normal cascade). Copied verbatim by generate.py to assets/toys.css:
   plain static CSS, not a Jinja template.

   THREE STANDING LAWS this file must never break (they are also the gate's
   "floor invariants", see gate_design_quality.py visual_toys check):
   1. GRACEFUL REDUCED MOTION (never a dead page). A visitor with OS "reduce
      motion" on must still get the FULL, final, fully-visible page: content is
      never stranded hidden, functional state changes still resolve (instantly),
      and only the vestibular triggers (parallax, large travel, scale/zoom,
      count-up tween, the background video) are dropped. The old behavior (kill
      every toy, ship a lifeless static page) is explicitly wrong. The global
      reduced-motion safety net below caps every transition/animation to ~0 so
      state still resolves, and each primed element has a reduced-motion final
      state.
   2. CONTENT NEVER STRANDED. Any element that starts hidden (opacity:0 /
      collapsed) does so ONLY under body.tv-js (added by toys.js once it has
      installed the observer that will reveal it) AND resolves to its final
      visible state under reduced-motion regardless. No-JS or a JS error = the
      un-primed, fully-visible state. Scroll-gated reveals also reveal on a full
      scroll and are unobserved after firing.
   3. DETERMINISM. No randomness, no wall-clock, no time-of-day logic anywhere.
      Timing is fixed CSS durations + IntersectionObserver class toggles +
      DOM-index --i vars + scroll position only. Same inputs render the same
      frames every time (toys.js carries the JS half of this rule).

   Every rule is scoped under body.toys-on, so a site that has not opted in
   renders byte-identical even if this file were linked by mistake. NO DARK MODE
   (dim/scrim tints are blue-gray ink via color-mix on --ink, never pure black).
   NO em dashes anywhere in this file. */

/* ===================================================================== */
/* 0. TOKENS (candy-scoped, tv- prefixed so they never clash with         */
/*    style.css tokens; reuse the compiled --accent/--elev-*/--lift-* too) */
/* ===================================================================== */
body.toys-on{
  /* easings: entrances decelerate with no overshoot (calm, clinical) */
  --tv-ease-enter: cubic-bezier(.22,1,.36,1);
  --tv-ease-calm:  cubic-bezier(.2,.7,.3,1);
  /* duration ladder: nothing interactive over .4s, product motion under .5s,
     atmospheric one-offs (reveal/hero) capped at .7s */
  --tv-dur-micro:   var(--dur-fast,150ms);   /* hover / focus / toggle */
  --tv-dur-control: var(--dur-base,220ms);   /* popover / condense / state */
  --tv-dur-settle:  .34s;                     /* card lift / accordion */
  --tv-dur-reveal:  .58s;                     /* scroll entrance */
  /* amplitude scale (kept small; the motion judge polices taste, not a px cap) */
  --tv-rise: 40px;   /* reveal travel: perceptible but calm */
  --tv-rise-sm: 12px;/* state-entrance travel */
  --tv-stagger: 60ms;/* per-card index step, index clamped in JS to 8 */
  /* scrim ink: blue-gray from --ink, NEVER pure black (dark-mode tell) */
  --tv-scrim: color-mix(in srgb, var(--ink,#14202e) 92%, #000);
}

/* ---- GLOBAL REDUCED-MOTION SAFETY NET (LAW 1) ------------------------
   On a toys-on page, a reduced-motion visitor gets every transition and
   animation capped to ~0: state changes still RESOLVE (nav condenses, cards
   reach hovered/checked/open states, primed content lands visible) but nothing
   travels, spins, zooms, or loops. This is the single guarantee that reduced
   motion is calm, not dead. Kept last-word via specificity on body.toys-on. */
@media (prefers-reduced-motion: reduce){
  body.toys-on *,
  body.toys-on *::before,
  body.toys-on *::after{
    animation-duration:.01ms !important;
    animation-iteration-count:1 !important;
    transition-duration:.01ms !important;
    scroll-behavior:auto !important;
  }
}

/* ===================================================================== */
/* 1. SCROLL-REVEAL + STAGGER  [FLOOR]                                     */
/* ===================================================================== */
/* .tv-prime is added by toys.js immediately before it observes an element, and
   ONLY when motion is allowed, so a no-JS/reduced-motion/JS-error visitor never
   loses content (un-primed = normal fully-visible state). A perceptible 40px
   rise + faint scale-in reads as a real entrance (the prior 14px was below the
   threshold of perception); the amount is a judged taste call, not a hard cap.
   Stagger is a deterministic DOM-index delay (--i, clamped to 8 in JS). */
body.toys-on .tv-prime{
  opacity:0;
  transform:translateY(var(--tv-rise)) scale(.99);
  transition:opacity var(--tv-dur-reveal) var(--tv-ease-enter),
             transform var(--tv-dur-reveal) var(--tv-ease-enter);
  transition-delay:calc(min(var(--i,0),8) * var(--tv-stagger));
}
body.toys-on .tv-prime.tv-in{opacity:1;transform:none}
/* reduced motion: the safety net above already caps the transition to ~0, but
   restate the final state explicitly so content is unconditionally visible even
   if the primed class lingers. */
@media (prefers-reduced-motion: reduce){
  body.toys-on .tv-prime{opacity:1;transform:none}
}

/* ===================================================================== */
/* 2. PARALLAX-LITE (hero photo AND hero video)  [PREMIUM, judged]         */
/* ===================================================================== */
/* toys.js sets the transform inline per rAF frame, desktop only, scroll only.
   The will-change hint is a pure perf assist (no reduced-motion guard needed:
   toys.js never sets a transform for reduced-motion visitors). */
@media (min-width:900px){
  body.toys-on .hero-media img,
  body.toys-on .hero-media video{will-change:transform}
}

/* ===================================================================== */
/* 3. HOVER-DEPTH  [FLOOR]  (the primary directory interaction)           */
/* ===================================================================== */
/* Tiny lift + one deeper elevation step via the site's own tokens (never a
   literal). Clinical ships flat --elev-1/2 (=none) and hairline --elev-3, so
   the lift reads as a hairline+shadow firming up, not a heavy drop shadow. */
@media (prefers-reduced-motion: no-preference){
  body.toys-on .card,
  body.toys-on .tile-listing,
  body.toys-on .row-listing,
  body.toys-on .cat-tile,
  body.toys-on .hookup-chip{
    transition:transform var(--tv-dur-settle) var(--tv-ease-enter),
               box-shadow var(--tv-dur-settle) var(--tv-ease-enter);
  }
  body.toys-on .card:hover,
  body.toys-on .tile-listing:hover,
  body.toys-on .row-listing:hover,
  body.toys-on .cat-tile:hover,
  body.toys-on .hookup-chip:hover{
    transform:translateY(var(--lift-y));
    box-shadow:var(--elev-2-hover);
  }
  body.toys-on .card:active,
  body.toys-on .tile-listing:active,
  body.toys-on .row-listing:active{transform:translateY(-1px) scale(.997)}
  /* .atlas-legend-chip owns hand-tuned hover physics; only bump its shadow so
     it composes with (never fights) that transform. */
  body.toys-on .atlas-legend-chip{transition:box-shadow var(--tv-dur-micro) ease}
  body.toys-on .atlas-legend-chip:hover{box-shadow:var(--elev-3)}
}
/* reduced motion: no travel, but a hovered card still reads hovered via its
   base stylesheet color/border change (functional, instant). */

/* ===================================================================== */
/* 4. STICKY-HEADER CONDENSE  [FLOOR]                                      */
/* ===================================================================== */
/* toys.js toggles .tv-scrolled on .site-header past a small scroll threshold.
   The header is already position:sticky in the base stylesheet; this only adds
   the depth cue once scrolling begins. The condensed state is a STATE, not a
   motion effect, so a reduced-motion visitor still gets it (only the transition
   itself is capped by the safety net, so it snaps in). */
body.toys-on .site-header{
  transition:box-shadow var(--tv-dur-micro) ease,
             background-color var(--tv-dur-micro) ease,
             backdrop-filter var(--tv-dur-micro) ease;
}
body.toys-on .site-header.tv-scrolled{box-shadow:var(--elev-2)}
/* clinical/guide/ledger ship a light header: firm it into a frosted hairline
   bar once scrolled (LIGHT white frost, never a dark bar). `body.toys-on`
   leads every selector (the gate greps for that literal scope prefix). */
body.toys-on.arch-clinical .site-header.tv-scrolled,
body.toys-on.arch-guide .site-header.tv-scrolled,
body.toys-on.arch-ledger .site-header.tv-scrolled{
  background-color:color-mix(in srgb, var(--card,#fff) 82%, transparent);
  backdrop-filter:blur(12px) saturate(1.08);
  box-shadow:0 1px 0 var(--border);
}

/* ===================================================================== */
/* 5. AMBER SIGNATURE UNDERLINE  [PREMIUM]  (the ONE ownable gesture)      */
/* ===================================================================== */
/* Per RUI-24: one memorable, ownable move applied on 3+ page types. A soft
   accent underline draws in under nav + footer links on hover/focus via scaleX
   from the left. Decorative only (link text is always fully legible); calm and
   consistent, never a particle field. Amber = the single warm accent. */
@media (prefers-reduced-motion: no-preference){
  body.toys-on .main-nav a,
  body.toys-on .footer-legal a,
  body.toys-on .footer-col a{position:relative}
  body.toys-on .main-nav a::after,
  body.toys-on .footer-legal a::after,
  body.toys-on .footer-col a::after{
    content:"";position:absolute;left:0;right:0;bottom:-2px;height:2px;
    background:var(--accent);transform:scaleX(0);transform-origin:left;
    transition:transform var(--tv-dur-control) var(--tv-ease-enter);
    pointer-events:none;
  }
  body.toys-on .main-nav a:hover::after,
  body.toys-on .main-nav a:focus-visible::after,
  body.toys-on .main-nav a[aria-current="page"]::after,
  body.toys-on .footer-legal a:hover::after,
  body.toys-on .footer-legal a:focus-visible::after,
  body.toys-on .footer-col a:hover::after,
  body.toys-on .footer-col a:focus-visible::after{transform:scaleX(1)}
}

/* ===================================================================== */
/* 6. FAQ ACCORDION (grid-template-rows)  [FLOOR, harmless if absent]      */
/* ===================================================================== */
/* Animate 0fr -> 1fr (true content height, no max-height guessing). Only fires
   where a .faq-item/.faq-body <details> pattern exists; a site without one is
   unaffected. [open] is the source of truth, so content is fully present and
   shown when open (no-strand). */
body.toys-on .faq-body{
  display:grid;grid-template-rows:0fr;
  transition:grid-template-rows var(--tv-dur-settle) var(--tv-ease-calm);
}
body.toys-on details[open] > .faq-body,
body.toys-on .faq-item.is-open .faq-body{grid-template-rows:1fr}
body.toys-on .faq-body > *{overflow:hidden;min-height:0}

/* ===================================================================== */
/* 7. FILTER / SEARCH MICRO-INTERACTIONS  [FLOOR, harmless if absent]      */
/* ===================================================================== */
/* Checkbox tick draw-in + a state-driven control entrance (a "Clear" pill or
   results summary that appears because state changed, not a timer). Both are
   data-bound: the checked/active state is always truthfully rendered with or
   without the transition. Scoped to the search UI classes; no-op elsewhere. */
body.toys-on .tv-tick{
  stroke-dasharray:16;stroke-dashoffset:16;stroke:var(--accent);
  transition:stroke-dashoffset var(--tv-dur-control) var(--tv-ease-enter);
}
body.toys-on :checked + label .tv-tick,
body.toys-on .is-checked .tv-tick{stroke-dashoffset:0}
body.toys-on .tv-state-in{
  opacity:0;transform:translateY(var(--tv-rise-sm));
  transition:opacity var(--tv-dur-control) var(--tv-ease-enter),
             transform var(--tv-dur-control) var(--tv-ease-enter);
}
body.toys-on .is-active > .tv-state-in,
body.toys-on .tv-state-in.is-in{opacity:1;transform:none}
@media (prefers-reduced-motion: reduce){
  body.toys-on .tv-state-in{opacity:1;transform:none}
  body.toys-on .tv-tick{stroke-dashoffset:0}
}

/* ===================================================================== */
/* 8. SKELETON SHIMMER, FINITE  [PREMIUM]                                  */
/* ===================================================================== */
/* The base .skeleton-card ships an infinite df-shimmer sweep (correct for an
   un-opted default). Under body.toys-on it plays ONE finite pass then holds,
   so nothing loops forever on a shipped screen. */
@media (prefers-reduced-motion: no-preference){
  body.toys-on .skeleton-card,
  body.toys-on .skeleton-card .sk-line{
    animation-duration:650ms;animation-iteration-count:1;animation-fill-mode:forwards;
  }
}

/* ===================================================================== */
/* 9. CROSSHAIR MARK: ONE-TIME SVG DRAW-IN  [PREMIUM]                      */
/* ===================================================================== */
/* .toy-crosshair (clinical hero) traces its circle + ticks in via
   stroke-dashoffset. Pure CSS, starts on paint, finishes well under the cap,
   never repeats. Hidden on phones (no safe corner beside a wrapped H1). */
body.toys-on .toy-crosshair{position:absolute;top:.4rem;right:.4rem;pointer-events:none;z-index:2;color:var(--muted-2);opacity:.6}
body.toys-on .hero-photo .toy-crosshair{color:var(--ink-on-accent);opacity:.85}
@media(max-width:640px){body.toys-on .toy-crosshair{display:none}}
@media(prefers-reduced-motion:no-preference){
  body.toys-on .toy-crosshair circle{stroke-dasharray:176;stroke-dashoffset:176;animation:tv-crosshair-draw 600ms ease-out forwards}
  body.toys-on .toy-crosshair path{stroke-dasharray:80;stroke-dashoffset:80;animation:tv-crosshair-draw 500ms ease-out 100ms forwards}
}
@keyframes tv-crosshair-draw{to{stroke-dashoffset:0}}

/* ===================================================================== */
/* 10. STAT COUNT-UP  [PREMIUM]                                            */
/* ===================================================================== */
/* toys.js rewrites the <b> text in place via rAF (ease-out, fires once on
   scroll-in) and lands on the exact authored figure, so the shipped HTML is
   unaffected either way and a reduced-motion visitor sees the final number
   immediately (toys.js skips the tween for them). tabular-nums stops width
   jitter while the digits change. */
body.toys-on .stat-strip .stat b{font-variant-numeric:tabular-nums}

/* ===================================================================== */
/* 11. VIDEO HERO -- MOVED (H9 fix, DOCTRINE law 13). A hero_video asset is  */
/*     an opt-in independent of visual_toys, so its .hero-video/            */
/*     .hero-video-toggle sizing/fade/a11y CSS now lives in the always-on   */
/*     base stylesheet (templates/style.css.j2, next to the poster <img>    */
/*     rule it composes with), unscoped from body.toys-on, instead of here. */
/*     Keeping it in this opt-in file would mean a hero_video site with     */
/*     visual_toys:false never receives it. toys.js's initVideoHero() still */
/*     drives WHEN the video plays/fades/reveals its pause control -- see   */
/*     that file's "5. video hero" section, which now runs regardless of    */
/*     body.toys-on too, only the layout/paint CSS moved. ==================*/
/*     (this section intentionally left as a pointer comment only) */

/* ===================================================================== */
/* 12. HERO LOAD CHOREOGRAPHY  [PREMIUM]                                   */
/* ===================================================================== */
/* The hero content authored-rises on first paint, staggered, over the video
   fade-in, so the hero reads as composed on load rather than static-then-video.
   CSS-only (runs without JS): the whole block is no-preference-only, so a
   reduced-motion visitor gets no animation and the content's natural (fully
   visible) state, never a delayed flash. fill-mode both holds each element
   hidden only until its own turn, then lands it visible (no-strand). */
@media (prefers-reduced-motion: no-preference){
  body.toys-on .clinical-hero-main > h1,
  body.toys-on .clinical-hero-main > .lede,
  body.toys-on .clinical-hero-main > .clinical-proof-stat,
  body.toys-on .clinical-hero-main > .search-bar{
    animation-name: tv-hero-rise;
    animation-duration: .66s;
    animation-timing-function: var(--tv-ease-enter);
    animation-fill-mode: both;
  }
  body.toys-on .clinical-hero-main > h1{animation-delay:.06s}
  body.toys-on .clinical-hero-main > .lede{animation-delay:.16s}
  body.toys-on .clinical-hero-main > .clinical-proof-stat{animation-delay:.24s}
  body.toys-on .clinical-hero-main > .search-bar{animation-delay:.32s}
}
@keyframes tv-hero-rise{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:none}}

/* ===================================================================== */
/* 13. INTERIOR IMAGERY MOTION  [PREMIUM]                                  */
/* ===================================================================== */
/* The ambient content photos (hub header, info editorial, listing ambient,
   home how-it-works) gently SETTLE from a slight zoom-in as they scroll into
   view, so the premium-imagery feel carries past the hero across the whole
   site. Transform-only (scale, no reflow); toys.js primes .tv-photo-prime and
   toggles .tv-photo-in on first intersection (unobserved after, once only).
   No-strand: a no-JS/reduced-motion visitor never gets primed, so the photo is
   simply its normal fully-visible self. */
@media (prefers-reduced-motion: no-preference){
  body.toys-on .content-photo.tv-photo-prime{
    opacity:0;transform:scale(1.045);transform-origin:center;
    transition:opacity var(--tv-dur-reveal) var(--tv-ease-enter),
               transform .66s var(--tv-ease-enter);
  }
  body.toys-on .content-photo.tv-photo-prime.tv-photo-in{opacity:1;transform:scale(1)}
}

/* ===================================================================== */
/* 14. STICKY CONDENSED SEARCH  [FLOOR, clinical]                          */
/* ===================================================================== */
/* Collapsed until the header condenses (toys.js adds .tv-scrolled once past the
   hero), then a slim search field slides in, so search is always one tap from
   the nav. Desktop only; mobile keeps the nav toggle. Reduced-motion still gets
   the field (only the slide transition is capped by the safety net). */
/* compositor-only (opacity + transform, no layout-property transitions): the
   field is ABSOLUTELY positioned so it reserves no header space, and the
   redundant Search pill fades as the field takes its place. */
body.toys-on .header-search{display:none}
/* font-size at ALL widths (>=16px): the field is hidden on mobile, but the a11y
   input-size check still measures it, and a UA-default (~13px) would fail it. */
body.toys-on .header-search input{font-size:1rem}
@media (min-width:900px){
  body.toys-on .site-header .header-inner{position:relative}
  body.toys-on .header-search{
    display:flex;align-items:center;position:absolute;right:0;top:50%;z-index:2;
    opacity:0;transform:translate(10px,-50%);pointer-events:none;
    transition:opacity var(--tv-dur-control) var(--tv-ease-calm),
               transform var(--tv-dur-control) var(--tv-ease-calm);
  }
  body.toys-on .site-header.tv-scrolled .header-search{
    opacity:1;transform:translate(0,-50%);pointer-events:auto}
  /* the redundant Search pill fades out as the field appears (opacity only) */
  body.toys-on .site-header .main-nav .nav-cta{
    transition:opacity var(--tv-dur-control) var(--tv-ease-calm)}
  body.toys-on .site-header.tv-scrolled .main-nav .nav-cta{opacity:0;pointer-events:none}
  body.toys-on .header-search input{
    border:1px solid var(--border);border-right:0;
    border-radius:var(--radius-pill) 0 0 var(--radius-pill);
    padding:.42rem .85rem;font-size:1rem;width:13rem;max-width:44vw;background:var(--card);color:var(--ink)}
  body.toys-on .header-search input:focus-visible{outline:var(--focus-ring);outline-offset:1px}
  body.toys-on .header-search button{
    border:1px solid var(--accent);background:var(--accent);color:var(--ink-on-accent);
    border-radius:0 var(--radius-pill) var(--radius-pill) 0;padding:.42rem .72rem;
    display:flex;align-items:center;cursor:pointer}
  body.toys-on .header-search button:hover{background:var(--accent-2)}
}

/* ===================================================================== */
/* 15. SEARCH RESULTS SETTLE  [PREMIUM]                                    */
/* ===================================================================== */
/* When the search page renders/filters its result cards (search.js), each card
   settles in with a short, index-staggered rise, so the list reads as arriving
   with intent rather than snapping. toys.js primes .tv-settle on freshly
   rendered result cards and toggles .tv-settle-in; deterministic DOM-index
   delay (clamped). No-strand: cards ship visible; the prime is JS-only and the
   reduced-motion path resolves to final instantly. */
@media (prefers-reduced-motion: no-preference){
  body.toys-on .tv-settle{
    opacity:0;transform:translateY(10px);
    transition:opacity var(--tv-dur-settle) var(--tv-ease-enter),
               transform var(--tv-dur-settle) var(--tv-ease-enter);
    transition-delay:calc(min(var(--i,0),8) * var(--tv-stagger));
  }
  body.toys-on .tv-settle.tv-settle-in{opacity:1;transform:none}
}
