/* ==========================================================================
   OstuGrupp — feedback while waiting
   Loaded by inc/feedback.php AFTER main.css, woocommerce.css and filters.css
   (declared as dependencies), so equal-specificity rules here win on order
   against those three.

   WHERE THIS SHEET ACTUALLY SITS — measured, not assumed. curl of
   http://og.local/pood/ on 2026-08-15, reading the <link> order: this is sheet
   23 of 32, NOT the last one. Five theme sheets load AFTER it —
   chrome-polish.css, surfaces-d4-d12.css, motion.css, choreography.css,
   type-rhythm.css — plus any sibling module that sorts after "feedback"
   alphabetically (today: category-header.css, search-ux.css).
   Grepped all seven for the selectors used below: `.ostu-skeleton*` appears in
   NONE of them (its only other home in the whole theme is filters.css), and the
   only later sheet that touches the catalogue add-to-cart button is motion.css
   (lines 118-175, 670-690, 795-810). What it sets there is transition-property
   / -duration / -timing-function plus :hover and :active transforms — never a
   colour VALUE, never opacity, pointer-events or a pseudo-element, so it cannot
   take any of block 3 away. One live interaction, and it is a welcome one:
   motion.css:139 lists `color` among the transitioned properties, so the label
   going transparent below eases out over --og-move (200ms) instead of snapping.
   So nothing here loses to a later sheet — but the claim is "checked", not
   "assumed", and if a sheet is added after this one the check has to be redone.
   Where a rule must beat an existing one it is raised on its own merit and the
   arithmetic is written above it — order alone is not trusted, because a cache
   plugin that concatenates stylesheets can reshuffle it.

   Three blocks:
     1  ghost product grid  — resize the EXISTING skeleton to the real card
     2  navigation progress bar — new element, nothing like it existed
     3  add-to-cart in-flight — spinner inside the button, width unchanged
   ========================================================================== */


/* ==========================================================================
   1 · GHOST PRODUCT GRID
   ==========================================================================
   The skeleton markup comes from assets/js/filters.js buildSkeleton():
     ul.ostu-skeleton > li.ostu-skeleton__card
       > div.ostu-skeleton__img
       > div.ostu-skeleton__body > span.ostu-skeleton__line x3
   That markup is fixed and is NOT changed here — only its geometry.

   WHY: filters.css:481-505 sizes the ghost card as
     img (aspect-ratio 1/1, = column width) + body (14+16 padding, three lines
     12+12+18 with 10px gaps and a 4px nudge = 96px) + 2px borders.
   The real card was MEASURED in the browser at 165x429 (375px viewport) and
   215x476 (1280px viewport). Working the container width back out of those
   (4 cols x 215 + 3 gaps x 16 = 908px; 2 x 165 + 10 = 340px) and re-dividing it
   by the ghost's own 20px gap, the old ghost card came out 212x308 at 1280px
   and 160x256 at 375px — SHORT BY 168px and 173px, and 3-5px too narrow.
   Twelve cards over six rows is roughly a thousand pixels of page height that
   vanished on every filter press and came back when the products landed.
   ⚠ Those two ghost figures are ARITHMETIC from the two measured cards, not
   two more measurements. The 429 and 476 are the measured ones.

   HOW THE HEIGHT IS FIXED — two layers, so it is right with or without JS:
     a) assets/js/feedback.js measures a REAL rendered .product-card and writes
        its pixel height into --ostu-fb-card-h on .js-ostu-shop-main. That is a
        measurement, not a guess, and it is re-taken on resize and whenever the
        grid is replaced. This is the path that actually runs.
     b) With no JS (or before the first measurement) the card falls back to
        height:auto and the body carries a min-height floor. The image is
        aspect-ratio 1/1 so it sizes itself from the column, which means only
        the BODY height has to be assumed — and the body height is very nearly
        constant across widths: 429-163-2 = 264 at 375px, 476-213-2 = 261 at
        1280px. 262px sits between them.
        ⚠ 262px is ARITHMETIC derived from two browser measurements, not a
        third measurement. It is the fallback only.

   THE TWO LAYERS MUST NOT FIGHT — this is a fix, v2 of this file was wrong.
   The floor used to be UNCONDITIONAL, so it kept applying after (a) had already
   pinned the exact height, and flex-shrink was 0 so the body could not give the
   pixels back. Worked example at 1280px: the card is pinned to the measured
   476px, the square image takes 213px, 2px of border, so the body's share of
   the box is 476-213-2 = 261px — but the floor forced 262px. One pixel of the
   ghost button was cut off by the card's overflow:hidden (filters.css:497).
   One pixel is nothing; the ARITHMETIC is the problem, because at any width
   where the real body is genuinely shorter than 262px the same subtraction
   loses more than a pixel — and 768-1023px (the 3-column band) has never been
   measured by anyone.
   Fix: --ostu-fb-skel-floor. feedback.js writes it to 0px in the same statement
   that writes the measured height, so the floor exists ONLY while the
   measurement does not. With the floor gone the body is flex:1 1 auto against a
   pinned box, i.e. exactly what the real .product-card__body is
   (woocommerce.css:131 — flex:1 1 auto; min-height:0), so the ghost fills the
   measured height precisely instead of overshooting it.
   ------------------------------------------------------------------------- */

