/* ENTTEC homepage products section.
 * Ported from _kim/mockups/homepage-2026-07/products-section-v6.html.
 * Scoped under .hpp. Do not edit the mockup and expect this to follow;
 * this file is the shipping copy.
 */
/* ==========================================================================
   PRODUCTS SECTION v6.

   Follows the flow Nicolas already built on /products/:
     - a category control filters the product grid IN PLACE
     - tiles are a bordered thumbnail, a category chip, a title and one line
     - a live result count sits with the control

   Differences from /products/, and why:
     - Chips instead of a checkbox modal. On /products/ the filter lives behind
       a "Filter by Category" button because that page has 37 products and a
       search field to sit beside. A homepage section has no room for a modal
       and no search, so the same choice is exposed directly. Same interaction,
       one less click.
     - Single select, not multi. Multi-select needs Clear All, a count of
       active filters and an empty state. On a homepage the question is "show
       me one kind of thing", so one active chip covers it.
     - One row of 5, then "Show more" adds another row. Keeps the section small.

   DENSITY PASS (2026-07-28), after a UI/UX audit:
     Started at about 1090px showing 8 products with a name and one prose line.
     Now about 990px showing 12, each carrying the real /products/ description
     and SKU. Changes: 6 columns instead of 4, SQUARE images (the WooCommerce
     standard), real copy pulled from /products/, and a tighter chip row that
     no longer wraps to two lines.
     Square is the expensive choice, 60% taller than 16:10 at equal width, so
     the column count went up to pay for it.
     Rationale: density should match the audience. These are spec-driven
     products bought by people who have already chunked the domain, so a large
     render earns little and the specs earn a lot. Applying novice-grade
     minimalism to an expert audience is the documented failure mode.

   Kim's requirement, and the bit /products/ does not do: once a category is
   chosen, offer the dedicated category page. That link appears next to the
   count and changes with the selection.

   All 37 products, names, URLs, images and categories scraped from the live
   category pages. Every image checked for a 200.
   ========================================================================== */

.hpp{
  --ink:#101822;--ink-2:#3c4658;--ink-mute:#5d6a7c;--ink-faint:#9aa6b6;
  --bg:#ffffff;--bg-alt:#f4f7fb;--line:#e4e9f0;--line-2:#d7dee8;
  --brand:#0ea5df;
  --acct-blue:#0ea5df;--acct-blue-hover:#0c8bbb;
  --acct-border:#d8d8d8;--acct-border-soft:#e6e8eb;
  --acct-text:#1a1a1a;--acct-text-2:#2b2b2b;--acct-text-muted:#6c757d;
  --acct-surface-2:#f7f8fa;--acct-shadow-sm:0 2px 8px rgba(16,24,40,.08);
  --acct-radius-sm:6px;--acct-radius-lg:12px;
  --acct-radius:8px;
  --acct-ring:0 0 0 3px rgba(14,165,223,.35);
  /* The account DS uses the SOFTER ring on fields and the stronger one on
     buttons. Kept as two tokens so the sort select can match the account
     fields exactly rather than approximately. */
  --acct-ring-soft:0 0 0 3px rgba(14,165,223,.25);
  --acct-dur:170ms;--acct-ease:cubic-bezier(.4,0,.2,1);
  --ehp-maxw:1200px;--ehp-pad:clamp(22px,5vw,64px);
  --hsans:"Montserrat","Helvetica Neue","Segoe UI",system-ui,sans-serif;
  --bsans:"Source Sans 3","Segoe UI",system-ui,-apple-system,sans-serif;
  background:var(--bg);color:var(--ink);font-family:var(--bsans);font-size:16px;line-height:1.6;
  -webkit-font-smoothing:antialiased;width:100%;overflow-x:hidden;
}
.hpp *{box-sizing:border-box}
/* Must precede every rule that sets display: the UA's [hidden]{display:none}
   has effectively no specificity and any author display rule defeats it. */
.hpp [hidden]{display:none!important}
.hpp :where(h2,h3){margin:0;font-weight:inherit}
.hpp :where(p){margin:0}
.hpp h2,.hpp h3{font-family:var(--hsans);letter-spacing:-.01em;line-height:1.12}
.hpp img{max-width:100%;display:block}
.hpp a{color:inherit;text-decoration:none}
.hpp .hpp-wrap{max-width:var(--ehp-maxw);margin-inline:auto;padding-inline:var(--ehp-pad)}
/* Compound AND descendant. The section element carries BOTH classes
   (<section class="hpp hpp-blk">), so a descendant selector alone never matches
   it and the section renders with no vertical padding at all. The descendant
   form is kept for any nested block. */
.hpp.hpp-blk,.hpp .hpp-blk{padding-block:clamp(60px,7.5vw,104px)}
/* 700, matching .enttec-hp .ehp-eyebrow on the rest of the homepage. Ours was
   500, carried over from the standalone prototype where Kim had asked for a
   lighter weight; against the live page's other eyebrows it read as a different
   component. If the lighter weight is wanted, change it on home.php for all of
   them rather than diverging here. */
.hpp .hpp-eyebrow{font-family:var(--hsans);font-size:13px;letter-spacing:.2em;text-transform:uppercase;font-weight:700;color:var(--brand)}
.hpp .hpp-lnk{color:var(--brand);font-weight:600;font-size:15px;
  text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:3px}
