/* ═══════════════════════════════════════════════════════════════════════
   CHASKA COFFEE ROASTERS — effects.css
   Motion only. Every colour, size, font and layout decision belongs to
   style.css; this file declares no design token and no component.

   TWO CURVES, AND THE RULE THAT DECIDES BETWEEN THEM
   Do NOT helpfully collapse these back into one. The split is a deliberate,
   user-approved decision, and the reasoning generalises to anything added
   here later:

     ANSWERS THE VISITOR  →  --fx-curve     cubic-bezier(.34, 1.56, .64, 1)
       The overshoot. Hover lift, :active press, the hero prev/next circles,
       the nav panel, button and chip hovers, the turntable — anything that
       moves BECAUSE a pointer, a tap or a key made it move. An overshoot
       answering something you just did reads as alive, and it is this
       brand's signature: a sneaker drop, deliberately wrong for the calm
       sibling site (musafircafe), which keeps one no-overshoot curve.
       It arrives through --transition, which style.css defines as
       `.35s cubic-bezier(.34,1.56,.64,1)`, and through --fx-curve for the
       places that need the curve without that duration.

     ARRIVES BY ITSELF    →  --fx-curve-in  cubic-bezier(.22, 1, .36, 1)
       No overshoot. The scroll reveal and its stagger, and anything else a
       scroll position or an observer sets off. The same bounce applied to a
       paragraph that arrived on its own — while someone is trying to read
       it — does not read as alive. It reads as wobbly.

     THE TEST for anything new: did the visitor cause this, or did it just
     happen to them? Caused → overshoot. Happened → ease-out.

   The marquee is neither, and stays LINEAR. A marquee on an eased curve
   visibly stutters at the loop seam, because the speed at the end of one
   cycle does not match the speed at the start of the next. Linear is a
   constant speed, not a third personality.

   FOUR THINGS MOVE ON THIS SITE. NOTHING ELSE.

     1. THE BEAN TICKER   The signature element. An infinite horizontal
                          marquee of origin data, running under the hero and
                          as every section divider on every page. Pauses on
                          hover and on focus-within, so a keyboard visitor
                          can stop it and read it.
     2. THE LIFT          Cards answer a pointer by rising. Transform only —
                          the DNA allows exactly one shadow token and it is
                          spent on the floating product renders, so a hover
                          may never grow a shadow.
     3. THE PRESS         Buttons, chips and the nav toggle sink 2px on
                          :active. Faster than everything else, because
                          control feedback that lags feels broken.
     4. THE STAGGER       Scroll entrance. A 22px rise + fade, delayed off
                          --i, which animations.js writes onto each target.

   WHY THE STAGGER IS A KEYFRAME AND NOT A TRANSITION
   The pages emit `<li class="card reveal">` — one element that is both a
   hover surface and a scroll target. A transition-based reveal would have
   to own `transform` and `transition` on that element, and its stagger
   delay would survive into every later hover: card five would wait 320ms
   before it agreed to lift. So the reveal runs as an animation with
   `animation-fill-mode: backwards` — it holds the hidden frame through its
   delay, plays once, and then hands `transform` straight back to the hover
   rules with nothing left over. The two never touch the same declaration.

   RULES HELD
     · transform and opacity only. Nothing here animates width, height,
       padding, margin, colour, or a shadow.
     · the hidden resting state is gated on html.has-js, which animations.js
       sets only when it has decided to run. No script, nothing hidden.
     · the hidden state is written `:not(.is-in)`, so the instant a target
       is revealed its own styles apply — if the animation never runs, the
       element is simply visible. There is no fill-forward anywhere.
     · @media (prefers-reduced-motion: reduce) at the foot of this file
       stops the marquee dead and removes every transform-based animation.
       That block is mandatory and must stay last.

   Tokens consumed from style.css: --transition. Everything motion-specific
   below is namespaced --fx-*.
   ═══════════════════════════════════════════════════════════════════════ */


/* ── Motion-only variables ───────────────────────────────────────────────
   Namespaced --fx-* so they can never be mistaken for design tokens, and
   so style.css keeps sole ownership of the :root design contract. */