/* Grid geometry must match ul.products EXACTLY or the ghost columns do not sit
   where the real ones will. woocommerce.css:88-93 + 1590 + 1755 are the source
   of truth; the column counts already agreed, the GAPS did not (the ghost used
   one 20px value at every width).

   SPECIFICITY: filters.css:481 is `.ostu-skeleton` (0,1,0). Scoping to the
   shop column that always contains it gives (0,2,0) — wins on merit, not just
   on load order. The skeleton is inserted into .js-ostu-shop-main by
   filters.js showSkeleton(), so the scope always holds. */
.js-ostu-shop-main .ostu-skeleton {
  gap: var(--space-16, 16px);
  margin: var(--space-24, 24px) 0 0;
}

/* Mirrors woocommerce.css:1590 (max-width:768px). Note the deliberate overlap
   with the min-width:768px column rule in filters.css — at exactly 768px the
   real grid is 3 columns AND 12px gap, and so is this one. */
@media (max-width: 768px) {
  .js-ostu-shop-main .ostu-skeleton {
    gap: var(--space-12, 12px);
    margin-top: 18px;
  }
}
/* Mirrors woocommerce.css:1755 (max-width:480px). */
@media (max-width: 480px) {
  .js-ostu-shop-main .ostu-skeleton { gap: 10px; }
}

/* The card itself. filters.css:493 leaves it display:block; a flex column is
   what lets the image keep its square while the body absorbs the remainder of
   the measured height. (0,2,0) over (0,1,0). */
.js-ostu-shop-main .ostu-skeleton__card {
  display: flex;
  flex-direction: column;
  height: var(--ostu-fb-card-h, auto);
}

/* Square media block, exactly like .product-card__media (woocommerce.css:125:
   aspect-ratio 1/1, flex:none, --muted ground). flex:none stops it collapsing
   when the card height is pinned. */
.js-ostu-shop-main .ostu-skeleton__img {
  flex: none;
}

/* Body padding + gap mirror .product-card__body (woocommerce.css:131 → 14px /
   7px). filters.css:501 used 14px 16px 16px / 10px, which is why the ghost body
   never lined up with the real one.
   flex: 1 1 auto — the real body's own value (woocommerce.css:131). It grows
   into the pinned card height and, once feedback.js has zeroed the floor, it
   can also give height back, so the ghost lands ON the measured height instead
   of a pixel or more past it. min-height is the no-JS fallback only: the var is
   set to 0px by feedback.js the moment a real card has been measured. */
.js-ostu-shop-main .ostu-skeleton__body {
  padding: 14px;
  gap: 7px;
  flex: 1 1 auto;
  min-height: var(--ostu-fb-skel-floor, 262px);
}
@media (max-width: 768px) {
  .js-ostu-shop-main .ostu-skeleton__body { padding: var(--space-12, 12px); gap: 6px; }
}
@media (max-width: 480px) {
  .js-ostu-shop-main .ostu-skeleton__body { padding: 11px; }
}

/* Two title lines, then the price pinned to the bottom by margin-top:auto —
   the same trick .product-card .price uses (woocommerce.css:172), so the ghost
   puts its price where the real price lands. filters.css:505 had a 4px nudge
   instead, which floated the price up under the title. */