.hpp .hpp-lnk:hover{color:var(--acct-blue-hover)}

/* Bootstrap 4.5 is loaded page-wide and sets
   button:focus{outline:1px dotted;outline:5px auto -webkit-focus-ring-color}.
   That fires on MOUSE CLICK, because :focus is not :focus-visible, which is the
   dark ring appearing when you click a chip. Suppressing :focus here is only
   safe because every one of these elements has a :focus-visible ring below, so
   keyboard users still get a visible indicator. Never suppress :focus without
   that pair. */
.hpp .hpp-chip:focus,
.hpp .hpp-card:focus,
.hpp .hpp-golink:focus,
.hpp .hpp-showmore button:focus{outline:none;box-shadow:none}

.hpp a:focus-visible{outline:3px solid var(--acct-blue);outline-offset:3px;border-radius:2px}
.hpp button:focus-visible{outline:none;box-shadow:var(--acct-ring)}
.hpp .hpp-chip:focus-visible{outline:none;box-shadow:var(--acct-ring)}
.hpp .hpp-card:focus-visible{outline:none;box-shadow:var(--acct-ring)}

.hpp .hpp-sec-head{display:flex;justify-content:space-between;align-items:flex-end;gap:24px;flex-wrap:wrap;margin-bottom:26px}
.hpp .hpp-sec-head h2{font-size:clamp(28px,3.2vw,44px);font-weight:700;margin-top:12px;color:var(--ink)}

/* --- Category chips -----------------------------------------------------
   Real <button>s with aria-pressed, in a group labelled for screen readers.
   Not links: this filters the list on the page, it does not navigate. The
   navigation option is the separate "Go to the ... page" link, which is the
   thing Kim asked for and /products/ does not offer.
   ---------------------------------------------------------------------- */
/* ONE LINE, ALWAYS. The row wrapped to a second line for a single chip, which
   cost 42px of height to say nothing, and a two-line filter row reads as two
   groups of options rather than one set.
   nowrap plus overflow-x makes it a horizontal scroller, the pattern YouTube,
   Google's result filters and every app store use for exactly this control.
   The known weakness of that pattern is DISCOVERABILITY: a flat cut edge does
   not say "there is more", especially on desktop where nobody thinks to swipe.
   So it gets three affordances, not just one:
     1. an edge fade, so the row visibly continues rather than stopping,
     2. arrow buttons on pointer devices, because a mouse has no swipe,
     3. scroll-padding, so keyboard focus never lands half-under a fade.
   All three are applied by JS ONLY WHEN THE ROW ACTUALLY OVERFLOWS. A fade over
   a row that already fits is a decoration that lies about there being more, and
   at 1440 with a short category set the row does fit. */
.hpp .hpp-chipbar{position:relative;margin-bottom:14px}
.hpp .hpp-chips{display:flex;flex-wrap:nowrap;gap:6px;overflow-x:auto;overflow-y:hidden;
  scroll-behavior:smooth;scroll-padding-inline:44px;
  /* NO scroll-snap. It was tried and removed: with snap-align on every chip the
     browser re-snapped after each programmatic scroll, so a 120px scroll landed
     back at 0 and small trackpad nudges bounced. Snapping earns its keep when
     each item is a full-width panel you are meant to land ON. A chip row is
     read as one continuous strip, so free scrolling is the correct behaviour. */
  /* The row is its own scroll container, so a horizontal trackpad swipe inside
     it must not also scroll the page sideways once it hits the end. */
  overscroll-behavior-x:contain;
  /* The native bar would sit under the chips and add 8-15px of height for a
     control we are replacing with fades and arrows. Keyboard and touch scrolling
     both still work; only the visible bar goes. */
  scrollbar-width:none;-ms-overflow-style:none;
  /* Room for the focus ring, which is drawn OUTSIDE the chip and would be
     clipped by overflow:hidden on the cross axis. */
  padding-block:4px;margin-block:-4px}
.hpp .hpp-chips::-webkit-scrollbar{display:none}
/* flex:0 0 auto is load-bearing: without it flex-shrink squeezes every chip to
   fit the row instead of letting the row overflow, so nothing ever scrolls and
   the labels just get crushed. */
.hpp .hpp-chip{flex:0 0 auto}

/* Fades. pointer-events:none so they never eat a click on the chip beneath.
   Each is toggled independently: no fade on the left until you have actually
   scrolled, which is what tells you there is something behind you. */
.hpp .hpp-chipbar::before,
.hpp .hpp-chipbar::after{content:"";position:absolute;top:0;bottom:0;width:56px;
  pointer-events:none;opacity:0;transition:opacity var(--acct-dur) var(--acct-ease);z-index:1}