:root {
    /* Caused by the visitor — overshoot. See the two-curve rule above. */
    --fx-curve:     cubic-bezier(.34, 1.56, .64, 1);
    /* Arrives on its own — ease-out, no overshoot. */
    --fx-curve-in:  cubic-bezier(.22, 1, .36, 1);
    --fx-rise:      22px;   /* how far a revealed block travels           */
    --fx-in:        .62s;   /* entrance duration                          */
    --fx-step:      80ms;   /* stagger between siblings                   */
    --fx-lift:      4px;    /* pointer lift — matches style.css exactly   */
    --fx-press:     .14s;   /* :active feedback — the one fast move       */
    --fx-ticker:    26s;    /* marquee cycle on a phone (mobile-first)    */
    --fx-ticker-lg: 42s;    /* …slower from 768px, where more is on show  */
}


/* ═══ 1 ── THE BEAN TICKER ═══════════════════════════════════════════════
   bean_ticker() emits the origin list TWICE inside .ticker-track, so the
   second half is a pixel-exact copy of the first. Translating the track by
   -50% therefore lands on visually identical content and the reset is
   invisible — no gap, no jump, no JavaScript.

   The fallback below covers the other shape the helper may emit: two
   sibling .ticker-track elements, each holding one full set. There each
   track travels -100% of its own width, which swaps their positions and
   lands on the same frame — seamless for the same reason. Exactly one of
   the two rules ever applies.
   ═══════════════════════════════════════════════════════════════════════ */

@keyframes bean-ticker {
    from { transform: translate3d(0, 0, 0); }
    to   { transform: translate3d(-50%, 0, 0); }
}

@keyframes bean-ticker-pair {
    from { transform: translate3d(0, 0, 0); }
    to   { transform: translate3d(-100%, 0, 0); }
}

/* The structural minimum the marquee cannot live without.

   .ticker MUST be a flex row. style.css gives the band its colour, padding,
   clip and type, and gives each .ticker-track `display:flex; width:max-content;
   flex:none` — but leaves .ticker itself a block box. Two block-level tracks
   in a block parent stack VERTICALLY: the band would render two rows tall and
   the loop would translate a stack rather than a strip. One declaration fixes
   it, and it belongs here because "two copies side by side" IS the loop
   mechanism, not a look. */
.ticker {
    display: flex;
    flex-wrap: nowrap;
    align-items: center;
    overflow: hidden;
}

.ticker-track {
    flex: none;
    width: max-content;
    animation: bean-ticker var(--fx-ticker) linear infinite;
}

/* Two sibling tracks instead of one doubled track. */
.ticker:has(.ticker-track + .ticker-track) .ticker-track {
    animation-name: bean-ticker-pair;
}

/* Pause to read it. Hover covers the mouse; focus-within covers the
   keyboard and anything that moves focus into the band. */
.ticker:hover .ticker-track,
.ticker:focus-within .ticker-track {
    animation-play-state: paused;
}

/* A phone shows less of the loop at once, so a long cycle reads as sluggish
   there and a short one reads as frantic on a desktop. Mobile-first, and on
   one of the four project breakpoints — the only media query in this file
   that is not prefers-reduced-motion. */
@media (min-width: 768px) {
    .ticker-track {
        animation-duration: var(--fx-ticker-lg);
    }
}


/* ═══ 2 ── THE LIFT ══════════════════════════════════════════════════════
   Cards rise to meet the pointer and settle with the overshoot. No shadow
   growth: the DNA spends its single shadow token on the floating product
   renders, and a second elevation source would flatten that distinction.

   ⚠ style.css ALREADY declares this lift — `.card:hover` and
   `.origin-card:hover` both translateY(-4px) with var(--transition). This
   file does not restate it at a different value, because two stylesheets
   disagreeing about how far a card rises is how a site ends up with a
   hover that changes depth depending on which rule won. --fx-lift is set
   to the same 4px and used only for the states style.css does NOT cover.

   What this file adds, and style.css cannot:
     · suppression on touch, where :hover latches after a tap and leaves the
       card stuck in the air until you tap something else;
     · the keyboard's half of the interaction;
     · the picture inside the frame.
   ═══════════════════════════════════════════════════════════════════════ */

.promise-tiles > *,
.card-media img,
.origin-card img {
    transition: transform var(--transition);
}

/* A pointer that cannot hover must not leave a card lifted. */
@media (hover: none) {
    .card:hover,
    .origin-card:hover {
        transform: none;
    }
}

@media (hover: hover) and (pointer: fine) {

    /* The tiles are the one surface style.css gives no hover at all. */
    .promise-tiles > *:hover {
        transform: translate3d(0, calc(var(--fx-lift) * -1), 0);
    }

    /* The picture creeps forward a hair behind the lift. Scale only;
       style.css owns the frame that clips it. */
    .card:hover .card-media img,
    .origin-card:hover .card-media img {
        transform: scale(1.04);
    }
}