.js-ostu-shop-main .ostu-skeleton__line--title { width: 92%; height: 13px; }
.js-ostu-shop-main .ostu-skeleton__line--sub   { width: 62%; height: 13px; }
.js-ostu-shop-main .ostu-skeleton__line--price {
  width: 45%;
  height: 22px;
  margin-top: auto;
}

/* The ghost of the "Lisa korvi" button — a 44px block in a washed-out brand
   green, so the loading grid reads as THIS shop's cards rather than as a stack
   of grey boxes. This is the piece that makes the skeleton recognisable from
   across the room.

   ::after and not ::before: nothing else claims ::after on this element, while
   ::before is already the cart icon on the real button (woocommerce.css:308) —
   worth keeping the two habits apart.
   44px is the same touch-target minimum the real button carries
   (woocommerce.css:301 min-height:44px). */
.js-ostu-shop-main .ostu-skeleton__body::after {
  content: "";
  flex: none;
  width: 100%;
  height: 44px;
  border-radius: var(--radius, 12px);
  /* A precomputed literal, not color-mix(): an unsupported color-mix would
     invalidate the WHOLE declaration and leave the ghost button invisible. The
     @supports block below upgrades it to the token-derived colour where that is
     safe, so the brand green stays the single source if it is ever retuned. */
  background: rgba(4, 120, 87, .12);
  animation: ostu-fb-breathe 1.9s ease-in-out infinite;
}
@supports (background: color-mix(in srgb, red 10%, transparent)) {
  .js-ostu-shop-main .ostu-skeleton__body::after {
    background: color-mix(in srgb, var(--green, #047857) 12%, transparent);
  }
}

@keyframes ostu-fb-breathe {
  0%, 100% { opacity: 1; }
  50%      { opacity: .55; }
}


/* ==========================================================================
   2 · NAVIGATION PROGRESS BAR
   ==========================================================================
   New element. Nothing in inc/ or assets/ drew one (grepped for
   progress-bar / nav-progress / pageshow / beforeunload — no hits).
   Driven by assets/js/feedback.js on same-origin link clicks only.

   z-index sits on the toast layer (tokens.css:287, --z-toast:1200). It is the
   one layer allowed above a modal, and while this bar is showing the document
   is on its way out anyway. The bar is at the top edge and the cart toast is
   bottom-centre, so the two never meet.
   ------------------------------------------------------------------------- */
.ostu-fb-progress {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 4px;
  z-index: var(--z-toast, 1200);
  pointer-events: none;
  opacity: 0;
  /* The delay means a navigation that resolves instantly never flashes a bar
     at the shopper; only a wait long enough to notice gets one. */
  transition: opacity .2s ease .12s;
}
.ostu-fb-progress.is-active {
  opacity: 1;
  transition-delay: 0s;
}

.ostu-fb-progress__bar {
  display: block;
  height: 100%;
  width: 0;
  border-radius: 0 2px 2px 0;
  background: linear-gradient(90deg, var(--green, #047857), var(--green-bright, #34d399));
  box-shadow: 0 0 10px rgba(4, 120, 87, .55), 0 1px 4px rgba(4, 120, 87, .35);
  transition: width .25s ease;
}

/* The owner browses the site LOGGED IN, and #wpadminbar (z-index 99999) would
   cover a bar at top:0 — the one person who most needs to see this would be the
   one person who could not. From 783px up the admin bar is position:fixed and
   32px tall, so the bar is pushed below it.

   BELOW 783px this is NOT fully solved, and the earlier note here ("top:0 stays
   correct there") was wrong. The mobile admin bar is position:absolute and 46px
   tall, so it sits at the top of the DOCUMENT, not the viewport: while the page
   is scrolled to the very top it still covers a fixed bar at top:0, and from
   ~46px of scroll onwards the bar is fully visible. Pushing to top:46px would
   fix the first case and break the second (the bar would then float in empty
   space once the admin bar had scrolled away), so it is left alone
   deliberately. Affects logged-in admins on a phone only; a shopper never has
   an admin bar. */
@media (min-width: 783px) {
  body.admin-bar .ostu-fb-progress { top: 32px; }
}


/* ==========================================================================
   3 · ADD-TO-CART, IN FLIGHT
   ==========================================================================
   Today the catalogue button only dims: woocommerce.css:327
   `.product-card a.add_to_cart_button.loading { opacity: .85 }`.

   ⚠ THIS DOES NOT HANG ANYTHING ON WooCommerce'S OWN `.loading` CLASS, and
   that is deliberate. assets/css/controls-d1-d2.css:373-392 documents the
   reason at length: WooCommerce's add-to-cart script adds `.loading` before the
   request and removes it in exactly one place — the `added_to_cart` handler.
   There is no error handler, so a dropped connection leaves `.loading` on the
   button until the page is reloaded. A spinner keyed to that class would spin
   for ever on a failed request, which is a UI that lies.
   The class used here (.ostu-fb-busy) is added AND removed by
   assets/js/feedback.js, which clears it on `added_to_cart` (which hands over
   the exact button), on the jQuery `ajaxComplete` OF THAT BUTTON'S OWN request
   matched by product id (fires on failure too), and on a per-button 10s timer.
   Because the exit is guaranteed, pointer-events:none is safe here where it was
   not safe there.

   pointer-events:none covers mouse and touch and NOTHING ELSE. Keyboard
   activation of an <a> is not a pointer event: Enter fires the native click,
   and WooCommerce's own add-to-cart.js additionally binds keydown for Space and
   calls .click() itself (WC 11.0.1, add-to-cart.min.js, delegated on
   document.body). Both would have queued a SECOND add. That gap is closed in
   feedback.js by a capture-phase keydown guard, not by CSS — the two together
   are what make "cannot be pressed twice" true rather than nearly true.

   WIDTH DOES NOT CHANGE. The label is not replaced with "Lisan…" and no node is
   inserted; the text stays in the layout and only its colour goes transparent,
   the cart icon keeps its 17px box and only its opacity goes to 0, and the
   spinner is an absolutely-positioned ::after that takes no space. So the
   button's box is byte-for-byte the same and the card cannot reflow.

   SPECIFICITY: `.product-card a.add_to_cart_button.loading` (woocommerce.css
   :327) is 3 classes + 1 type = (0,3,1). These are also (0,3,1) and only need
   to add properties that rule never sets, so there is no contest to win. */
.product-card a.add_to_cart_button.ostu-fb-busy,
.product-card__actions a.add_to_cart_button.ostu-fb-busy {
  position: relative;
  color: transparent;
  pointer-events: none;
}

/* The cart glyph. The rule that draws it —
   `.product-card__actions a.add_to_cart_button:not(.added)::before` — counts
   .product-card__actions + .add_to_cart_button + .add_to_cart_button's :not()
   argument as 3 classes, plus the `a` type and the pseudo-element: (0,3,2).
   These match at (0,3,2) and win on load order, which is why feedback.css
   declares ostugrupp-woocommerce as a dependency rather than hoping. */
.product-card a.add_to_cart_button.ostu-fb-busy::before,
.product-card__actions a.add_to_cart_button.ostu-fb-busy::before {
  opacity: 0;
}

.product-card a.add_to_cart_button.ostu-fb-busy::after,
.product-card__actions a.add_to_cart_button.ostu-fb-busy::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: 18px;
  height: 18px;
  margin: -9px 0 0 -9px;
  border: 2px solid rgba(255, 255, 255, .45);
  border-top-color: #fff;
  border-radius: var(--radius-circle, 50%);
  animation: ostu-fb-spin .7s linear infinite;
}

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


/* ==========================================================================
   4 · REDUCED MOTION
   ==========================================================================
   filters.css:523-525 already silences the skeleton shimmer; that block cannot see
   the two animations introduced above, so they are handled here.

   The spinner is SLOWED, not stopped — a spinner frozen mid-rotation says
   nothing at all, and it would be the only signal left once the label has gone
   transparent. This copies the decision already made for .ostu-spinner
   (filters.css:541), so the shop behaves the same way in both places.
   Everything else — the breathing button ghost, the bar's width and fade
   transitions — is switched off outright.
   ------------------------------------------------------------------------- */
@media (prefers-reduced-motion: reduce) {
  .js-ostu-shop-main .ostu-skeleton__body::after {
    animation: none;
  }
  .ostu-fb-progress,
  .ostu-fb-progress__bar {
    transition: none;
  }
  .product-card a.add_to_cart_button.ostu-fb-busy::after,
  .product-card__actions a.add_to_cart_button.ostu-fb-busy::after {
    animation-duration: 1.6s;
  }
}