.hpp .hpp-chipbar::before{left:0;background:linear-gradient(to right,#fff 18%,rgba(255,255,255,0))}
.hpp .hpp-chipbar::after{right:0;background:linear-gradient(to left,#fff 18%,rgba(255,255,255,0))}
.hpp .hpp-chipbar.can-back::before{opacity:1}
.hpp .hpp-chipbar.can-fwd::after{opacity:1}

/* Arrows. Hidden by default and revealed only when the row overflows AND the
   device has a fine pointer: on touch, swiping is the native gesture and a
   button would just cover a chip. */
.hpp .hpp-chipnav{position:absolute;top:50%;transform:translateY(-50%);z-index:2;
  display:none;align-items:center;justify-content:center;width:30px;height:30px;padding:0;
  background:#fff;border:1px solid var(--acct-border);border-radius:50%;cursor:pointer;
  color:var(--acct-text-2);box-shadow:var(--acct-shadow-sm,0 2px 8px rgba(16,24,40,.08));
  transition:border-color var(--acct-dur) var(--acct-ease),color var(--acct-dur) var(--acct-ease),
             background var(--acct-dur) var(--acct-ease),opacity var(--acct-dur) var(--acct-ease)}
.hpp .hpp-chipnav svg{width:12px;height:12px;display:block}
.hpp .hpp-chipnav:hover{border-color:var(--acct-blue);color:var(--acct-blue);background:var(--acct-surface-2)}
.hpp .hpp-chipnav:focus{outline:none;box-shadow:none}
.hpp .hpp-chipnav:focus-visible{outline:none;box-shadow:var(--acct-ring)}
.hpp .hpp-chipnav-back{left:-6px}
.hpp .hpp-chipnav-fwd{right:-6px}
@media(hover:hover) and (pointer:fine){
  .hpp .hpp-chipbar.can-back .hpp-chipnav-back,
  .hpp .hpp-chipbar.can-fwd .hpp-chipnav-fwd{display:inline-flex}
}
/* 11px inline padding and a 6px gap, down from 13 and 7. With TEN chips that was
   what kept the row on one line at 1440: it measured 1095px against 1072px of
   content before the trim, and a scroller plus an arrow for 23px of overflow is
   all cost and no benefit. Height is untouched, so tap targets never shrank.

   THAT NO LONGER HOLDS. Splitting LED dimmers out of LED strip made it eleven
   chips, and at 1440 the row now measures 1121px against 1072px, so it overflows
   by 49px and the scroller engages with one arrow showing.

   Measure this with the webfont LOADED (`await document.fonts.ready`) or the
   number lies: the same row reads 1078px on the fallback face, which is what made
   a real 49px overflow look like 6px and nearly bought a pointless 1px gap trim
   (tried, reverted: at 5px it still overflowed by 39px).

   The 49px is NOT recoverable from spacing. Padding to 9px buys 22px, and the
   font is already at 12.5px against a 12px floor. The real lever is the label
   "Popular & new", worth about 45px on its own, and shortening it is a copy
   decision, not a CSS one. Scrolling at 1440 is accepted until that is settled. */
.hpp .hpp-chip{font-family:var(--hsans);font-size:12.5px;font-weight:600;color:var(--acct-text-2);
  background:var(--bg);border:1px solid var(--line-2);border-radius:99px;padding:8px 11px;cursor:pointer;
  text-decoration:none;
  transition:background var(--acct-dur) var(--acct-ease),border-color var(--acct-dur) var(--acct-ease),
             color var(--acct-dur) var(--acct-ease),transform 60ms var(--acct-ease)}
.hpp .hpp-chip:hover{border-color:var(--acct-blue);color:var(--acct-blue);background:var(--acct-surface-2)}
/* Press feedback. These are the most-clicked controls in the section and had
   none, so on touch (no hover) a tap gave nothing back until the grid redrew.
   60ms, well under the 100ms "fast" band, because a press response should feel
   instant rather than animated. */
.hpp .hpp-chip:active{transform:scale(.97)}
/* Selected state carries colour AND weight AND a filled background, so it does
   not rely on hue alone. */
/* Both selectors: buttons carry aria-pressed, links carry aria-current. Keying
   off only one would leave the eight category chips unable to show selection. */
.hpp .hpp-chip[aria-pressed="true"],
.hpp .hpp-chip[aria-current="true"]{background:var(--acct-blue);border-color:var(--acct-blue);color:#fff;font-weight:700}
@media(max-width:640px){.hpp .hpp-chip{padding:10px 14px}}

/* --- Result bar --------------------------------------------------------- */
.hpp .hpp-bar{display:flex;justify-content:space-between;align-items:center;gap:16px;flex-wrap:wrap;
  padding:8px 0 12px;border-bottom:1px solid var(--line);margin-bottom:16px}
.hpp .hpp-count{font-size:14px;color:var(--acct-text-muted)}
.hpp .hpp-count b{color:var(--ink);font-weight:600}
/* The route to the dedicated category page. Sits top right in the result bar,
   opposite the count, so the bar reads "here is what you are looking at" on
   the left and "here is where all of it lives" on the right.
   A text link, not a button: it was briefly a bordered pill and that competed
   with the chips directly above it for the same attention. The clarity comes
   from the label naming the destination outright rather than from a border. */
.hpp .hpp-golink{font-family:var(--hsans);font-size:13.5px;font-weight:600;color:var(--acct-blue);
  display:inline-flex;align-items:center;gap:7px;padding:2px 0;
  transition:color var(--acct-dur) var(--acct-ease)}
.hpp .hpp-golink:hover{color:var(--acct-blue-hover);text-decoration:underline;text-underline-offset:4px}
.hpp .hpp-golink::after{content:"→";transition:transform var(--acct-dur) var(--acct-ease)}
.hpp .hpp-golink:hover::after{transform:translateX(3px)}

/* Sort control. Sits in the result bar opposite the count, only on All.
   MATCHES THE ACCOUNT DESIGN SYSTEM select, not an invented one:
   wp-content/themes/eshop/css/enttec-account-ds2.css, the
   `#enttec-account.acct-ds2 select:not([multiple])` rules. Same 1px #d8d8d8
   border, 8px radius, 14px Source Sans 3 at #2b2b2b, blue border plus the soft
   ring on focus, and the same 12x8 chevron at right 14px center.
   Two deliberate departures, both forced:
   - min-height 38px, not the DS 42px. This sits in a 14px result bar beside a
     13px count, and a 42px control made the bar taller than the chip row above
     it. Same anatomy, one size down.
   - The chevron is a COPY of the theme's select-chevron.svg, in this plugin's
     own assets. A mu-plugin must not reach into a theme directory for an asset;
     the section is meant to survive a theme swap. If the DS chevron is ever
     redrawn, this copy needs the same edit.
   appearance:none is what removes the OS arrow so the DS chevron is the only
   one; without it Windows and macOS each draw their own on top. */
/* WHY THIS IS NOT A <select>.
   A native select's OPEN MENU is drawn by the operating system, not the page.
   Nothing in CSS reaches it: no radius, no shadow, no brand colour, no check
   mark. Styling `option` is ignored on macOS and Windows alike. So the closed
   control matched the account DS and the open list looked like a Windows menu,
   which is exactly what Kim saw.
   (Chrome 135+ can style it via appearance:base-select, but that is one engine
   in 2026, so it would fix the menu for some visitors and not others.)
   The replacement is the APG listbox pattern: a real button that owns the
   value, and a ul[role=listbox] of li[role=option]. Same semantics as a select
   to a screen reader, fully styleable, keyboard-complete. The panel copies the
   account DS dropdown (12px radius, its exact two-layer shadow, 6px options,
   the 10% blue highlight and a blue check on the selected row). */
.hpp .hpp-sort{display:inline-flex;align-items:center;gap:9px;font-family:var(--bsans);font-size:13px;
  color:var(--acct-text-muted);white-space:nowrap}
.hpp .hpp-sel{position:relative}

/* Trigger: the account DS field box, one size down for the result bar. */
.hpp .hpp-sel-btn{display:inline-flex;align-items:center;gap:10px;
  font-family:var(--bsans);font-size:14px;font-weight:400;color:var(--acct-text-2);
  background:#fff;border:1px solid var(--acct-border);border-radius:var(--acct-radius);
  min-height:38px;padding:0 12px;cursor:pointer;line-height:1.4;text-align:left;box-shadow:none;
  transition:border-color var(--acct-dur) var(--acct-ease),box-shadow var(--acct-dur) var(--acct-ease)}
.hpp .hpp-sel-btn:hover{border-color:#b0b6bc}
/* min-width stops the button resizing as the label changes, which would shove
   the whole result bar sideways every time you pick a different sort. */
.hpp .hpp-sel-val{min-width:104px}
/* The DS chevron, as an element rather than a background, so it can rotate to
   signal open. Same 12x8 asset the account selects use. */
.hpp .hpp-sel-btn::after{content:"";width:12px;height:8px;flex:0 0 auto;
  background:url("select-chevron.svg") no-repeat center/12px 8px;
  transition:transform var(--acct-dur) var(--acct-ease)}
.hpp .hpp-sel-btn[aria-expanded="true"]::after{transform:rotate(180deg)}
/* Plain :focus, not :focus-visible. Unlike a chip, this control OPENS
   something, so it must read as active the whole time the panel is up,
   including when it was opened by mouse. */
.hpp .hpp-sel-btn:focus{outline:none;border-color:var(--acct-blue);box-shadow:var(--acct-ring-soft)}
.hpp .hpp-sel-btn[aria-expanded="true"]{border-color:var(--acct-blue);box-shadow:var(--acct-ring-soft)}

/* Panel: the account DS dropdown surface, values copied not eyeballed. */
.hpp .hpp-sel-list{position:absolute;z-index:20;top:calc(100% + 6px);right:0;min-width:100%;
  margin:0;padding:6px;list-style:none;
  background:#fff;border:1px solid var(--acct-border-soft);border-radius:var(--acct-radius-lg);
  box-shadow:0 12px 32px -12px rgba(20,40,60,.30),0 2px 6px rgba(20,40,60,.06);
  transform-origin:top right;animation:hpp-pop 130ms var(--acct-ease)}
@keyframes hpp-pop{from{opacity:0;transform:translateY(-4px) scale(.98)}to{opacity:1;transform:none}}
.hpp .hpp-sel-list li{display:flex;align-items:center;gap:8px;white-space:nowrap;
  font-family:var(--bsans);font-size:14px;color:var(--acct-text-2);
  padding:8px 10px;border-radius:var(--acct-radius-sm);cursor:pointer;
  transition:background var(--acct-dur) var(--acct-ease),color var(--acct-dur) var(--acct-ease)}
.hpp .hpp-sel-list li:hover,
.hpp .hpp-sel-list li.is-active{background:rgba(14,165,223,.10)}
.hpp .hpp-sel-list li:focus{outline:none;background:rgba(14,165,223,.10)}
.hpp .hpp-sel-list li:focus-visible{outline:none;box-shadow:var(--acct-ring)}
.hpp .hpp-sel-list li[aria-selected="true"]{color:var(--acct-blue);font-weight:600}
/* The tick reserves its space always, so selecting a row does not shift the
   label of every other row sideways. */
.hpp .hpp-sel-list li svg{width:12px;height:12px;flex:0 0 auto;opacity:0}
.hpp .hpp-sel-list li[aria-selected="true"] svg{opacity:1}

/* --- Product tiles, matching the /products/ card ------------------------
   Bordered thumbnail, category chip, title, one line. Same anatomy as
   .product-item on /products/ so the two pages feel like one site.
   SKU and description are now the REAL ones, scraped from the /products/ page
   itself, so the two pages cannot drift apart by hand.
   ---------------------------------------------------------------------- */
/* DENSITY PASS: 4 columns -> 5, and the image goes 4:3 -> 16:10.
   At 1440 a 4-col card was 254px wide and spent 191 of its 263px on the image.
   These are spec-driven products: a bigger render of a DIN-rail box adds almost
   nothing, while the specs beside it add a lot. Current guidance splits exactly
   on this line, image-dominant cards for high-visual categories, compact
   information-dense cards for electronics and tools.
   5 columns at 16:10 gives a 201px card with a 126px image, so two rows show
   TEN products in less height than eight took. Gap stays at 16px, the floor
   the density guidance gives before boundaries start to fail. */
/* SQUARE IMAGES, five per row, ONE row by default.
   1:1 is the WooCommerce thumbnail standard and what every product render is
   shot for, so the section assumes it rather than fights it. It is also the
   expensive choice: square is 60% taller than 16:10 at the same width.
   Height is bought back by showing one row instead of two, not by shrinking
   the cards, which keeps the full description readable at 201px.
   At 1440: 201px card, square image, name, full description, SKU. */
.hpp .hpp-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:16px}
@media(max-width:1140px){.hpp .hpp-grid{grid-template-columns:repeat(4,1fr)}}
@media(max-width:920px){.hpp .hpp-grid{grid-template-columns:repeat(3,1fr)}}
@media(max-width:660px){.hpp .hpp-grid{grid-template-columns:repeat(2,1fr);gap:14px}}
@media(max-width:420px){.hpp .hpp-grid{grid-template-columns:1fr}}

/* HOVER: no lift.
   A translateY on a card works when cards are large and loosely spaced. This
   grid is neither: five tight columns on a strict baseline, so one card rising
   out of line breaks the row it sits in and the eye reads the whole row as
   misaligned rather than reading one card as hovered. It was worse next to the
   clipped edge, where a lifting card slid under the fade.
   IMAGE-ONLY HOVER, Kim's pick from six variants compared side by side in
   _kim/mockups/homepage-2026-07/card-radius-hover-lab.html (2026-07-30).
   The shell does not change at all: no border recolour, no surface tint, no
   movement. Only the product scales, under a soft shadow. Everything the eye
   registers is the product itself, which is the thing being browsed.
   WHY THE OTHERS LOST. The previous version changed four things at once (brand
   border, warmed surface, shadow, zoom); at five across and 47 cards that reads
   as busy rather than responsive. A translateY lift was rejected earlier for a
   structural reason that still holds: one card rising out of a strict five-column
   baseline makes the whole ROW look misaligned, and it was worse against the
   clipped edge where a lifting card slid under the fade.
   The scale happens INSIDE the card's own clip, so it changes nothing about the
   grid, and the card still needs its border-radius on the image and the New tab
   (see those rules) because the transform composites the subtree. */
.hpp .hpp-card{display:flex;flex-direction:column;background:var(--bg);
  border:1px solid var(--line);border-radius:10px;overflow:hidden;
  transition:box-shadow var(--acct-dur) var(--acct-ease),border-color var(--acct-dur) var(--acct-ease),
             background var(--acct-dur) var(--acct-ease)}
.hpp .hpp-card:hover{box-shadow:0 4px 14px rgba(16,24,34,.08)}
/* Press feedback for touch, where hover never fires: the shadow drops away so
   the card reads as pushed in. Non-motion, so it survives reduced-motion too. */
.hpp .hpp-card:active{box-shadow:none;background:#eef2f7}
/* FULL BLEED. Was contain plus 12px of padding, which left a ring of empty
   gradient around every render and looked worst on the pixel strips, where a
   wide product floated in the middle of a square with the image visibly just
   stopping. cover fills the square edge to edge, which is also what a stock
   WooCommerce cropped thumbnail does, so the section matches the platform
   instead of fighting it.
   The cost, stated plainly: cover crops. A very wide or very tall source loses
   its edges. That is the right trade for strip and dot renders, which read
   better cropped, and harmless for the boxed units, which are shot centred. */
.hpp .hpp-card-img{position:relative;aspect-ratio:1/1;background:linear-gradient(180deg,#fbfcfe,#eef2f7);
  overflow:hidden;
  /* Own the top corners instead of borrowing the card's clip. 9px is the card's
     10px outer radius minus its 1px border, so the inner edge lines up exactly.
     WHY THIS EXISTS: on hover the <img> gets transform:scale(1.05), which promotes
     this subtree to its own compositor layer, and an ancestor's ROUNDED clip
     degrades to a RECTANGULAR one on that layer. The top corners visibly squared
     off on hover and rounded again on mouse-out. Rounding here, and on .hpp-new,
     makes the corner a property of the element that paints it, so it survives
     compositing. Do not "simplify" these two radii away. */
  border-radius:9px 9px 0 0}
.hpp .hpp-card-img img{width:100%;height:100%;object-fit:cover;object-position:center;
  transition:transform .3s var(--acct-ease)}
/* 1.07, up from 1.05. With the shell no longer changing, the zoom is the whole
   hover signal, so it needs to be readable on its own. */
.hpp .hpp-card:hover .hpp-card-img img{transform:scale(1.07)}
/* Category chip on the tile, as on /products/. */
/* z-index:2 is load-bearing, not decoration. The pill is absolutely positioned
   with z-index auto, and the <img> that follows it in the DOM gets a transform
   on hover. A transform creates a stacking context, so the transformed image
   paints in the same layer as the positioned pill and, being later in source
   order, paints OVER it. The pill vanished under the image on every hover.
   Raising the pill fixes it without touching the hover effect. */
/* KNOWN EXCEPTION to the 12px minimum, Kim's call, asked for twice.
   11px, and no longer uppercase: caps at this size widened every label and
   made a small chip look bulky, which was most of why it read as "too big".
   Sentence case plus the smaller size is a much bigger change than the 1px
   suggests. Recorded rather than hidden: if the floor is ever re-enforced,
   this is the rule that breaks it. */
.hpp .hpp-card-cat{position:absolute;z-index:2;left:8px;top:8px;font-family:var(--hsans);
  font-size:11px;font-weight:600;letter-spacing:.01em;line-height:1.45;
  color:var(--acct-text-muted);background:rgba(255,255,255,.94);border:1px solid var(--line-2);
  border-radius:99px;padding:2px 7px}
/* In a filtered view every card is the same category, so the pill repeats the
   chip above it on every tile. Redundant labelling is the kind of noise that
   makes a dense grid feel heavy without adding anything. */
.hpp .hpp-grid.is-cat .hpp-card-cat{display:none}
/* "New" marker: CORNER TAB.
   Kim's pick, 2026-07-30, after four rejected attempts. Recorded so none of
   them gets re-proposed:
   1. Solid blue PILL, white text. Same shape, radius and fill as the selected
      chip and the primary button, so on a card that is itself a link it read
      as something to press.
   2. Inline text after the product name. Wrapped to its own line on a long
      name, and among 12px grey body text it stopped catching the eye at all.
   3. Blue text over a soft white halo. Legible anywhere, but a gradient with no
      edge next to a grid of crisp bordered cards just looked smudged.
   4. Soft tinted badge, pale blue fill with darker blue text. Quiet enough, but
      it needed two blues to work.
   THE TAB is different from all of them because it is ANCHORED, not floating.
   It sits flush in the corner with square outer edges and one rounded inner
   corner, so it reads as part of the card rather than a chip resting on the
   photo. Nothing else in this section is corner-anchored, so it cannot be
   confused with a control: every button here sits inside its region with space
   around it.

   COLOUR RULE, Kim 2026-07-30: the tag uses ENTTEC blue exactly, #0EA5DF, and
   nothing derived from it. No lightened blue, no darkened blue, no rgba tint of
   blue. Where a lighter tone is needed it comes from the greys instead. That is
   why the drop shadow below is neutral grey rather than the blue-tinted shadow
   this shape would normally get, and why the label is plain white on the full
   brand blue rather than a softer blue-on-blue pairing.
   White on #0EA5DF also happens to be the only pairing here that needs no
   contrast compromise: the brand blue as a FILL is fine, it is the brand blue
   as small TEXT on white that fails, at roughly 2.5:1.

   pointer-events:none so the tag can never intercept a click meant for the
   card, which is one big link. */
/* top/right -1px, NOT 0. The tag is positioned against .hpp-card-img, which sits
   1px inside the card's border, so at 0 its own 9px arc curved away from the
   card's 10px silhouette and the ~1px divergence showed as a pale crescent right
   at the corner (worst at 45 degrees, where two arcs of different radii diverge
   most). Overhanging by 1px pushes the tag out to the card's border box, and
   .hpp-card-img's own clip trims it back to the exact inner curve, so the blue
   reaches the edge with nothing behind it.
   It KEEPS its own radius as well (10px below, matching the card's outer corner):
   belt and braces, so if the clip is ever degraded by compositing the way the
   card's was, the tag still paints a rounded corner of its own instead of a
   square one. Both halves are load-bearing; see the note on .hpp-card-img. */
.hpp .hpp-new{position:absolute;z-index:2;top:-1px;right:-1px;overflow:hidden;
  font-family:var(--hsans);font-size:10.5px;font-weight:600;letter-spacing:.13em;
  text-transform:uppercase;line-height:1;white-space:nowrap;pointer-events:none;
  color:#fff;background:var(--acct-blue);
  padding:6px 9px 6px 11px;border-bottom-left-radius:10px;
  /* 10px, the card's OUTER radius, because the 1px overhang above puts this
     corner on the card's border box rather than 1px inside it. */
  border-top-right-radius:10px;
  box-shadow:-1px 1px 5px rgba(16,24,40,.16)}

/* ENTRY: unfurls from the corner it is pinned to.
   transform-origin at that exact corner is the load-bearing line. With a
   default centre origin the tag grows outward in all directions and reads as a
   box that appeared; pinned to 100% 0 it reads as a tab being pulled out of the
   corner, so the motion explains the shape instead of just decorating it.
   Held until .is-seen, set by an IntersectionObserver once the section is
   actually on screen. This block sits well below the fold, so a load-time
   animation would finish before anyone scrolled to it: all of the cost, none of
   the effect.
   The easing overshoots slightly (the 1.35 in the curve) so it settles with a
   small snap rather than easing flat. On a 300ms move that reads as crisp
   rather than bouncy. */
/* NO opacity:0 resting state, deliberately. Hiding the tag until JS adds
   .is-seen would mean that with JS off, or if the observer never fires, the tag
   never appears at all: an animation would have become load-bearing for
   visibility. The resting state is fully visible and the animation only ever
   ADDS to it. The observer threshold is set low (0.05) so the class lands while
   only the section heading is on screen, well before the cards scroll into
   view, which is what stops a visible flash before the animation starts. */
.hpp.is-seen .hpp-new{animation:hpp-tab-in .34s cubic-bezier(.18,.89,.32,1.35) both;
  transform-origin:100% 0}
@keyframes hpp-tab-in{
  from{opacity:0;transform:scale(.55)}
  to{opacity:1;transform:none}
}
/* A short stagger so two new tags do not snap in on the same frame, which reads
   as one event rather than two separate products. nth-child on the CARD, not
   the tag, because the tags are not siblings. 60ms is under the 100ms that
   registers as a delay, so it is felt as sequencing rather than as waiting. */
.hpp.is-seen .hpp-card:nth-child(even) .hpp-new{animation-delay:.06s}

/* HOVER: a single sheen crossing the tag.
   The card already answers hover three ways (border, shadow, image zoom), so
   this only has to add life, not information. A sheen moves nothing and
   occupies no space, so it cannot disturb the strict grid baseline the way a
   lift or a scale would.
   It runs on hovering the CARD, not the tag, because the tag has
   pointer-events:none and could never be hovered directly. */
.hpp .hpp-new::after{content:"";position:absolute;inset:0;
  background:linear-gradient(105deg,rgba(255,255,255,0) 35%,rgba(255,255,255,.55) 50%,rgba(255,255,255,0) 65%);
  transform:translateX(-120%)}
.hpp .hpp-card:hover .hpp-new::after{animation:hpp-sheen .62s var(--acct-ease) 1}
@keyframes hpp-sheen{from{transform:translateX(-120%)}to{transform:translateX(220%)}}

/* AMBIENT SHEEN: the same sweep, repeating on a long cycle so the section is
   not completely static once the entry animation has finished.
   The whole trick is the REST. The sweep occupies the first 8% of an 8.5 second
   cycle, so it moves for about 0.7s and then sits still for nearly eight. A
   loop that animates continuously is the documented over-animation failure and
   makes text beside it genuinely harder to read; a loop that is mostly rest
   reads as the page being alive rather than as something demanding attention.
   Gated on .is-live, which the observer adds and REMOVES, so nothing animates
   while the section is scrolled off screen. That matters for battery and for
   not burning main-thread time on work nobody can see. */
.hpp.is-live .hpp-new::after{animation:hpp-sheen-loop 8.5s linear infinite}
@keyframes hpp-sheen-loop{
  0%{transform:translateX(-120%)}
  8%{transform:translateX(220%)}
  100%{transform:translateX(220%)}
}

.hpp .hpp-card-body{padding:10px 11px 12px;display:flex;flex-direction:column;gap:4px;flex:1}
.hpp .hpp-card-body .hpp-name{font-size:13.5px;font-weight:600;color:var(--ink);line-height:1.25}
/* Real short description from /products/, verbatim and UNCLAMPED.
   No line-clamp: these run 33 to 91 characters, so clamping cut the longer
   ones mid-sentence. Uneven text length is not a problem here because grid
   rows size to their tallest card and the SKU is pinned to the bottom, so the
   cards in a row still end level. */
/* Clamped to 3 lines. The prototype used the /products/ page's own excerpt,
   which is short; the real WooCommerce short_description is a full paragraph,
   so unclamped it dominated the card and made every row a different height.
   3 rather than 2: at 2 the shorter descriptions were being cut mid-sentence
   for no gain. */
.hpp .hpp-desc{font-size:12px;line-height:1.45;color:var(--acct-text-muted);
  display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
/* SKU sits directly under the name, because the two are read together: the
   name identifies it, the code is what gets quoted in a BOM or an email. It
   was pinned to the card bottom to line up across a row, but that separated it
   from the thing it identifies and put it below the description it has nothing
   to do with. Alignment was the wrong thing to optimise for. */
.hpp .hpp-sku{font-family:var(--hsans);font-size:12px;font-weight:600;letter-spacing:.03em;
  color:var(--ink-faint)}

/* --- "There is more" fade -----------------------------------------------
   A gradient alone over a clean edge is a decoration that says nothing. What
   actually reads as "more below" is CUT-OFF CONTENT: the next row rendered and
   clipped part-way, so the eye sees real tiles disappearing rather than a
   tidy stop. NN/g calls this the illusion of completeness, and it is the same
   reason the hero is sized so the stats band breaks the fold.
   The fade then does its real job, which is stopping the clip from looking
   like a rendering error. */
.hpp .hpp-grid-wrap{position:relative}
.hpp .hpp-grid-wrap.is-clipped{overflow:hidden}
.hpp .hpp-grid-wrap.is-clipped::after{content:"";position:absolute;left:0;right:0;bottom:0;
  height:104px;pointer-events:none;
  background:linear-gradient(to bottom,rgba(255,255,255,0) 0%,rgba(255,255,255,.72) 42%,#fff 92%)}

.hpp .hpp-showmore{margin-top:18px;display:flex;justify-content:center}
.hpp .hpp-showmore button{display:inline-flex;align-items:center;gap:9px;
  font-family:var(--hsans);font-size:14px;font-weight:600;color:var(--acct-text-2);
  background:var(--bg);border:1px solid var(--acct-border);border-radius:var(--acct-radius);padding:11px 22px;cursor:pointer;
  transition:border-color var(--acct-dur) var(--acct-ease),color var(--acct-dur) var(--acct-ease),
             background var(--acct-dur) var(--acct-ease)}
.hpp .hpp-showmore button:hover{border-color:var(--acct-blue);color:var(--acct-blue);background:var(--acct-surface-2)}
.hpp .hpp-showmore svg{width:11px;height:11px;display:block;flex:0 0 auto}
/* AMBIENT BOB on the chevron. This one earns its keep as an affordance, not
   just as decoration: the clipped grid above ends in a fade, and a mark that
   keeps drifting downward says "the content continues that way" more directly
   than a static arrow does.
   Same rest-heavy shape as the sheen. The movement occupies the first 20% of a
   3.4 second cycle and the remaining 80% is still, and it is only 3px, so it
   registers peripherally without pulling the eye off the product names. */
.hpp.is-live .hpp-showmore svg{animation:hpp-bob 3.4s var(--acct-ease) infinite}
@keyframes hpp-bob{
  0%,20%,100%{transform:translateY(0)}
  10%{transform:translateY(3px)}
}
/* Hover takes over from the ambient loop with a single, larger, deliberate
   nudge, so pointing at the button gives a different answer from just looking
   at it. */
.hpp .hpp-showmore button:hover svg{animation:none;transform:translateY(3px);
  transition:transform var(--acct-dur) var(--acct-ease)}

/* The live count re-renders on every filter change and every Show more. Without
   this it silently swaps text, which is the one moment the number actually
   matters and the easiest place to miss that anything happened. A 260ms fade
   with a 3px rise is enough to say "this value is new" without being a banner.
   aria-live already announces it for screen readers; this is the visual half of
   the same job. */
.hpp .hpp-count.is-upd{animation:hpp-count-in .26s var(--acct-ease) both}
@keyframes hpp-count-in{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}

/* Cards fade in when a filter changes. Additive only: the grid is already
   correct before this runs. */
@keyframes ehp-in{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
/* Filter change: the whole grid is new content, so the whole grid animates. */
/* THE DELAY COMES FROM --i, set by JS on each VISIBLE card, not from
   nth-child. nth-child counts every card in the grid including the 30-odd
   hidden ones, so on a filtered view the fourth thing you can see might be the
   nineteenth child: the delays landed in an order that had nothing to do with
   what was on screen. --i is the position in the visible run, so the sweep
   always reads left to right.
   45ms per card, so a five-card row completes in about 180ms after the last
   one starts. Fast enough that it reads as one movement rather than five. */
.hpp .hpp-anim .hpp-card{animation:ehp-in .3s var(--acct-ease) both;
  animation-delay:calc(var(--i,0) * 45ms)}
/* Show more: only the newly revealed cards animate. Re-animating the rows that
   were already on screen would say "everything changed" when almost nothing
   did, and re-running 20 animations to add 5 items is the "over-animation"
   failure the current guidance warns about. 200ms, at the fast end, because
   this repeats up to eight times on All. */
.hpp .hpp-card.is-new{animation:ehp-in .2s var(--acct-ease) both}

@media(prefers-reduced-motion:reduce){
  .hpp *{transition:none!important}
  .hpp .hpp-anim .hpp-card,
  .hpp .hpp-card.is-new{animation:none!important}
  .hpp .hpp-chip:active{transform:none}
  /* The tag stops moving but does NOT disappear: its resting state is already
     the finished state, so killing the animation leaves it fully visible.
     Motion is never the carrier of a signal here, or of visibility. */
  .hpp.is-seen .hpp-new{animation:none!important}
  /* Both ambient loops off. These are the two that would otherwise run
     indefinitely, which is exactly the class of motion that causes symptoms for
     people with vestibular disorders. Neither carries information the static
     state does not: the tag still says New, the chevron still points down. */
  .hpp.is-live .hpp-new::after,
  .hpp.is-live .hpp-showmore svg{animation:none!important}
  .hpp .hpp-card:hover .hpp-new::after{animation:none!important}
  .hpp .hpp-showmore button:hover svg{transform:none}
  .hpp .hpp-count.is-upd{animation:none!important}
  .hpp .hpp-sel-list{animation:none!important}
  /* Arrow-button and keyboard scrolling jump instead of gliding. The row still
     scrolls; it just does not animate getting there. */
  .hpp .hpp-chips{scroll-behavior:auto}
  /* The card itself no longer moves on hover, so only the image zoom needs
     switching off here. */
  .hpp .hpp-card:hover .hpp-card-img img{transform:none}
}