/* Keyboard parity — tabbing to a control inside a card lifts the card too.
   Every card on this site contains a link or a form, so without this the
   whole interaction simply does not exist for a keyboard. */
.card:focus-within,
.origin-card:focus-within {
    transform: translate3d(0, calc(var(--fx-lift) * -1), 0);
}


/* ═══ 3 ── THE PRESS ═════════════════════════════════════════════════════
   Control feedback. Deliberately the fastest move on the site — a press
   that takes 350ms to answer reads as a broken button. Same curve, so it
   still belongs to the same family.
   ═══════════════════════════════════════════════════════════════════════ */

.btn,
.chip,
.nav-toggle,
.nav-link {
    transition: transform var(--transition);
}

.btn:active,
.chip:active,
.nav-toggle:active {
    transform: translate3d(0, 2px, 0) scale(.985);
    transition-duration: var(--fx-press);
}

/* ⚠ THE SECOND NO-DEAD-CONTROL GUARD, AND IT IS THE MORE SERIOUS ONE.
   header.php ships <html class="no-js">, swaps it to has-js from an inline
   script, and documents: "The nav-toggle is hidden and the nav-panel is
   shown open under .no-js … style.css must define both." style.css never
   defined either. Without the three rules below, a phone with scripting
   off gets: .nav > .nav-list hidden (mobile-first), .nav-panel hidden (no
   .is-open, and the sibling/:target fallbacks in style.css cannot match
   this markup — the toggle is inside .nav, the panel is outside it), and a
   hamburger button that does nothing. That is a dead control AND a page
   with no navigation at all.

   Not motion, and it would sit more naturally in style.css — but style.css
   is another agent's file and shipping the bug is worse than shipping the
   fix one file to the left. Flagged in the hand-off.

   The header stops being sticky too: with the panel expanded in normal
   flow, a sticky header would eat most of a 360px viewport. */
html.no-js .nav-toggle {
    display: none;
}

html.no-js .nav-panel {
    display: flex;
    position: static;
    margin-block-start: var(--space-xs);
}

html.no-js .site-header {
    position: static;
}

/* The mobile panel drops in from under the header. Transform and opacity
   only — style.css owns whether the panel is displayed at all, and main.js
   owns the aria state. */
.nav-panel {
    transition:
        opacity   var(--transition),
        transform var(--transition);
}

html.has-js .nav-panel:not(.is-open) {
    opacity: 0;
    transform: translate3d(0, -10px, 0);
}

html.has-js .nav-panel.is-open {
    opacity: 1;
    transform: translate3d(0, 0, 0);
}


/* ═══ 4 ── THE STAGGER ═══════════════════════════════════════════════════
   animations.js observes every .reveal, writes --i onto it (its position
   among the reveal targets that share its parent) and adds .is-in when the
   block enters the viewport.

   Read the header note above for why this is an animation rather than a
   transition. In short: `backwards` fill holds the hidden frame through
   the delay and leaves NOTHING behind when it finishes, so the hover rules
   above get `transform` back intact.

   ── WHY THERE IS A CSS FAILSAFE HERE, AND WHY IT IS NOT OPTIONAL ──────
   The obvious guard would be "hide only under html.has-js, which the JS
   adds". On THIS site that guard does not hold: header.php sets has-js from
   an inline script in the <head>, before first paint, so the class proves
   only that SCRIPTING IS ON — not that animations.js loaded, parsed, or
   survived. If that file 404s or throws before its first line, has-js is
   already on the element and every .reveal would sit at opacity 0 forever.
   That is the exact blank-page failure this repo has shipped before.

   Gating on a second, JS-added class instead would trade the blank page for
   a visible flash: the scripts are `defer`, so they run after the parse and
   can easily land after first paint — content would render, then hide, then
   animate in. Wrong fix.

   So the hidden state stays on has-js (pre-paint, no flash) and carries a
   zero-duration animation delayed 4s that reveals the element on its own.
   It costs nothing in normal operation and is never seen: the observer adds
   .is-in at the moment an element enters the viewport, so a target that the
   failsafe has already cleared is always still off-screen, and it still
   plays its full entrance when .is-in finally lands. The failsafe becomes
   visible only in the one case it exists for — a reveal layer that never
   ran at all.

   Three independent guarantees, then:
     · JS off entirely      → header.php's inline script never runs either,
                              html keeps .no-js, nothing is ever hidden;
     · animations.js dead   → this 4s CSS failsafe clears every target;
     · observer never fires → animations.js's own 3s check, plus the above.
   ═══════════════════════════════════════════════════════════════════════ */

@keyframes drop-in {
    from {
        opacity: 0;
        transform: translate3d(0, var(--fx-rise), 0);
    }
    to {
        opacity: 1;
        transform: translate3d(0, 0, 0);
    }
}

/* The failsafe. Zero duration, 4s delay, fills forwards — it does nothing
   at all until the moment it is needed, and then it simply un-hides. */
@keyframes reveal-failsafe {
    to {
        opacity: 1;
        transform: translate3d(0, 0, 0);
    }
}

html.has-js .reveal {
    --fx-delay: calc(var(--i, 0) * var(--fx-step));
}

html.has-js .reveal:not(.is-in) {
    opacity: 0;
    transform: translate3d(0, var(--fx-rise), 0);
    animation: reveal-failsafe 0s linear 4s forwards;
}

/* --fx-curve-in, NOT --fx-curve. Nobody asked this block to appear; it
   appeared because they scrolled past it, and they are about to read it.
   The overshoot belongs on things that answer a click. */
html.has-js .reveal.is-in {
    animation: drop-in var(--fx-in) var(--fx-curve-in) var(--fx-delay) backwards;
}


/* ═══ THE TURNTABLE ══════════════════════════════════════════════════════
   The drag-to-spin control on /shop. Frame swapping is INSTANT on purpose:
   a transition between frames turns a spin into a smear and destroys the
   illusion that you are turning a physical cup.

   ⚠ THE NO-DEAD-CONTROL RULES BELOW ARE LOAD-BEARING, NOT DECORATION.
   shop.php ships .turntable-hint ("Drag to spin, or use the left and right
   arrow keys") and states that CSS keeps it hidden until main.js adds
   .is-ready. style.css never got a rule for either class, so without the
   two below the hint renders for a visitor with scripting off and promises
   a gesture that cannot happen — and the stage shows a `grab` cursor for a
   thing that will not be grabbed. That is precisely the dead control the
   build contract forbids, so the guard lives here, next to the interaction
   layer that owns .is-ready, rather than being left to nobody.

   main.js adds .is-ready only after every handler is bound AND there are at
   least two frames to move between. Until then /shop is simply a page with
   a good product photograph on it.
   ═══════════════════════════════════════════════════════════════════════ */

.turntable:not(.is-ready) .turntable-hint {
    display: none;
}

.turntable:not(.is-ready) .turntable-stage {
    cursor: default;
}

.turntable-stage {
    transition: transform var(--transition);
}

.turntable.is-dragging .turntable-stage {
    transition: none;
}

.turntable-frame {
    transition: none;
}


/* ═══ THE HERO SWAP ══════════════════════════════════════════════════════
   The prev/next circles under the hero copy move the drop between origins.
   A short dip in opacity with a small directional push, carried by the
   .is-swapping + .is-prev / .is-next classes main.js puts on .drop for the
   length of one swap. Selectors that the markup does not use are inert.
   ═══════════════════════════════════════════════════════════════════════ */

.drop-cup,
.drop-splash {
    transition:
        opacity   var(--transition),
        transform var(--transition);
}

.drop.is-swapping .drop-cup,
.drop.is-swapping .drop-splash {
    opacity: .3;
}

.drop.is-swapping.is-next .drop-cup {
    transform: translate3d(-4%, 0, 0);
}

.drop.is-swapping.is-prev .drop-cup {
    transform: translate3d(4%, 0, 0);
}


/* ═══ FORM FEEDBACK ══════════════════════════════════════════════════════
   main.js writes into the .field-error elements the contact page already
   emitted; it never creates markup. An empty error box takes up no visual
   room and an arriving message drops 4px into place, so a message that
   changes between two invalid values still reads as a new message.
   The colour is style.css's business, not this file's.

   --fx-curve-in here, by the two-curve test. The visitor did cause this —
   they submitted, or they left the field — but what arrives is a SENTENCE
   they now have to read, and a sentence that bounces into place while
   someone is reading it is the exact case the calm curve exists for.
   ═══════════════════════════════════════════════════════════════════════ */

.field-error {
    transition:
        opacity   var(--fx-in) var(--fx-curve-in),
        transform var(--fx-in) var(--fx-curve-in);
}

html.has-js .field-error:empty {
    opacity: 0;
    transform: translate3d(0, -4px, 0);
}


/* ═══════════════════════════════════════════════════════════════════════
   PREFERS-REDUCED-MOTION — MANDATORY, AND LAST
   The marquee stops dead. Every transform-based animation and transition
   is removed. Every reveal target lands finished and visible: this block
   must never be able to leave content hidden, so it clears the resting
   state explicitly rather than trusting animations.js to have bailed out.
   ═══════════════════════════════════════════════════════════════════════ */
/* ═══ THE CUP SHOWCASE ════════════════════════════════════════════════════
   Replaced the drag-to-spin turntable. The tilt ANSWERS A POINTER, so by the
   two-curve rule at the top of this file it keeps --fx-curve, the overshoot.
   Layout lives in style.css; only the motion is here. */

.cup-show img {
    transform: rotate3d(1, -.35, 0, 0deg);
    transition: transform .45s var(--fx-curve);
    will-change: transform;
}

.cup-show:hover img,
.cup-show:focus-within img {
    transform: rotate3d(1, -.35, 0, 13deg) translateY(-8px) scale(1.02);
}


@media (prefers-reduced-motion: reduce) {

    /* The cup sits flat and still. */
    .cup-show img,
    .cup-show:hover img,
    .cup-show:focus-within img {
        transform: none;
        transition: none;
    }

    /* The marquee: stopped, not slowed. Held at its first frame, so the
       first origin is the one you read. */
    .ticker-track,
    .ticker:has(.ticker-track + .ticker-track) .ticker-track {
        animation: none;
        transform: none;
    }

    /* Reveals: finished, visible, no rise, no delay, no animation —
       whether or not .is-in was ever added. */
    .reveal,
    html.has-js .reveal,
    html.has-js .reveal:not(.is-in),
    html.has-js .reveal.is-in {
        opacity: 1;
        transform: none;
        animation: none;
        transition: none;
    }

    /* The nav panel appears and disappears, it does not travel. */
    html.has-js .nav-panel:not(.is-open),
    html.has-js .nav-panel.is-open,
    .nav-panel {
        opacity: 1;
        transform: none;
        transition: none;
    }

    /* Every remaining transform move on the site, removed at the source
       rather than shortened — including the ones style.css declares for
       itself (.btn:hover, .card:hover, .origin-card:hover and the hero
       circles all translateY there, and this file is the only place that
       honours the preference).

       ⚠ TWO STATIC TRANSFORMS ARE DELIBERATELY ABSENT FROM THIS LIST:
       .wb / .wb-1…5, the letter wobble, and .drop-beans, which is centred
       with `translate: -50% 0`. Neither one animates — they are a fixed
       rotation and a fixed offset that happen to be expressed as
       transforms. A blanket `transform: none` here would flatten the
       wobble (a first-timer, and the reason the headings read hand-set)
       and knock the bean scatter half its own width off centre. Reduced
       motion means "do not move things", not "delete the design". */
    .card,
    .origin-card,
    .promise-tiles > *,
    .card:hover,
    .origin-card:hover,
    .promise-tiles > *:hover,
    .card:focus-within,
    .origin-card:focus-within,
    .card-media img,
    .origin-card img,
    .card:hover .card-media img,
    .origin-card:hover img,
    .btn,
    .btn:hover,
    .btn:active,
    .chip,
    .chip:active,
    .drop-prev,
    .drop-next,
    .drop-prev:hover,
    .drop-next:hover,
    .nav-link,
    .nav-toggle,
    .nav-toggle:active,
    .turntable-stage,
    .turntable-frame,
    .drop-cup,
    .drop-splash,
    .drop.is-swapping .drop-cup,
    .drop.is-swapping .drop-splash,
    .drop.is-swapping.is-next .drop-cup,
    .drop.is-swapping.is-prev .drop-cup,
    .field-error {
        transform: none;
        transition: none;
        animation: none;
    }

    /* The hero still swaps, it just does not slide or fade to get there. */
    .drop.is-swapping .drop-cup,
    .drop.is-swapping .drop-splash {
        opacity: 1;
    }

    /* An empty error box still takes no visual room; a real message is
       still visible. It simply arrives without travelling. */
    html.has-js .field-error:empty {
        opacity: 0;
    }
}
