/* GENERATED FILE. Do not edit here.
   Source: _kim/mockups/src/epp-sheet.css
   Regenerate: python _kim/mockups/port-to-plugin.py
   Editing this copy directly will be overwritten by the next port and
   will silently desync the mockup from production. */
/* ENTTEC product pages, epp-* component layer for Kadence-free product bodies.
   Loaded ONLY on products registered in enttec_product_pages_html_mode_slugs(),
   so Kadence product pages are untouched.
   All font sizes are px, never rem: the theme sets html{font-size:10px}.

   TASK 3 LIFTS THIS FILE VERBATIM into assets/product-pages.css.

   ---------------------------------------------------------------------------
   DESIGN DIRECTION (Kim, 2026-07-30). READ THIS BEFORE CHANGING ANY NUMBER.
   ---------------------------------------------------------------------------
   The first four rounds of this page were built by MEASURING the live Pixelator
   Mini Mk2 page and matching its numbers: band colours, column widths, type
   sizes, table row counts. That produced something structurally faithful and
   visually dead, because Pixelator Mini Mk2 is a Kadence page and cloning it
   inherited a mediocre layout without Kadence's excuse for it.

   The reference is now the PROJECT PAGES design language
   (wp-content/mu-plugins/enttec-project-pages/assets/project-pages.css), which
   is the current, Kadence-free, editorial system this site is moving to. This
   sheet is deliberately its sibling: same palette, same type scale, same
   sub-nav, same editorial product rows, same closing CTA. Where the two need
   the same thing they say it the same way, so a reader moving between a project
   page and a product page reads one site rather than two.

   DESIGN CONTRACT (do not drift):
    - Palette is STRICT SCM-SOP-144 brand only. No blue tints/opacities, ever.
        blue #0ea5df . black #000 . white #fff . dark grey #333
        mid grey #4c4c4c . light grey #d9d9d9
    - Type mirrors the project pages:
        h1 Montserrat 67/500 . h2 Montserrat 38/400 . h3 Montserrat 24/500
        body Source Sans 21/400 . body colour #4c4c4c
      h2 at 38px is Kim's 2026-07-30 decision, unchanged. What DID change is the
      weight: 400, not 600. That single value is most of the difference between
      "product datasheet" and "editorial page", and it is why the headings now
      read as the project pages' do.
    - Band rhythm alternates and never repeats: dark, white, grey, white, dark,
      white, grey, dark. Five stacked dark bands was the single biggest thing
      wrong with the previous draft.
    - NO stat cards, NO accordion-as-content, NO "Features" heading. All three
      were cut by Kim on 2026-07-30. The accordion survives only where it is a
      genuine disclosure control (it currently survives nowhere on this page).
   --------------------------------------------------------------------------- */

/* ======================= VARIABLES (the only literals) =======================
   Brand palette pinned locally, exactly as project-pages.css does it, so
   nothing on the page can reach for an off-brand design-system tint. */
.epp {
  --epp-blue:   #0ea5df;
  --epp-black:  #000000;
  --epp-dark:   #333333;   /* brand "dark grey" - the dark bands */
  --epp-mid:    #4c4c4c;   /* brand "mid grey"  - body text on white */
  --epp-light:  #d9d9d9;   /* brand "light grey" - hairlines on light bands */
  --epp-grey:   #f2f2f2;   /* the light grey band */

  /* Content measure, copied from the theme's --container-max-width rather than
     invented, so every section lines up with the live product page beside it.
     Ours was a flat 1500px cap, which at 1440 gave a 1425px band against the live
     product pages' 1345px: every section ran 80px wider than its neighbour. Kim
     spotted it against OCTO MK3.

     ALL THREE RUNGS, not just the middle one. The theme steps the gutter down as
     the screen narrows (main-style.scss :root, breakpoints 1600 and 575). We had
     pinned only the 80px rung, which is correct at 1440 and wrong at both ends:
     above 1600 our sections ran 80px wider than the theme's, and at a 390px phone
     the desktop gutter left a 295px section in a 375px viewport, a 263px reading
     measure once the padding below is counted. Mirror the ladder instead, and the
     band matches at every width rather than at one.

     Keep these in step with main-style.scss. They are a copy, not a reference,
     because that value is a Sass-compiled theme variable and this sheet is loaded
     independently of it. */
  --eds-container: calc(100% - 160px);

  /* No second gutter. The design system adds --eds-container-pad INSIDE the
     max-width, which is right for a page that sets --eds-container to a fixed
     measure (the DS default is 1760px, a cap with no gutter of its own). Ours is
     already expressed as a gutter, so the padding was charging for it twice: at
     1440 that is a 1265px band against the theme's 1345px, and at 390px it is the
     difference between 335px and 263px of reading width. The gutter lives in
     --eds-container alone. */
  --eds-container-pad: 0px;

  /* Split sections: heading alone in a narrow left column, content in a wide
     right column. 24/76 is the live product page's measured 309/966, used by
     specifications and resources. */
  --epp-split-head: 24fr;
  --epp-split-body: 76fr;

  /* Related-products carousel: how many product cards are visible at once, and
     the gap between them. The slide width is computed from these two, so a
     future page with a different card count changes one number here rather than
     a hand-worked percentage in three media queries. */
  --epp-carousel-visible: 3;
  --epp-carousel-gap: var(--eds-gap-card);

  /* On-dark ink. Values match eds-section--tone-dark so the two layers cannot
     disagree about what "secondary text on charcoal" means. */
  --epp-dark-ink:  #ffffff;
  --epp-dark-body: rgba(255,255,255,0.72);
  --epp-dark-soft: rgba(255,255,255,0.55);
  --epp-dark-rule: rgba(255,255,255,0.16);
  --epp-dark-panel:rgba(255,255,255,0.05);
}

/* The other two rungs of the theme's gutter ladder (see --eds-container above).
   Written as max-width queries in the theme's own order so a diff against
   main-style.scss reads straight across. */
@media screen and (max-width: 1600px) { .epp { --eds-container: calc(100% - 80px); } }
@media screen and (max-width: 575px)  { .epp { --eds-container: calc(100% - 40px); } }

/* ============================== BANDS ==============================
   Each band sets its ink and rule colours ONCE. Components below read only
   those variables, never a band-specific colour, which is why the same figure,
   table, capability tile and gallery markup works on charcoal, on white and on
   grey without a single "if we are on the light band" override. */
.epp-section--dark {
  background: var(--epp-dark);
  --epp-ink:      var(--epp-dark-ink);
  --epp-body-ink: var(--epp-dark-body);
  --epp-soft-ink: var(--epp-dark-soft);
  --epp-rule:     var(--epp-dark-rule);
  --epp-panel:    var(--epp-dark-panel);
}
.epp-section--light {
  background: #ffffff;
  --epp-ink:      var(--epp-black);
  --epp-body-ink: var(--epp-mid);
  --epp-soft-ink: var(--epp-mid);
  --epp-rule:     var(--epp-light);
  --epp-panel:    var(--epp-grey);
}
/* #d9d9d9, the brand "light grey", measured off the related-products band at the
   foot of every prod product page. NOT --epp-grey (#f2f2f2): that is the paler
   page-grey band, and Kim asked for the grey the products section actually uses on
   prod. --epp-rule has to move with it, because it normally IS #d9d9d9 and a
   hairline the same colour as its background is no hairline at all. */
.epp-section--grey {
  background: var(--epp-light);
  --epp-ink:      var(--epp-black);
  --epp-body-ink: var(--epp-mid);
  --epp-soft-ink: var(--epp-mid);
  --epp-rule:     #c2c2c2;
  --epp-panel:    #ffffff;
}

/* ======================= THE THEME'S BANNER AND SUMMARY =======================
   The banner image, h1, breadcrumb, short description, SKU and "Contact an
   Expert" button are printed by the THEME, above and outside our article, so
   they are the one part of this page we restyle from the outside rather than
   author. They are scoped to .product-details-top, which exists only on a
   single-product template, and this sheet only loads on html-mode products.

   Left alone, the theme sets the h1 at its own size and weight and the page
   opens on a different type system to the one the body uses two hundred pixels
   further down. Matching the project-page h1 here is what makes the banner and
   the article read as one page. */
.product-details-top h1 {
  font-family: var(--enttec-font-display); font-weight: 500;
  font-size: clamp(40px, 6vw, 67px); line-height: 1.1; letter-spacing: normal;
  text-transform: none; margin: 0 0 18px;
}
.product-details-top .woocommerce-product-details__short-description p {
  font-family: var(--enttec-font-body); font-size: clamp(17px, 1.5vw, 21px);
  /* 60ch, not 46ch. This paragraph is the widest thing in .product-info-area and
     a flex item that shrinks to its content, so its max-width is what sets the
     whole summary column's width. At 46ch (412px) the ELM HUB opener wrapped to
     five lines; 58ch is where it drops to four, and 60ch (537px) keeps a line's
     worth of slack for copy edits and font fallbacks. The theme caps the column
     at 570px, so this stays inside the theme's own limit and nothing overflows
     the band. Measured at 1440; narrower viewports are bound by the column, not
     by this value. */
  font-weight: 400; line-height: 1.5; max-width: 60ch; margin: 0 0 26px;
}
/* "CONTACT AN EXPERT" TOP MARGIN IS DELIBERATELY NOT SET HERE (Kim, 2026-08-21).
   There used to be an override on
   `body.ecp-enabled .product-details-top .contact_us_btn.contact_an_expert`
   pinning this button's margin-top. It is gone: the button now takes whatever
   contact-popup.css gives it, which is the single place that styles this button.

   HISTORY, so nobody re-adds it by reflex. The override was born to cancel a
   sitewide `.contact_us_btn.contact_an_expert { margin-top: 20px }` in
   main-style.css, which stacked with the short description's own 26px bottom
   margin and read as "detached" (46px, rejected 2026-08-11). It was then found not
   to be applying at all, because contact-popup.css's own
   `body.ecp-enabled .contact_us_btn.contact_an_expert` is (0,3,1) and beat the
   (0,3,0) selector; matching the element took it to (0,4,1) and the gap to ~1px
   (2026-08-12). It briefly carried 14px on 2026-08-21 before being removed as
   unnecessary.

   The paragraph's own bottom margin is still zeroed by the rule below, so the
   remaining separation is the button's margin from contact-popup.css plus its 12px
   top padding.

   MEASURING NOTE: .product-info-area is `display: flex; flex-direction: column`
   with explicit `order` on its children, so DOM order is NOT visual order and
   previousElementSibling is the wrong tool. Read the computed `order` and compare
   rendered `top` values instead. */

/* ...and drop the paragraph's own bottom margin, which is the entire remaining gap.
   Zeroing the button's margin-top above took the space from 36px to 27px, and the 27
   left is the short description paragraph's 26px, inherited from the theme. Kim
   2026-08-12: "i think you just needed to remove the margin-bottom on
   .product-details-top .woocommerce-product-details__short-description p". Measured
   after: 1px, so the only separation left is the button's own 12px top padding, which
   is what stops it reading as attached to the sentence.

   `p` rather than `p:last-child` on purpose. ELM HUB's description is a single
   paragraph, and this sheet loads for html-mode slugs only, which today is ELM HUB
   alone. If a description here ever gains a SECOND paragraph, this will jam the two
   together and it should become p:last-child then, not now.

   NOT SCOPED TO body.ecp-enabled, unlike the button rule above, and that difference
   is deliberate (Kim 2026-08-14). The button rule needs that class only to outrank
   the composer's own margin-top; nothing but the theme sets this paragraph's bottom
   margin, so (0,2,1) already wins on its own. Scoping it meant the gap came back
   whenever the composer was inactive, which is exactly how it was found: the
   composer stopped registering, ecp-enabled fell off the body, and 26px reappeared
   under the short description on a page nobody had changed. A spacing rule should
   not depend on whether an unrelated dialog is present. */
.product-details-top .woocommerce-product-details__short-description p { margin-bottom: 0; }

/* The banner render is composed on brand dark grey (see build-elm-hub-banner.py),
   so the band it sits in must be that same grey or a seam shows at whatever
   height the theme's object-fit:cover crops to. */
.product-details-top .banner-image { background: var(--epp-dark); }

/* BANNER HEIGHT. The theme gives the banner 100vh, and the summary block
   (breadcrumb, h1, short description, button) ends at about 70% of it, so on a
   900px window there are 264px of empty dark band before the sub-nav, and on a
   taller screen there is more. Capping at 700px removes the dead space without
   crowding the summary.

   TWO THINGS MAKE THIS FIDDLY, both worth knowing before touching it:

   1. The height lives on .product-details-top, NOT on the image. The image is
      position:absolute with height:100%, and .banner-image itself computes to
      0px, so setting a height on either of those two does nothing useful.

   2. The theme's own rule is
          body.single-product.newLookEnabled .product-details-top img
      at (0,3,1), which OUTRANKS a plain .product-details-top .banner-image img
      at (0,2,1). It also sets object-position: center top. Anchoring the top
      means a shorter box crops off the BOTTOM of the source, which would cut the
      base of the unit off (it occupies y 197-740 of the 900px composition).
      Centred, the crop takes an equal slice from each end and the unit is
      untouched.

   3. The cap is MAX-HEIGHT, not height, and that is the point rather than a
      workaround. The theme owns `height: 100vh` at (0,3,1) and there is no
      beating that from (0,2,1) without either chaining .newLookEnabled (which
      rots the moment the theme renames it) or repeating a class purely for
      specificity. max-height is a property the theme never sets, so it applies
      uncontested, AND it says what we actually mean: keep the theme's responsive
      100vh behaviour, just stop it getting taller than it needs to be. The
      theme's inline min-height (about 542px, set from its own script) is left
      alone and does not conflict, being well under the cap.

   Both rules are scoped with body.single-product, a WooCommerce CORE body class
   rather than a theme invention, so the scope does not rot either.

   Desktop only: below 1025px the summary block reflows much taller and needs all
   the height the theme gives it. */
@media (min-width: 1025px) {
  body.single-product .product-details-top { max-height: 700px; }
  body.single-product .product-details-top .banner-image img { object-position: 50% 50%; }
}

/* THE BANNER GOES UNDER THE PRODUCT TEXT ON A PHONE (Kim, 2026-09-03).

   WHERE THIS APPLIES, AND WHY 575px IS NOT AN INVENTED NUMBER. Above it the
   banner image is not above anything: .banner-image computes to 0px high and the
   render is an absolutely positioned backdrop with the summary sitting over it,
   so there is no order to change. At 575px and below the theme drops the image
   into the flow as a real 300px block above the summary, and that is the only
   state where the question exists. Measured across 1440 to 390: the flip happens
   between 576 and 575, which is the theme's own Bootstrap breakpoint and the same
   one this sheet already uses further down.

   WHY IT IS WORTH DOING. On a phone the first screen was 300px of product photo
   before the visitor reached the product's name, and the photo is the one thing
   on that screen that answers no question: they arrived from a link that already
   said what this is. Title, SKU, what it is and Contact an Expert now open the
   page, and the render follows as support rather than as a toll gate. The 3RU
   chassis is also the least legible thing at 390px wide, so it loses nothing by
   moving below the sentence that explains it.

   ORDER, NOT DOM: .product-details-top is the theme's markup and it is already
   display:flex/column, so this is two declarations rather than a template
   override. Explicit values on BOTH children, because the theme leaves them at
   order:0 and relying on one being unset is how this silently reverts. */
@media screen and (max-width: 575px) {
  body.single-product .product-details-top .summary { order: 1; }
  body.single-product .product-details-top .banner-image { order: 2; }

  /* CLEAR THE HEADER, which the reorder makes necessary and which is the one
     thing the old order was quietly paying for. The theme's header is a
     transparent overlay pinned at top 0, not part of the flow, so whatever is
     first in this band runs underneath it. That was fine while the banner render
     was first (an image bleeding under a transparent header is the intended
     look, and the theme relies on it). Now the copy is first, and the breadcrumb
     landed at y20 inside a header running to y70: "Home / Software / ELM HUB"
     was drawn straight through the logo. Confirmed with elementFromPoint, which
     returned the header's own brand element over the breadcrumb's midpoint.

     86px total, which is 70 + 16: the header measures 70px at every width from
     390 up, and 16 is the smallest gap that does not read as a collision. Those
     are the S-PLAY banner's numbers (see .epp-banner > .eds-container further
     down, fixed the same day for the same reason on the same header), reused
     deliberately so the two pages clear it by the same amount.

     66px of MARGIN rather than 86 of padding, for the same reason as the
     margin-bottom below: the theme owns `padding: 20px` here as a shorthand at
     (0,5,1) and beats any padding of ours. 66 + the theme's own 20 is 86. */
  body.single-product .product-details-top .product-info-area { margin-top: 66px; }

  /* CLOSE THE DEAD BAND ABOVE THE BANNER (Kim, 2026-09-03: "there's too much gap
     between the banner and the product description section on mobile view").

     Measured: 142px between the button and the first pixel of the product, of
     which only 30px was spacing. The rest was two things, and neither is a
     spacing problem:

       1. THE THEME PADS THE IMAGE BY 70px at this width
          (body.single-product.newLookEnabled .product-details-top img, screen and
          max-width 575px: position:relative; min-height:300px; padding-top:70px).
          That padding is a HEADER RESERVE. It exists because the banner used to
          be the first thing in the band and ran under the transparent fixed
          header. It is now the last thing, and the copy above it carries its own
          86px clearance, so the reserve is 70px of empty band and nothing else.
       2. THE RENDER WAS UPSCALED TO FILL A TALLER BOX than its own shape. The
          asset is 1903x1040 (aspect 1.83), so at 390 wide its natural height is
          213px; object-fit: cover was blowing it up to 300 and cropping the
          sides, which magnified the composition's own empty top along with it.

     THE ASSET WAS MEASURED, not guessed: scanning it row by row, content starts
     at 11.4% and the bright core of the unit at 23.0%, and both run to 99.9%.
     So the empty band is the TOP of the composition and the unit's base sits on
     the bottom edge.

     That is what makes object-position: 50% 100% the right call rather than a
     nudge: cover with a box shorter than the scaled image crops the end AWAY from
     the anchor, so bottom-anchoring takes the crop off the top, which is exactly
     and only the empty part. The theme's `object-position: top` does the
     opposite and would cut the unit's base. 180px against a 213px scaled height
     spends 33px of the 49px empty top and leaves the unit whole.

     SPECIFICITY, since the theme's rule is (0,3,1) and this has to win: adding
     .banner-image makes it (0,3,2), three classes each and one more element, so
     it beats the theme outright without chaining .newLookEnabled, which the
     banner-height note above warns rots the moment the theme renames it. */
  body.single-product .product-details-top .banner-image img {
    padding-top: 0; min-height: 180px; object-position: 50% 100%; }
}



/* ============================== BASE ==============================
   A COMPLETE, SELF-SUFFICIENT baseline for every element this body uses.

   Why it is spelled out in full instead of as a short list of overrides: ELM HUB
   is the first product body without Kadence, and the intent is to remove Kadence
   and the legacy theme CSS entirely. So nothing here may be written as a delta
   from what those sheets happen to do today. Every declaration states what we
   want in absolute terms, and the test applied to each was:

       if Kadence and the theme CSS vanished tomorrow, would this line still be
       correct, and would it still be needed?

   No !important is used to win against the theme or Kadence, anywhere. The only
   !important declarations in this sheet are in the reduced-motion block at the
   very bottom, overriding OUR OWN transitions, which is the one case where an
   accessibility preference has to beat every component rule unconditionally.

   ---------------------------------------------------------------------------
   WHY EVERY ELEMENT SELECTOR BELOW IS WRAPPED IN :where(.epp)

   This is the load-bearing detail of the whole block, and it is easy to get
   backwards. `:where()` matches normally but contributes ZERO to specificity, so
   `:where(.epp) li` scores as a bare `li` (0,0,1) while still only ever matching
   inside our article.

   Without it, `.epp li` would score (0,1,1), HIGHER than a single-class
   component rule like `.epp-cap` (0,1,0). The baseline would then beat our own
   components no matter what order they appear in. The first draft did exactly
   that, and silently stripped the padding off every tile.

   So the cascade we want is, weakest to strongest:

       legacy bare-element rules   (0,0,1) earlier in the document
       this baseline               (0,0,1) but LAST in <head>, so it wins the tie
       our epp-* components        (0,1,0) and above, so they win outright

   Never @layer here: unlayered styles beat layered ones outright, so putting
   this in a layer would hand every property straight back to the theme.
   --------------------------------------------------------------------------- */

.epp, .epp *, .epp *::before, .epp *::after { box-sizing: border-box; }

.epp { font-family: var(--enttec-font-body); color: var(--epp-mid);
  /* 1.5vw, not 1.4vw. Measured against the live OCTO MK3 page: its body copy is a
     flat 21px with a 31.5px line box, and at 1440 the old 1.4vw resolved to
     20.16px, so this page ran a whole point small next to the thing it is meant to
     match. 1.5vw hits the 21px ceiling at 1400 and above and still scales down on
     smaller screens. */
  font-size: clamp(17px, 1.5vw, 21px); line-height: 1.5; font-weight: 400;
  font-style: normal; text-align: start; -webkit-font-smoothing: antialiased;
  width: 100%; margin-inline: 0; }

/* Bands run edge to edge. The theme insets `main section`; neutralise that
   horizontal inset and let .epp fill its wrapper rather than using a 100vw
   breakout (which mixes vw, counting the scrollbar, with 50%, which does not,
   and shifts the whole page a scrollbar-width left). */
.epp .epp-section { width: 100%; max-width: none; margin-inline: 0; padding-inline: 0; }

/* Headings carry no bottom margin except h2's, which the project pages set to
   30px. Every other gap on this page comes from a grid gap or an explicit
   spacing rule, so heading margins would double up and margin collapse would
   make the result depend on sibling order. */
:where(.epp) h2, :where(.epp) h3, :where(.epp) h4 {
  font-family: var(--enttec-font-display); color: inherit;
  margin: 0; padding: 0; text-transform: none; }
:where(.epp) h2 { font-weight: 400; line-height: 1.2; letter-spacing: 0; margin: 0 0 30px; }
:where(.epp) h3 { font-weight: 500; line-height: 1.25; letter-spacing: 0; }
:where(.epp) h4 { font-weight: 600; line-height: 1.3; letter-spacing: 0; font-size: 18px; }

/* Heading SIZES are split out at (0,1,1), unlike the appearance rules above.
   A mobile media query in the minified theme bundle (cross-origin, so its
   selector cannot even be read from the page) sets h2 to 1.5em and h3 to 1.3em,
   and it outranks a bare element selector. Left in the baseline, our 38px
   headings silently collapsed to 24.75px below the theme's breakpoint while
   looking perfectly correct at 1440. That is why the win-everywhere check has
   to run at every breakpoint, not just at desktop. */
.epp h2 { font-size: clamp(28px, 4vw, 38px); }
.epp h3 { font-size: clamp(20px, 2vw, 24px); }

:where(.epp) p { margin: 0 0 1.2em; padding: 0;
  font-size: inherit; line-height: inherit; color: inherit; }
:where(.epp) p:last-child { margin-bottom: 0; }

/* Semibold, not bold. 600 against a 400 body is a clear step up without the
   shouty look 700 gives on Source Sans, and it matches the weight the design
   system uses for card titles and buttons. Used to pick out the load-bearing
   phrase in a paragraph (Kim 2026-07-31). Inherits colour deliberately: a
   highlight that also changed colour would compete with the links. */
:where(.epp) strong, :where(.epp) b { font-weight: 600; color: inherit; }

:where(.epp) ul, :where(.epp) ol { margin: 0; padding: 0; list-style: none; }
:where(.epp) li { margin: 0; padding: 0; }

:where(.epp) a { color: var(--epp-blue); text-decoration: none;
  background-color: transparent;
  transition: color .15s var(--eds-ease), text-decoration-color .15s var(--eds-ease); }
/* Underline ONLY inside body copy, never on cards, buttons or nav (those are
   links too and must stay clean), and only on hover. Mirrors the project pages. */
/* Underlined ALWAYS, and hover changes the colour rather than the decoration.
   Kim, 2026-08-04: "the hover ui on a tags here are weird and not right", and there
   were two separate causes. Outside the overview the underline was absent until
   hover, so it POPPED IN under the cursor: 1.5px appearing from nothing at a 3px
   offset reads as a glitch, not as feedback. Inside the overview it was permanent
   but hover only shifted its colour by a shade, which most people cannot see.

   Now hover lifts the text AND its underline to the band's ink colour. --epp-ink is
   white on the dark bands and near-black on the light ones, so this is legible on
   every band with no band-specific override, which is the rule the palette section
   sets out. Colour only: animating text-decoration-thickness janks and is patchily
   supported. */
/* ONE animated property: color. The underline is drawn in currentColor, so it
   follows the text automatically and the whole link crossfades as a single smooth
   change. The previous version transitioned color AND text-decoration-color
   separately, which is where "this isn't smooth" came from: two properties, two
   interpolations, and text-decoration-color animates unevenly (Safari in
   particular treats it as discrete), so the text and its underline arrived at
   slightly different times. Kim, 2026-08-04.

   Slower and softer too: 240ms on an ease-out rather than a flat 150ms, which is
   what makes a colour change read as deliberate instead of as a flicker. */
:where(.epp) p a { text-decoration: underline; text-underline-offset: 3px;
  text-decoration-thickness: 1.5px; text-decoration-color: currentColor;
  transition: color .24s cubic-bezier(.2, .6, .2, 1); }
:where(.epp) p a:hover { color: var(--epp-ink); }
@media (prefers-reduced-motion: reduce) {
  :where(.epp) p a { transition: none; }
}

:where(.epp) img { display: block; max-width: 100%; height: auto; border: 0; }
:where(.epp) figure, :where(.epp) figcaption { margin: 0; padding: 0; }

:where(.epp) button { font-family: var(--enttec-font-body); font-size: inherit;
  font-weight: inherit; line-height: inherit; color: inherit;
  letter-spacing: inherit; text-transform: none; text-align: center;
  margin: 0; background: none; border: 0; border-radius: 0; cursor: pointer; }
/* appearance is split out at (0,1,0) because a button rule in the minified theme
   bundle outranks a bare element selector and was painting platform button
   chrome over our gallery thumbnails. Raising specificity is the fix rather than
   an !important, and it stays correct once that bundle is gone. */
.epp button { -webkit-appearance: none; appearance: none; }

:where(.epp) table { width: 100%; margin: 0; border-collapse: collapse;
  border-spacing: 0; font-size: inherit; color: inherit; }
:where(.epp) th, :where(.epp) td { text-align: left; vertical-align: top; background: none; }

/* Anchor jumps clearing the sticky sub-nav is NOT handled here any more. Each
   section carries data-eds-subnav-target instead, and the design system owns the
   scroll-margin-top calc (plus the mobile height). Setting it on .epp-section as
   well would win on source order and silently take the value back. */
.epp-section { color: var(--epp-ink); }
.epp-section--dark h2, .epp-section--dark p { color: var(--epp-ink); }

/* Buttons keep their design-system label colour inside .epp: the broad
   `:where(.epp) a` colour rule above must not repaint button text. */
.epp a.eds-btn--primary, .epp a.eds-btn--ghost { color: #fff; }
.epp a.eds-btn--outline { color: var(--epp-black); }
.epp .epp-section--dark a.eds-btn--outline { color: #fff; }
/* An eds-btn that is an ANCHOR loses the design system's transform/background
   transition to the `a { transition: color }` rule above, because that rule
   REPLACES the declaration rather than merging with it. Restore the full
   transition on the anchor form or every link-button on the page snaps instead
   of lifting. Same trap project-pages.css documents at its line 351. */
.epp a.eds-btn { transition: transform var(--eds-dur) var(--eds-ease),
                             background var(--eds-dur) var(--eds-ease),
                             border-color var(--eds-dur) var(--eds-ease),
                             color var(--eds-dur) var(--eds-ease); }

/* Eyebrow: solid brand blue on every band. The design system tints it #5ec9ef
   on dark, which is an off-brand blue variant we are not allowed to use. */
.epp .eds-eyebrow { color: var(--epp-blue); font-size: 13px; font-weight: 600;
  letter-spacing: .12em; text-transform: uppercase; line-height: 1.3;
  margin: 0 0 var(--eds-space-2xs); }
.epp .epp-section--dark .eds-eyebrow { color: var(--epp-blue); }
.epp .eds-section-head p { color: var(--epp-body-ink); margin: .5em 0 0; max-width: 68ch; }

/* ============================== SUB-NAV ==============================
   MOVED INTO THE DESIGN SYSTEM, 2026-08-04. The bar is now
   `.eds-subnav .eds-subnav--bar` with `data-eds-subnav`, and every line that
   used to live here (the sticky offset that follows the navbar, the centred row
   that scrolls inside itself, the blue underline growing from the centre) is in
   enttec-design-system.css under the same heading, with the reasoning intact.

   Nothing is overridden here, deliberately. When this page's bar and the project
   pages' bar were compared they turned out identical down to the rgba, so there
   was no page-specific skin to keep: --epp-dark-body is rgba(255,255,255,.72),
   --epp-blue is #0ea5df, and the design system's --enttec-blue is the same
   #0ea5df (the off-brand tint the notes warn about is #5ec9ef, and that is the
   eyebrow, not this bar).

   One thing worth keeping in mind if this page ever DOES need its own skin: the
   theme's own [navigation-shortcode] is still not an option. It parses
   post_content as blocks and knows only nine hardcoded anchor names, so it
   renders empty on a hand-authored body. */

/* ============================== SPLIT SECTIONS ==============================
   Heading alone in a narrow left column, content in a wide right column, at the
   live product page's measured 309/966. Used by specifications and resources.
   RESTORED 2026-07-30: an intermediate draft moved these to the project pages'
   0.85/1.15, which is right for a project page (where the left column carries a
   sentence-long statement) and wrong here (where it carries one word). */
.epp-split { display: grid;
  grid-template-columns: minmax(0, var(--epp-split-head)) minmax(0, var(--epp-split-body));
  gap: var(--eds-space-lg); align-items: start; }
/* minmax(0, 1fr), NOT 1fr. "1fr" is shorthand for minmax(auto, 1fr), and that
   auto MINIMUM refuses to shrink below the column's min-content, so a single
   unshrinkable child ends up setting the width of the entire section. The
   desktop rule directly above already guards against this with minmax(0, ...);
   this mobile override silently dropped the guard.

   Measured on the test box 2026-08-24 at 390px: the Specifications track came
   out 708px inside a 257px container, which clipped the spec table mid-row and
   drew a scrollbar straight through the middle of it. The spec-index chip row
   was the child doing it, proved by setting display:none on that nav and
   watching the track fall to 260px. Note .epp-specidx already carries a
   min-width:0 with a comment calling it load-bearing, and that comment is
   right about the mechanism but the declaration is on the wrong element: the
   grid ITEM is .epp-split__head, so the nav's max-content still reached the
   track through it. Resources uses the same split and overflowed too, by 26px
   at 320px, so this one line was breaking two sections. */
@media (max-width: 900px) { .epp-split { grid-template-columns: minmax(0, 1fr); gap: var(--eds-space-md); } }
.epp-split__body { display: flex; flex-direction: column; gap: var(--eds-space-lg); }

/* ============================== OVERVIEW ==============================
   Two equal columns: marketing statement plus lead on the left, the rest of the
   prose on the right. This is the live product page's own overview layout, and
   it is also exactly what Kim described on 2026-07-30: "there should be a
   marketing subtitle not the word Overview, then on the right column, that's
   where the rest of the text should be."

   --eds-space-md, not -lg: the real page's two columns sum to 1305 of the 1345
   available, so its gutter is 40px. -lg (72px here) would miss the measurement. */
.epp-overview { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--eds-space-md); align-items: start; }
@media (max-width: 900px) { .epp-overview { grid-template-columns: 1fr; } }
.epp-overview__head h2 { margin-bottom: 0; }
/* ONE size for the whole right column, and no size-up lead paragraph. Kim
   2026-07-31: "we need to make the right section the same font size". The
   emphasis now comes from semibold runs and links inside the prose, which is
   the better instrument anyway: a bigger first paragraph tells you where to
   start reading, while semibold tells you what actually matters, and only the
   second of those survives a reader who skims. */
/* Pure white, not the 72% --epp-body-ink this used. Prod's overview copy is
   rgb(255,255,255) on its #2f2f2f band; the softer ink is kept everywhere else on
   the page, where prod also uses a grey (its resource rows are #808080). Matching
   per band beats one global choice. */
.epp-overview__body p { color: #ffffff; }
/* Overview links: blue and CLEAN at rest, underline on hover and on keyboard
   focus. Kim 2026-08-11, replacing the permanently-underlined treatment.

   The underline is declared at rest and merely painted TRANSPARENT, rather than
   flipped from `none` to `underline` on hover. text-decoration-LINE is not an
   animatable property, so the flip would snap; text-decoration-COLOR is, so this
   fades in and matches the sheet's other hover transitions. It also means the
   text never reflows on hover, which a line added from nothing can cause.

   :focus-visible carries the same underline as :hover, so a keyboard user gets
   the cue a mouse user gets. Do not drop it to save a selector.

   ACCESSIBILITY NOTE, deliberately accepted rather than overlooked. The paragraph
   ink here is pure #fff and the brand blue is #0ea5df, which is 2.8:1 against it.
   WCAG technique G183 wants 3:1 between link and body text when colour is the
   only resting difference. We are marginally under, and the palette allows no
   lighter blue to close it. What keeps this defensible is that the non-colour cue
   still exists on hover AND focus; the resting state is the compromise. If this
   ever needs to pass strictly, the fix is a lighter blue in the brand palette,
   not a tint invented here.

   THE COLOUR IS PINNED BLUE THROUGH BOTH STATES, which is a deliberate local
   departure from the sheet's own body-link convention a few hundred lines up
   (`:where(.epp) p a:hover { color: var(--epp-ink) }`, i.e. hover goes white on a
   dark band). That convention exists because those links are underlined at rest,
   so colour is free to be the hover cue. Here the underline IS the hover cue, and
   letting the colour go white as well would mean a link that stops looking like a
   link at the exact moment it is being pointed at. One cue per state change. */
.epp-overview__body a { color: var(--epp-blue);
  text-decoration: underline; text-underline-offset: 3px;
  text-decoration-thickness: 1.5px; text-decoration-color: transparent;
  transition: text-decoration-color var(--eds-dur-fast) var(--eds-ease); }
.epp-overview__body a:hover,
.epp-overview__body a:focus-visible { color: var(--epp-blue);
  text-decoration-color: var(--epp-blue); }

/* ============================== HARDWARE ==============================
   Gallery left, accordion right, at the live product page's measured 598/677
   (47:53 of the 1273px left after the gap). minmax(0,...) on both tracks so
   neither child can force the grid wider than its container.
   RESTORED 2026-07-30 along with the accordion: an intermediate draft replaced
   this with prose-plus-gallery and a tile grid, which reads as a project page. */
/* SAME GEOMETRY AS THE OVERVIEW. Kim 2026-08-11 asked for the two sections to
   align. They were out by 23px, and the cause was two different grids: the
   Overview is 50/50 with an --eds-space-md gap, this was 47/53 with the larger
   --eds-space-lg. Matching both values means the accordion and the paragraph
   above it share a left edge by construction rather than by a magic number, so
   changing one section's gap can never drift them apart again.
   The gallery gains a little width from the change, which suits it. */
.epp-features { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--eds-space-md); align-items: start; }
@media (max-width: 980px) { .epp-features { grid-template-columns: 1fr; gap: var(--eds-space-md); } }

/* Accordion: the design system's eds-accordion, re-inked from the band
   variables so the same markup works on charcoal, white and grey.

   TYPE MEASURED OFF PROD, 2026-08-06 (Kim: the resources toggle "needs to match
   the current prod webpage"). Reference is the DMX USB PRO resources accordion on
   AE prod at 1440, which is the same component in the same place on a published
   page:

                        prod            ours before
     title              24px / 400      18px / 600
     header height      65px            63px
     vertical padding   20px            18px

   So the weight was the loudest error, not the size: 600 on a 24px Montserrat
   title reads as a heading competing with the section's own h2, and prod's 400 is
   deliberately quieter. 20px top and bottom against a 24px line box is what makes
   prod's 65px header, so the height falls out of the padding rather than being set.

   The design system stays at 18px/600, which is right for its other consumers;
   this is a page-level override, not a change to the component. */
.epp-accordion details { border-color: var(--epp-rule); }
/* 28px OF AIR, NOT 20. Kim 2026-08-06: the features section "needs more spacing
   as it doesn't look premium when it's bundled up like that", and she is
   describing a real thing. Five 24px titles at 20px padding are five 65px bands
   separated by nothing but a hairline: the rows touch, so the eye reads one dense
   block rather than five separate answers, and density is the opposite of what a
   3RU rack unit at this price should feel like on the page.

   28 makes an 80px row. That is the only number changed, because the space
   BETWEEN two rows here is just the padding of the two rows either side of the
   rule, so widening the padding widens the gap and keeps the hairline where it
   belongs, centred in the space it divides. Adding margins instead would have
   detached each rule from the rows it separates.

   The Resources accordion opts back out below: Kim asked for that one to match
   prod, and prod's header is 65px. */
.epp-accordion summary { font-size: 24px; font-weight: 400; line-height: 1;
  padding-top: 28px; padding-bottom: 28px; color: var(--epp-ink); }
/* No hover colour on the label. See the hover block near the foot of this file
   for why: recolouring large light text on a dark band reads as a weight
   change. The hover signal lives on the hairline and the chevron instead. */
/* Chevron scaled to the bigger title. The design system draws a 10px box for its
   own 18px type; left there it reads as an afterthought under a 24px title, and
   prod's own trigger is a 24px glyph. padding-right stays the system's 36px rather
   than prod's 10px: prod's chevron is a flex sibling that reserves its own room,
   ours is an absolutely positioned pseudo-element, so the text needs the padding
   to keep clear of it. */
.epp-accordion summary::after { width: 12px; height: 12px;
  border-right-width: 2px; border-bottom-width: 2px;
  border-right-color: var(--epp-soft-ink);
  border-bottom-color: var(--epp-soft-ink); }
.epp-accordion summary:hover::after { border-right-color: var(--epp-blue);
  border-bottom-color: var(--epp-blue); }
/* The open pane gets the same treatment: 32px below the paragraph rather than 20,
   so the text is not sitting on the rule that closes it off. Combined with the
   summary's 28px above, an open pane now has real margins on both sides of its
   content instead of being wedged between two lines. */
.epp-accordion .eds-accordion__body { color: var(--epp-body-ink); font-size: 16.5px;
  padding-bottom: 32px; }

/* ============ FEATURES ON MOBILE: BLOCKS, NOT AN ACCORDION ============
   Below 981px .epp-features drops to one column, which stacks the gallery ABOVE
   the panes rather than beside them. That is what makes the desktop behaviour
   impossible here: a pane that re-frames the stage would be animating a picture
   the visitor scrolled past several hundred pixels ago, so the coupling is
   switched off (featuresSideBySide() in product-pages.js) and the strongest thing
   this section does simply stopped existing on a phone.

   So mobile stops being an accordion. Every pane is open, and the panes that name
   a region of the hardware carry their own crop of it, which is the same argument
   the desktop zoom makes, made in the one layout that can still make it: the
   picture and the words in a single eyeful.

   Why opening all five is not a regression. NN/g's finding is that accordions
   earn their place when the visitor wants ONE section, and that showing
   everything is better when most of the content is relevant, even at the cost of
   a longer page. An integrator specifying a permanent install reads most of these
   five. The section roughly doubles in height, and that is the trade: scrolling
   is cheap on a phone, tapping is not.

   FORCING THEM OPEN IS NOT `open`. The panes are a native radio group via their
   shared `name`, so the browser closes every one but the last no matter how many
   carry the attribute (measured: five marked open, one actually open).
   ::details-content is the pane's own content box, so overriding its
   content-visibility reveals a collapsed pane without touching either the
   attribute or the grouping. The desktop markup and the desktop exclusivity are
   both left exactly as they are.

   The @supports is load-bearing rather than defensive padding. ::details-content
   reached Baseline in September 2025 (Chrome 131, Firefox 143, Safari 18.4), and
   anything older falls through to the accordion it has today, which is a working
   section rather than a broken one.

   Deliberately NOT touched here: the thumbnail strip. It is the only visible
   affordance on touch that there are five images at all, and its wrapping is
   already a documented decision further down this file.
   ====================================================================== */
@media (max-width: 980px) {

  @supports selector(::details-content) {
    .epp-features .epp-accordion details::details-content {
      content-visibility: visible;
      block-size: auto;
    }
  }

  /* THE `.epp` PREFIX IS LOAD-BEARING, not habit. The rule that puts the hairline
     on the summary and sets its 18px padding is `.epp .epp-accordion summary`
     further down this file, which is the same specificity as
     `.epp-features .epp-accordion summary` and comes later, so it would win on
     order alone. Matching its shape here decides the cascade explicitly instead of
     leaving it to whichever rule happens to sit lower in the file.

     The summary stops being a control and becomes the block's heading: no chevron,
     no pointer, and pointer-events:none so a tap cannot collapse the block it
     labels. Left as a <summary> rather than swapped for an <h3> because the
     element is shared with desktop, where it is still the trigger. */
  .epp .epp-features .epp-accordion summary {
    font-size: 21px; font-weight: 600; line-height: 1.25;
    padding: 0 0 10px;
    cursor: default; pointer-events: none;
    /* THE HAIRLINE COMES OFF THE HEADING HERE. On a collapsing pane it sits
       between the heading and its content and does useful work, which is why it
       was moved onto the summary on 2026-08-11. On a block that never collapses
       it separates a heading from its own picture, which reads as the picture
       belonging to whatever comes next. */
    border-bottom: 0;
  }
  .epp .epp-features .epp-accordion summary::after { display: none; }

  /* One rule BETWEEN blocks rather than under each. With nothing left to
     collapse, a border under the last block would draw a line closing off a
     section that has already ended. */
  .epp .epp-features .epp-accordion details { padding-bottom: 30px; }
  .epp .epp-features .epp-accordion details + details {
    border-top: 1px solid var(--epp-rule); padding-top: 30px;
  }

  /* The 14px of air under the summary's rule has no rule to clear any more, and
     the summary's own 10px is the gap between the heading and its picture. */
  .epp .epp-features .epp-accordion .eds-accordion__body {
    padding-top: 0; padding-bottom: 0;
  }
}

/* THE PER-PANE FIGURE. Hidden on desktop, where the stage already does this job
   by pushing in on the same region.

   A background rather than an <img> because the crop is a ZOOM, not a fit:
   background-size and background-position say "260% of the box, held on the point
   33.5% across and 44% down" in two values, where an <img> would need a wrapper
   and a transform to say the same thing. The numbers are the same framing as the
   pane's own data-epp-gallery-focus, and they sit on the same element so the two
   cannot drift apart unnoticed. The file is one the gallery above has already
   fetched, so the block costs no extra request. */
.epp-pane-fig { display: none; }

@media (max-width: 980px) {
  .epp-pane-fig {
    display: block; width: 100%; aspect-ratio: 16 / 9;
    margin: 0 0 16px;
    background-color: var(--epp-panel);
    background-repeat: no-repeat;
    background-size: var(--epp-fig-size, contain);
    background-position: var(--epp-fig-pos, center);
    border-radius: 4px;
  }
}

/* ---- STATUS SCREENS INSIDE A PANE (ELM HUB front monitor) ----------------
   Two screenshots of the same screen in two states, side by side inside an
   accordion pane. Added 2026-09-14 (Kim), who asked for them in the pane and
   side by side rather than in a band of their own.

   THE SIZE IS THE TRADE, and it is worth writing down because it looks like a
   mistake otherwise. The pane is one column of the two-column .epp-features
   grid, so on a 1440 laptop the column is about 650px and each screenshot
   renders near 318px, roughly a third of its natural 992px. At that size the
   red fault block reads as a fault and the status chips read as status, but
   the message text does not. That is why each figure carries data-epp-zoom
   (the shared lightbox, 768px and up) AND why the captions state in words what
   the screen says. The image shows the shape of the thing; the caption carries
   the content. Do not enlarge these by breaking them out of the column.

   STACKS BELOW 600px. Side by side is Kim's call and it holds everywhere it
   can, but at 390px two columns put each screen at 163px, 16% of natural,
   which is a colour smear rather than a picture. One column at that width
   gives about 335px, which matches what the desktop pair gets. */
.epp-panescreens {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--eds-space-xs);
  margin-top: var(--eds-space-sm);
}
@media (max-width: 600px) { .epp-panescreens { grid-template-columns: 1fr; } }

.epp-panescreens figure { margin: 0; }
.epp-panescreens img {
  display: block; width: 100%; height: auto;
  border-radius: var(--eds-radius-sm);
  /* The plate. These are screenshots of a black UI on a charcoal panel, so
     without a visible edge the two merge into one slab and neither reads as a
     display. Deliberately brighter than --epp-rule, which disappears against
     pure black. */
  background: #000;
  border: 1px solid rgba(255, 255, 255, 0.18);
}
.epp-panescreens figcaption {
  margin-top: 6px;
  font-size: 13px; line-height: 1.4;
  color: var(--epp-soft-ink);
}

/* ================== MOBILE TYPE AND TAP-TARGET PASS ==================
   Measured on ELM HUB at 390px, not eyeballed. Every number below is a value
   that was read off the rendered page and compared against two floors: 44px for
   anything standalone that gets tapped, and 16px for anything that is read as
   body copy rather than as fine print.

   Consolidated into one block on purpose. These are eight small corrections
   across five components, and spread through the file as eight separate
   media queries they would be impossible to review as one decision or to revert
   as one.

   NOT CHANGED, and worth writing down so it is not "fixed" later by someone
   measuring the same thing:

     - Section rhythm. #software sits at 44px where the other bands sit at 60px,
       and that is .epp-section--tight doing its job on an inset band, not drift.
     - The thumbnail strip. At 390px it wraps 3 + 2, which is exactly what the
       comment on .epp-gallery__thumb intends. An earlier reading of "4 + 1 with
       an orphan" was taken at a different width and was wrong.
     - Horizontal overflow. There is none: the document and the viewport both
       measure 375px. The wide boxes an audit script flags are carousel cells
       inside their own overflow:hidden viewports, which is the design.
   ===================================================================== */
@media (max-width: 980px) {

  /* LINE-HEIGHT 1 IS ONLY SAFE WHILE A TITLE FITS ON ONE LINE. The accordion
     title is 24px Montserrat at line-height 24px, which is fine on desktop
     where every one of them is a single line, and collides with itself on a
     phone where three of the five wrap. Features already reads 21px/1.25 from
     the block above; this is the same correction for the Resources accordion,
     which is the other consumer and was left behind.

     `.epp .epp-accordion summary` (0,2,1) rather than a bare
     `.epp-accordion summary` (0,1,1), because a media query adds no specificity
     of its own and the 24px declaration would otherwise still win. Same lesson
     as the th[scope="row"] note further down this file. */
  .epp .epp-accordion summary { font-size: 21px; line-height: 1.25; }

  /* THE 44px FLOOR, for the three standalone targets that were under it.
     Measured: the pane link 257x25, the specs search field 300x40, the related
     card's "View product" 14px in a box under 44px tall. Inline links inside a
     paragraph are deliberately excluded, they are not standalone targets and
     padding them out would break the line box they sit in. */
  .epp .epp-accordion__link a,
  .epp .epp-swlink,
  .epp .eds-card__cta { min-height: 44px; }
  .epp .epp-specs-search__input { min-height: 44px; padding-block: 11px; }

  /* BODY COPY TO 16px. The related-product excerpt is real body copy at 15px,
     and the page already concedes the point: .epp--splay .epp-related
     .eds-card__excerpt was bumped to 16px page-locally further down this file.
     This does it for every product page instead of one, on mobile only, where
     the 16px floor actually applies. */
  .epp .eds-card__excerpt { font-size: 16px; line-height: 1.6; }
  .epp .eds-card__cta { font-size: 15px; }

  /* FINE PRINT STAYS FINE PRINT, just not this fine. These three are captions
     and labels rather than body copy, so they stay subordinate; they were simply
     at or under the 12px floor with a 16.5px paragraph next to them.
     th[scope="row"] is matched at its own weight per the note further down: the
     stacked spec label was 13px sitting above its own 16.5px value, which reads
     as the label being the less important half of a pair it introduces. */
  .epp .epp-gallery__caption { font-size: 15px; line-height: 1.5; }
  .epp .epp-specs th[scope="row"] { font-size: 14px; }

  /* THE TAG IS IN THESE TWO SELECTORS BECAUSE THIS BLOCK LOSES WITHOUT IT.
     Both of these components are declared LATER in this file than this block,
     at the same specificity, and a media query adds none of its own: measured,
     .epp-swmeta stayed at 12px and the figcaption at 28px until the element name
     took each of them to (0,2,2). Written this way rather than by moving the
     block to the foot of the file, so all eight corrections stay reviewable as
     one decision. Third time this file's cascade has caught a mobile override;
     the rule of thumb is that an earlier block needs to out-specify, never just
     restate. */
  .epp p.epp-swmeta { font-size: 13px; }

  /* THE DIAGRAM LABEL. clamp(28px, 4vw, 38px) bottoms out at its 28px floor on a
     phone, so "Connectivity diagram" renders at the same size as the page's h2s
     and louder than anything it labels. It stays a figcaption and stays
     centred; it just stops out-shouting the section headings. */
  .epp figure.epp-figure--cap-top figcaption { font-size: 22px; }
}

/* THE RESOURCES MEASURE, 500px. Measured on prod: the accordion is width:500px in
   a 966px column, left-aligned, so roughly half the column is deliberately empty.
   Ours filled the full 967px column, which is the "width" and "side" Kim flagged.
   A cap rather than a column change, so the heading stays on the same 24/76 split
   as specifications and the section keeps its alignment with the rest of the page.
   At narrow widths the column is already under 500px and the cap simply stops
   applying. */
/* Condensed measure, restored 2026-08-11. Matching Features meant inheriting
   its full-width column, and four short download rows across 836px left the
   download icon stranded a long way from its label. The width is the ONLY
   thing that differs now; the summary padding, type and motion all still come
   from the shared component, which is what Kim actually wanted matched. */
.epp-accordion--resources { max-width: 560px; }

/* ONE LINE UNDER THE SUMMARY, NOT TWO. Kim 2026-08-11: 18px top and bottom,
   with a border-bottom that must not read as a 2px rule.

   The doubling is why it looked wrong: the design system already draws a
   border on <details>, so adding one to <summary> puts two hairlines flush
   against each other on a closed pane and they render as a single heavier
   line. The line moves ONTO the summary and comes OFF the details, so there is
   exactly one either way, and on an open pane it now sits between the heading
   and its content, which is where it does useful work. */
.epp-accordion details { border-bottom: 0; }
.epp .epp-accordion summary { padding-top: 18px; padding-bottom: 18px;
  border-bottom: 1px solid var(--epp-rule); }
/* Air under the rule. Kim 2026-08-11: the first row sat directly against the
   summary's border, so the line read as the row's own top edge rather than as
   the boundary of the heading above it. */
.epp-accordion .eds-accordion__body { padding-top: 14px; }

/* MATCHES THE FEATURES ACCORDION. Kim 2026-08-11: "the ui for the toggle
   section doesnt match the toggle section on the features section and i prefer
   the one in the features section".

   Both were already the same eds-accordion component; this modifier was what
   made them differ. It capped the width at 500px and squeezed the summary to
   20px, giving a narrow, tighter accordion sitting beside a full-width one two
   sections up. Dropping both overrides means Resources simply inherits the
   component, so the two are the same object and cannot drift again.

   This supersedes the earlier "the width much smaller" (2026-07-31) and the
   match to the live DMX USB PRO page: matching Features internally beats
   matching a page we are replacing. */
/* THE SECTION DOES NOT CHANGE HEIGHT WHEN YOU SWITCH PANE. Kim 2026-08-06: "make
   sure the resources main section height doesn't move or change based on the
   content inside it". The four panes hold one, two, three and two rows, so every
   toggle used to grow or shrink the section and shove the whole page below it up
   or down. The row you were reaching for moved out from under the cursor, which
   is the most annoying possible failure of a list of links.

   THE HEIGHT IS ON THE SECTION. Not on the panel, and not on the accordion
   either. Kim 2026-08-06: "give the section a height, not the toggle a height".
   The toggle keeps NO height of its own, so every pane is exactly as tall as its
   own rows and each toggle sits directly under the content above it. The spare
   room ends up at the foot of the section, where there is nothing to separate and
   it reads as the section's own padding.

   Anything lower down puts the spare room inside the list. Reserving the PANEL
   was the first attempt and it opened a hole between a one-row pane and the next
   toggle ("why is there a gap between the toggle options, it looks really bad").
   Reserving the ACCORDION moved that hole to the bottom of the accordion, which
   is better but still hands a fixed height to a component whose whole job is to
   be the size of its content. The section is the thing that must not move, so the
   section is the thing that gets the height.

   The number is MEASURED, not typed in. epp-behaviour.js opens each pane once at
   load and records the tallest the section ever gets. A hardcoded min-height would
   be a number that silently stops being true the first time somebody adds a data
   sheet, and nobody would notice until the layout jumped again.

   The fallback is 0, so with no JS the section behaves exactly as it did before:
   it resizes, which is honest, rather than reserving a guessed gap. */
.epp-section[data-epp-reserved] { min-height: var(--epp-section-reserve, 0px); }
/* Open/close motion. Native <details> cannot animate its own height, so the
   panel fades and lifts in instead. Gated on .epp-js so a page without script
   never holds content at opacity 0, and killed outright under reduced motion. */
.epp-js .epp-accordion details[open] .epp-accordion__panel {
  animation: epp-panel-in var(--eds-dur) var(--eds-ease) both; }
@keyframes epp-panel-in { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } }

/* ======================= CAPABILITY TILES =======================
   Replaces BOTH things Kim cut on 2026-07-30: the four stat cards ("32,768
   universes per protocol") and the eight-pane feature accordion.

   Why one component for both: the stat cards and the accordion panes were the
   same content type (a short labelled fact) wearing two different costumes, one
   of which hid seven of its eight items behind a click for no reason. There is
   no disclosure problem to solve here, the content is short, so it is simply
   shown. The blue rule is the only ornament, borrowed from nothing: it is the
   same 3px blue bar the design system uses to mark a section start, reused at
   tile scale so the grid reads as a set. */
.epp-caps { list-style: none; margin: 0; padding: 0; display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--eds-space-lg); }
@media (max-width: 900px) { .epp-caps { grid-template-columns: 1fr; gap: var(--eds-space-md); } }
.epp-cap { margin: 0; }
.epp-cap::before { content: ""; display: block; width: 44px; height: 3px;
  background: var(--epp-blue); border-radius: 2px; margin-bottom: var(--eds-space-xs); }
.epp-cap h3 { margin: 0 0 8px; }
/* Tile body copy is a step DOWN from the 21px page body: at 21px, six tiles
   three across become six columns of running text competing with each other,
   and the h3s stop reading as headings. */
.epp p.epp-cap__text { font-size: 16.5px; line-height: 1.55; color: var(--epp-body-ink); margin: 0; }

/* ==================== SOFTWARE: THE SIGNAL CHAIN ====================
   Kim 2026-08-11. Replaces the lead-block-plus-tile-row treatment on the English
   ELM HUB body with a three-step walkthrough of what ELM does.

   The reasoning lives on the markup in products/elm-hub.php; the short version
   is that a tile grid can list capabilities but cannot explain a product,
   because a grid has no reading order and "what is this thing" is a sequence.

   NOTE, and this is why the .epp-swlead and .epp-caps rules below still exist:
   elm-hub.CN.php has NOT been ported yet, because its copy is translated and the
   new structure carries new sentences. Until it is, the two bodies render this
   section differently and BOTH sets of rules are live. Delete the old block only
   in the same change that ports the Chinese body. */

/* The spine is a 2px track with a blue fill scaled from the top by scroll
   progress. transform:scaleY animates on the compositor; animating height would
   lay out the whole section on every scroll frame. */
.epp-chain { position: relative; }
.epp-chain__spine { position: absolute; left: 23px; top: 8px; bottom: 96px; width: 2px;
  background: var(--epp-rule); }
.epp-chain__spine i { position: absolute; inset: 0; background: var(--epp-blue);
  transform: scaleY(0); transform-origin: top; transition: transform .1s linear; }

.epp-chain__step { position: relative; padding: 0 0 clamp(48px, 5vw, 76px) 74px; }
.epp-chain__num { position: absolute; left: 0; top: 0; width: 48px; height: 48px;
  border-radius: 50%; display: grid; place-items: center;
  background: var(--epp-dark); border: 2px solid var(--epp-rule);
  font-family: var(--enttec-font-display); font-weight: 600; font-size: 17px;
  color: var(--epp-soft-ink);
  transition: border-color var(--eds-dur) var(--eds-ease), color var(--eds-dur) var(--eds-ease),
              background var(--eds-dur) var(--eds-ease), transform var(--eds-dur) var(--eds-ease); }
/* The lit state is colour and fill, never motion alone: with reduced motion on,
   the blue disc is still what marks where you are. */
.epp-chain__step.epp-lit .epp-chain__num { border-color: var(--epp-blue); color: #fff;
  background: var(--epp-blue); transform: scale(1.06); }
.epp-chain__step h3 { margin: 0 0 12px; }
.epp p.epp-chain__lede { color: var(--epp-body-ink); font-size: clamp(17px, 1.35vw, 20px);
  max-width: 62ch; margin: 0 0 24px; }
/* align-items:START, not center. The left column (stat, label, chips) is much
   shorter than the 16:10 image beside it, so centring floated the stat about
   130px below the paragraph it belongs to and the step read as two unrelated
   halves. Top-aligned, the stat sits directly under its own lede. */
.epp-chain__grid { display: grid; grid-template-columns: 1.05fr .95fr;
  gap: clamp(24px, 3vw, 44px); align-items: start; }
@media (max-width: 900px) { .epp-chain__grid { grid-template-columns: 1fr; } }
/* Fixed ratio because the three source images are 768x456, 742x800 and 1920x1080.
   Left to their natural shapes the steps sit at three different heights and the
   spine reads as uneven. */
.epp-chain__media { border-radius: var(--eds-radius); overflow: hidden;
  border: 1px solid var(--epp-rule); aspect-ratio: 16 / 10; background: #222; }
.epp-chain__media img { width: 100%; height: 100%; object-fit: cover;
  transition: transform var(--eds-dur-slow) var(--eds-ease); }
.epp-chain__media:hover img { transform: scale(1.03); }

/* One number per step, grafted from direction C. Prose gives a skimmer nothing,
   and this is the section a skimmer most needs to land on. tabular-nums so the
   count-up does not jitter the line width as the digits change. */
.epp p.epp-chain__stat { font-family: var(--enttec-font-display); font-weight: 600;
  line-height: 1; font-size: clamp(34px, 3.6vw, 46px); color: var(--epp-blue);
  margin: 0 0 10px; font-variant-numeric: tabular-nums; }
.epp .epp-chain__stat span { font-size: .5em; color: var(--epp-ink); font-weight: 500; }
.epp p.epp-chain__statlabel { color: var(--epp-body-ink); font-size: 15.5px;
  line-height: 1.5; margin: 0 0 20px; max-width: 40ch; }

.epp-chips { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }
.epp-chips li { margin: 0; font-size: 13.5px; color: var(--epp-body-ink);
  border: 1px solid var(--epp-rule); border-radius: var(--enttec-radius-pill); padding: 6px 14px;
  transition: border-color var(--eds-dur) var(--eds-ease), color var(--eds-dur) var(--eds-ease),
              transform var(--eds-dur) var(--eds-ease); }
.epp-chips li:hover { border-color: var(--epp-blue); color: #fff; transform: translateY(-2px); }

.epp-chain__out { margin: clamp(40px, 4vw, 56px) 0 0 74px; padding: 26px 28px;
  border: 1px solid var(--epp-rule); border-radius: var(--eds-radius); background: var(--epp-panel); }
.epp .epp-chain__out h4 { font-size: 17px; font-weight: 600; margin: 0 0 6px; }
.epp .epp-chain__out p { font-size: 15.5px; color: var(--epp-body-ink); margin: 0; }

.epp p.epp-chain__licence { margin: clamp(40px, 4vw, 60px) 0 0; padding-top: 26px;
  border-top: 1px solid var(--epp-rule); color: var(--epp-soft-ink); font-size: 15.5px;
  max-width: 84ch; }
.epp .epp-chain__licence strong { color: #fff; }

/* The spine needs a gutter to live in. Below 620px there is none, so it goes and
   the numeral sits above its heading instead of beside it. */
@media (max-width: 900px) {
  .epp-chain__step { padding-left: 64px; }
  .epp-chain__out { margin-left: 64px; }
  .epp-chain__spine { left: 18px; }
}
@media (max-width: 620px) {
  .epp-chain__step { padding-left: 0; padding-top: 62px; }
  .epp-chain__out { margin-left: 0; }
  .epp-chain__spine { display: none; }
}

/* ==================== SOFTWARE LEAD BLOCK ====================
   Direction A from _kim/mockups/software-section-directions.html, chosen by Kim
   2026-07-31. One capability at full size with its image beside it, then the
   supporting four as a quiet row underneath (.epp-caps--4).

   Left-aligned, unlike the centred two-up it replaces. Centring was most of why
   that version was hard to look at: with no common left edge the eye has to find
   the start of every block on its own, and the section reads slower than the
   ones above and below it. */
/* NO RULE UNDER THE CLAIM. Kim 2026-08-11. The three blue rules over the proof
   row already draw the boundary a line here was drawing, so the grey one was a
   second divider doing the first one's job, and it cut the claim off from the
   evidence that belongs to it. The gap alone separates them. */
/* align-items: start, not center. Both columns now carry a stack of their own
   (copy, explainer, link, licence on the left; picture then the figures on the
   right), so centring them against each other left the shorter one floating in
   the middle of the taller one's height with a gap above and below. Top-aligned
   they start on the same line, which is the only alignment a reader can see. */
.epp-swlead { display: grid; grid-template-columns: 1fr 1.15fr;
  gap: clamp(32px, 5vw, 72px); align-items: start;
  margin-bottom: 0; }
.epp-swlead__copy h3 { font-size: clamp(24px, 2.6vw, 30px); font-weight: 500;
  margin: 0 0 18px; text-wrap: balance; }
.epp-swlead__copy p { color: var(--epp-body-ink); font-size: 19px; max-width: 52ch; }
/* The lead image is a photograph with its own dark background and a baked-in
   award badge, so it takes a plain rounded frame and no panel fill: a tinted
   panel behind a full-bleed photo just shows as a rim.

   CAPPED AT 520px. Kim 2026-08-06 on the source file: "this is too big". At the
   1.15fr column it filled 681px and stood 405px tall next to 198px of copy, so a
   supporting illustration was twice the height of the point it was supporting and
   won the section. 520 brings it to about 309px, close enough to the text block
   that the two read as a pair.

   margin-left: auto is the load-bearing half of this. Shrinking the image inside
   its column would otherwise pull its right edge in off the container, and this
   photo's right edge is what lines up with the gallery, the accordions and every
   other block on the page. Capping it and pushing it right keeps that line. */
/* THE FRAME MOVED TO THE IMAGE. This column is no longer just a picture: the
   three figures sit under it now, and a border with overflow:hidden around the
   whole column would have drawn a box around both and clipped nothing useful.
   border-radius on the img clips the photograph on its own. */
.epp-swlead__media { max-width: 520px; margin-left: auto; width: 100%; }
.epp-swlead__media img { display: block; width: 100%; height: auto;
  border-radius: var(--eds-radius); border: 1px solid var(--epp-rule); }

/* THE FIGURES, as evidence under the picture rather than three headline
   columns. Kim 2026-08-11 picked this arrangement (option 2 of
   _kim/mockups/elm-hub/elm-section-positioning.html) because two columns doing
   two different jobs, "what it is" on the left and "here is the proof" on the
   right, is the arrangement least likely to read as mesh.

   The number column is a fixed 96px rather than auto: with auto, "32,768",
   "100+" and "1TB" each set their own width and the three descriptions started
   at three different places, which is exactly the ragged look the section was
   being redesigned away from. A fixed column gives the descriptions one edge. */
.epp-swfacts { list-style: none; margin: var(--eds-space-md) 0 0; padding: 0;
  border-top: 1px solid rgba(255, 255, 255, .09); }
.epp-swfacts li { display: flex; gap: 18px; align-items: baseline;
  padding: 15px 0; margin: 0;
  border-bottom: 1px solid rgba(255, 255, 255, .09); }
.epp-swfacts b { flex: 0 0 96px; font-family: var(--enttec-font-display);
  font-weight: 400; font-size: 19px; letter-spacing: -.01em; color: var(--epp-ink); }
.epp-swfacts span { font-size: 15px; line-height: 1.5; color: var(--epp-soft-ink);
  min-width: 0; }

/* The explainer and the argument. 19px for the explainer because it is the one
   paragraph on this page a reader who does not know what ELM is has to get
   through, and 17px for the argument under it, so the two read in order rather
   than as one 90-word block. */
/* 65ch, Kim 2026-08-11. Worth knowing where this actually bites: on desktop it
   does not. The two-column grid gives the copy about 406px, which is narrower
   than either cap, so the column is what sets the measure and the ch value is
   only a ceiling. It takes effect below 1000px, where the grid collapses to one
   column and the paragraphs would otherwise run the full band. */
/* 22px, not 18px (2026-08-14). Every other gap in this column is 22px, so the
   lede was the one element sitting on its own value and the rhythm stuttered
   just above the Sentry paragraph. These classes are used by elm-hub{,.CN,.RU}
   and nothing else, so the change is contained to this page. */
.epp .epp-swlede { font-size: 19px; line-height: 1.62; color: var(--epp-body-ink);
  max-width: 65ch; margin: 0 0 22px; }
.epp .epp-swpara { font-size: 17px; line-height: 1.68; color: var(--epp-body-ink);
  max-width: 65ch; margin: 0 0 22px; }
.epp .epp-swlede strong, .epp .epp-swpara strong { color: var(--epp-ink); font-weight: 500; }
/* One column below 1000px, where the image is the full measure and the cap would
   leave it stranded against the left edge with a hole beside it. */
@media (max-width: 1000px) {
  .epp-swlead { grid-template-columns: 1fr; }
  .epp-swlead__media { max-width: none; margin-left: 0; }
}

/* Four across for the supporting row. Steps to two then one rather than
   squeezing four columns of two-word lines. */
.epp-caps--4 { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--eds-space-md); }
@media (max-width: 1000px) { .epp-caps--4 { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 620px)  { .epp-caps--4 { grid-template-columns: 1fr; } }
/* Supporting tiles are a step down from the lead: 17px against the lead's 19px,
   and the heading drops to 18px. They are there to be scanned, not read. */
.epp-caps--4 .epp-cap h3 { font-size: 18px; font-weight: 600; }
.epp .epp-caps--4 .epp-cap__text { font-size: 15.5px; }

/* Three across, for a row of proofs rather than a row of features. Kim
   2026-08-10 chose option C of _kim/mockups/elm-hub/elm-section-options.html,
   which trades the four-capability list for three one-line proofs.

   No two-column halfway state, unlike --4. Three single-line proofs at two
   across leave one alone on the second row, and a lone tile under a pair reads
   as a missing fourth item rather than as a set of three.

   THE MEDIA QUERY IS NOT OPTIONAL, and inheriting the base one does not work.
   .epp-caps collapses to a single column at 900px, but that rule and this one
   are both (0,1,0) and this one is further down the sheet, so it wins even
   inside the narrower viewport. Without the override below, a 390px phone
   renders three 90px columns of wrapped text. Caught on the ELM HUB page
   2026-08-10; the same trap is why --4 declares its own steps. */
.epp-caps--3 { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--eds-space-lg); }
@media (max-width: 900px) { .epp-caps--3 { grid-template-columns: 1fr; gap: var(--eds-space-md); } }
.epp-caps--3 .epp-cap h3 { font-size: 20px; font-weight: 500; }

/* Proof-row variant. The 44px blue stub grows into a full-width 2px rule over
   each column, which is what turns three tiles into one banded row: the eye
   gets three equal starts on the same line instead of three floating blocks.
   Kept as a modifier on .epp-cap rather than a new component, so the stub, the
   heading scale and the body size all still come from one place. */
/* NO BLUE RULE IN THIS SECTION. Kim 2026-08-11: "it actually looks weird here".
   The full-width version was drawn when the proofs were bordered cards on a
   near-black band and needed a top edge to sit under. On the plain charcoal
   band there is nothing for it to cap, and three heavy blue lines across the
   section were louder than the numbers they were introducing. The icon is the
   column's opening mark now. */
.epp-caps--proof .epp-cap::before { display: none; }

/* THE NUMBER BELONGS TO THE PROOF, not to a row of its own.
   Kim 2026-08-11: "UI-wise it looks bad". The cause was two identical
   three-column rows stacked, three proofs then three figures, so the same
   rhythm ran twice with nothing dominant and 85 words floated in 590px of
   dark. Folding each figure into the proof it evidences leaves ONE row where
   every column has a hierarchy to climb: rule, number, claim, line.
   It also promotes the specs a specifier is hunting for (universes, effects,
   storage) out of the quietest type on the page.

   Number first, heading second. A numeral stops the eye where a sentence does
   not, so it earns the top of the column; the heading below it says what the
   number is evidence OF. */
.epp-cap__stat { display: block; margin: 0 0 10px; }
.epp-cap__stat b { display: block; font-size: clamp(28px, 3vw, 40px); font-weight: 300;
  line-height: 1; color: var(--epp-ink); letter-spacing: -.01em; }
.epp .epp-cap__stat span { display: block; margin-top: 6px; font-size: 13.5px;
  line-height: 1.4; color: var(--epp-soft-ink); }
/* The claim under a number is a label, not a headline: half a step down from
   the 20px --3 heading so the number keeps the column. */
.epp-caps--proof .epp-cap h3 { font-size: 18px; font-weight: 600; margin-bottom: 6px; }
/* The three body lines are written to the SAME character count (65 in English,
   Kim 2026-08-11), so the columns end together instead of one running a line
   longer than its neighbours. min-height holds that alignment when a narrow
   viewport wraps one of them anyway: without it the blue rules stay level but
   the text below them does not. */
.epp .epp-caps--proof .epp-cap__text { min-height: 3.2em; }

/* ==================== PROOF ICONS ====================
   Kim 2026-08-11 replaced all three, and each one now says what its column
   actually measures rather than what its heading loosely implies:

     3D cube   for 32,768 universes. The proof is about laying a rig out flat
               or in true 3D, so the mark is the volume, not a flat grid.
     Sparkles  for 100+ effects. A play triangle described PLAYBACK, which is
               a different claim from having a library of generative effects.
     Drive     for the 1TB SSD. It was a clock, which measured time rather than
               storage, and storage is the number underneath it.

   Inline SVG so they inherit currentColor and cost no request. One motion each,
   transform or opacity only, so nothing can reflow the row. */
.epp-cap__ico { display: block; width: 26px; height: 26px; margin-bottom: 14px;
  color: var(--epp-blue); overflow: visible; }

/* cube: a small tip toward the reader, as though turning in space */
.epp-caps--proof .epp-cap:first-child:hover .epp-cap__ico {
  transform: rotate(-6deg) scale(1.06); }
.epp-caps--proof .epp-cap:first-child .epp-cap__ico {
  transition: transform .34s var(--eds-ease); }

/* sparkles: the two small ones twinkle out of step with each other, which is
   what makes it read as sparkle rather than as a single pulse */
.epp-cap__spark1, .epp-cap__spark2 { transform-origin: center;
  transition: transform .3s var(--eds-ease), opacity .3s var(--eds-ease); opacity: .55; }
.epp-cap:hover .epp-cap__spark1 { transform: scale(1.3); opacity: 1; transition-delay: .04s; }
.epp-cap:hover .epp-cap__spark2 { transform: scale(1.3); opacity: 1; transition-delay: .14s; }
.epp-cap__star { transition: transform .3s var(--eds-ease); transform-origin: 12px 9.5px; }
.epp-cap:hover .epp-cap__star { transform: scale(1.08); }

/* drive: the two activity LEDs light in sequence, the way a disk actually
   blinks when something is reading from it */
.epp-cap__led, .epp-cap__led2 { transition: opacity .22s var(--eds-ease); opacity: .45; }
.epp-cap:hover .epp-cap__led { opacity: 1; }
.epp-cap:hover .epp-cap__led2 { opacity: 1; transition-delay: .12s; }

@media (prefers-reduced-motion: reduce) {
  .epp-cap__ico, .epp-cap__spark1, .epp-cap__spark2, .epp-cap__star,
  .epp-cap__led, .epp-cap__led2 { transition: none; }
  .epp-caps--proof .epp-cap:first-child:hover .epp-cap__ico,
  .epp-cap:hover .epp-cap__spark1, .epp-cap:hover .epp-cap__spark2,
  .epp-cap:hover .epp-cap__star { transform: none; }
}

/* Tighter band, for a section whose copy has been cut to a fraction of what
   the page's default rhythm was built for. At 85 words the standard
   --eds-section-y reads as an empty room rather than as breathing space.
   A modifier, not an override of .epp-section, so every other section on
   every other product keeps the spacing it was designed with. */
.epp-section--tight { padding-block: clamp(44px, 4.5vw, 72px); }
/* One gap, not a gap plus the padding that used to sit under the removed rule. */
.epp-section--tight .epp-swlead { margin-bottom: clamp(40px, 4.4vw, 64px); }

/* ==================== SOFTWARE LOCKUP + CLAIM ====================
   The ELM logo, its attribution and the SKU, then the claim at display size.

   Why the logo carries the attribution instead of a sentence: "pre-installed
   on every ELM HUB" and the SKU are lookup facts, not reading. Sitting them
   against the mark answers "whose software is this and what do I order" in one
   glance and keeps the claim underneath free to be the only sentence.

   The logo is the ELM 2026 page's own asset, white ink with brand-blue
   accents, so it is drawn for a dark surface and gets no recolouring. Sized
   well under that page's hero use: here it is an attribution and must not
   out-shout the claim. */
/* JUST THE MARK. Kim 2026-08-11: "the Pre-installed on every ELM HUB / SKU
   70581 and the logo is a bit of a weird layout - can we move it around
   somewhere better?"

   The honest answer was that there was nowhere better, because the two lines
   beside the logo did not need to exist here at all. Both facts are now stated
   once each, in the place a reader would look for them: "ships with ELM 2026
   already installed and configured" is in the argument paragraph, and the part
   number is in the license line at the foot of the column. What remains is a
   brand mark opening the section, which is the whole of what the lockup was
   doing visually.

   .epp img.<class>, not the class alone. WooCommerce ships
   `.woocommerce img, .woocommerce-page img { height: auto }` and every product
   page carries .woocommerce-page on the body, so a single-class rule (0,1,0)
   loses to its (0,1,1) and the logo renders at its intrinsic 190px. Any fixed
   image height on these pages has to clear that bar. */
.epp img.epp-swmark { height: clamp(52px, 4.6vw, 66px); width: auto; display: block;
  margin: 0 0 12px; }

/* ONE LINE UNDER THE MARK, carrying both facts Kim asked to keep: that ELM is
   pre-installed (which answers a question buyers ask before they ask anything
   else) and the part number. A middot separates them rather than a second row,
   so the mark and its caption are two rows total instead of the three-part
   object that read as weird. Wrapping is left on: on a narrow column the SKU
   drops to its own line rather than squeezing the sentence. */
.epp .epp-swmeta { font-family: var(--enttec-font-display); font-size: 12px;
  letter-spacing: .12em; text-transform: uppercase; color: var(--epp-soft-ink);
  line-height: 1.6; margin: 0 0 clamp(16px, 1.8vw, 22px); max-width: none; }
.epp .epp-swmeta b { color: var(--epp-body-ink); font-weight: 600; letter-spacing: .06em; }
.epp .epp-swmeta span { color: var(--epp-rule); margin: 0 4px; }

/* The claim is the section's heading, so it is an h2 carrying display type
   rather than a styled paragraph: the page still needs one real heading here,
   and this is the only sentence in the section worth being it.
   20ch caps it at three lines, which is what makes it read as a statement
   rather than as a paragraph that happens to be large. */
/* MATCHES THE PAGE'S OTHER HEADINGS. Kim 2026-08-11 asked whether this and the
   Overview heading were the same weight. They were not: 300 at 42px with
   -0.01em tracking against the page's 400 at 38px with none, so this section
   was running its own type. Defensible on the near-black band it used to sit
   on, wrong now the band is the same charcoal as its neighbours. Weight, size
   and tracking all come from the page's h2 rules; only the measure and the
   balancing are local. */
/* The gap under the claim lives HERE, on the (0,2,1) rule, not on the
   .epp-swlead__copy one below it. A `margin: 0` shorthand at higher
   specificity was quietly resetting anything the lower rule set, so the
   explainer paragraph started on the line directly under a 46px heading. */
.epp h2.epp-swclaim { max-width: 20ch; margin: 0 0 22px; text-wrap: balance; }
.epp-swclaim em { font-style: normal; color: var(--epp-blue); }


/* ==================== THE WAY OUT ====================
   This section stops short of the full feature list on purpose, so it owes the
   reader the page that carries it. It sits on its own rule under the proofs,
   which is also the line that says the section has finished.

   The standalone figure row this replaced is gone: its three numbers now live
   inside the proofs they evidence (see .epp-cap__stat above), which is what
   removed the duplicated three-column rhythm. */
/* UNDER THE CLAIM, not stranded at the foot of the section. Kim 2026-08-11:
   "i really hate that the link is on its own".

   It had a rule above it and a whole row to itself, which made a single link
   look like an abandoned section footer. It belongs to the claim: the claim
   stops short of the feature list on purpose, and this is the sentence that
   says where the rest is. Sitting directly under it, the two read as one
   thought and the rule is not needed to separate anything. */
.epp p.epp-swtail { margin: clamp(18px, 2vw, 26px) 0 0; }
/* A BUTTON, not a text link. Kim 2026-08-11 asked for a border. It is the only
   way out of a section that deliberately stops short of the feature list, so
   it should look like something you press. Ghost rather than filled: the blue
   fill was already rejected on the View all products button for being louder
   than the action deserves. */
.epp-swlink { display: inline-flex; align-items: center; gap: 9px; font-weight: 500;
  font-size: 15px; color: var(--epp-ink); text-decoration: none;
  border: 1px solid var(--epp-rule); border-radius: 999px; padding: 10px 20px;
  white-space: nowrap;
  transition: color .3s cubic-bezier(.22,.61,.36,1), border-color .3s cubic-bezier(.22,.61,.36,1),
              background .3s cubic-bezier(.22,.61,.36,1), gap .3s cubic-bezier(.22,.61,.36,1); }
/* FILLS ON HOVER. Kim 2026-08-11: blue background, white text, rather than
   the whole thing turning blue. It reads as a button being pressed into
   service rather than a link changing its mind, and white on #0ea5df is the
   same pairing every primary button on the site already uses.

   The arrow still slides, which is the second half of the hover it already
   had. Background and border move together so the pill fills edge to edge with
   no rim of the old colour left showing. */
.epp-swlink:hover { background: var(--epp-blue); border-color: var(--epp-blue);
  color: #fff; gap: 13px; }
.epp-swlink:active { background: var(--epp-blue-hover, #0b93c7); border-color: var(--epp-blue-hover, #0b93c7); }
.epp-swlink:focus-visible { outline: 3px solid var(--epp-blue); outline-offset: 3px; border-radius: 3px; }
.epp-swlink .epp-swlink__arw { transition: transform .3s cubic-bezier(.22,.61,.36,1); }
.epp-swlink:hover .epp-swlink__arw { transform: translateX(3px); }
@media (prefers-reduced-motion: reduce) {
  .epp-swlink, .epp-swlink .epp-swlink__arw { transition: none; }
  .epp-swlink:hover { gap: 9px; }
  .epp-swlink:hover .epp-swlink__arw { transform: none; }
}

/* THE LICENSING LINE under the three proofs. Deliberately quiet: 14px in the
   soft ink, no rule above it and no box. It is a purchase detail, not a fourth
   proof, and giving it a card or a border would make it compete with the three
   above it for the same attention.
   640px, not the full band: this sits under a three-column grid, and a single
   line running the whole width of that grid reads as a footnote nobody has to
   finish.

   SELECTOR CARRIES TWO CLASSES ON PURPOSE. `.epp-section--dark p` sets the
   band's paragraph colour to the bright ink, and at (0,1,1) it outranks a lone
   `.epp-swnote`, so the "quiet" colour silently lost and the note rendered
   pure white. Two classes beat one class plus an element. */
.epp .epp-swnote { margin: var(--eds-space-md) 0 0; max-width: 640px;
  font-size: 14px; line-height: 1.65; color: var(--epp-soft-ink); }
/* No bold anywhere in this line. Kim 2026-08-11: "sold separately should not be
   font-weight bold". The <strong> is gone from the markup rather than being
   overridden to 400 here, so the file does not carry a rule whose whole job is
   to cancel a tag we chose to write. The line is one sentence; it does not need
   a word lifted out of it to be found. */
.epp .epp-swnote a { color: inherit; text-decoration: underline;
  text-underline-offset: 3px; text-decoration-thickness: 1px;
  transition: color var(--eds-dur) var(--eds-ease); }
.epp .epp-swnote a:hover { color: var(--epp-ink); }
@media (prefers-reduced-motion: reduce) { .epp-swnote a { transition: none; } }

/* ===================== LICENSE WARNING CALLOUT =====================
   Added 2026-08-14. The product team asked for it to be unmistakable that the
   software is installed but the LICENSE is not included, and the sales handout
   carries the point as a warning on every ELM slide it has.

   WHY THIS IS A NEW ELEMENT AND NOT A BOLDER .epp-swnote. Kim, 2026-08-11:
   "sold separately should not be font-weight bold", and the swnote rule above
   deliberately carries no bold at all. Emphasising that line now would reverse a
   decision rather than add to it. So the quiet purchase detail stays quiet and
   the warning gets its own object, which is also the honest structure: one is a
   commercial footnote, the other is a caveat somebody must not miss.

   COLOURS ARE THE ELM 2026 PAGE'S OWN WARNING TREATMENT (.elmxb-sentrynote in
   elm-2026.php), not new ones, so a caution reads the same on both pages.

   Contrast checked on the #2f2f2f band: #f2a35a on #2f2f2f is 7.3:1 and the
   bolded #f7c08a is 9.6:1, both clear of AA at this size. The colour is never
   the only signal either, the icon and the word NOT carry it too. */
.epp .epp-swalert { display: flex; gap: 11px; align-items: flex-start;
  margin: var(--eds-space-md) 0 0; max-width: 640px;
  padding: 13px 15px; border-radius: 10px;
  background: rgba(234,88,12,.10); border: 1px solid rgba(234,88,12,.34);
  font-size: 14px; line-height: 1.6; color: #f2a35a; }
/* An inline SVG, not the ⚠ character: the emoji presentation of U+26A0 renders
   as a colour glyph on some platforms and a thin outline on others, so its size
   and weight beside the text cannot be relied on. */
.epp .epp-swalert__icon { flex: none; width: 17px; height: 17px; margin-top: 2px; color: #f2a35a; }
.epp .epp-swalert__icon svg { display: block; width: 100%; height: 100%; }
/* max-width:none is load-bearing (2026-08-14). The warning sits inside
   .epp-swlead__copy, which caps its paragraphs at 52ch for the 19px body copy.
   At the warning's 14px that computed to ~362px inside a 588px box, so the
   disclosure body wrapped early and left a wide ragged gap down the right. The
   box already has its own measure via max-width:640px, so the column's measure
   must not apply a second time inside it. */
/* text-wrap:pretty stops the last line stranding a single word (Kim 2026-08-14:
   the disclosure body was ending on a line containing only "cost."). It asks the
   browser to shuffle the earlier breaks so the final line carries more than an
   orphan, which is exactly the case a manual &nbsp; would otherwise have to
   patch by hand every time the sentence changes.

   Progressive enhancement: unsupported browsers wrap as before, nothing breaks.
   Same family as the text-wrap:balance already on .epp-swclaim. */
.epp .epp-swalert p { margin: 0; color: inherit; font-size: inherit; line-height: inherit;
  max-width: none; text-wrap: pretty; }
.epp .epp-swalert b, .epp .epp-swalert strong { color: #f7c08a; font-weight: 600; }
.epp .epp-swalert a { color: inherit; text-decoration: underline;
  text-underline-offset: 3px; text-decoration-thickness: 1px;
  transition: color var(--eds-dur) var(--eds-ease); }
.epp .epp-swalert a:hover { color: #f7c08a; }
@media (prefers-reduced-motion: reduce) { .epp .epp-swalert a { transition: none; } }

/* ============ SOFTWARE SECTION: TWO BUTTONS, NOT ONE ============
   Kim 2026-08-14 asked for a second button beside "See everything ELM 2026 does"
   going straight to ELM's What's New section. Wrapping them in a flex row rather
   than letting the second inherit .epp-swtail's block layout, so they sit side by
   side and wrap together on a narrow column. */
.epp .epp-swtail { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 18px; }
/* The secondary sits QUIETER than the primary: an outline, not a fill. Two solid
   buttons of equal weight is two primary actions, and the deeper link is the
   ELM product page, not the section anchor. */
.epp .epp-swlink--ghost { background: transparent; border: 1px solid var(--epp-rule);
  color: var(--epp-body-ink); }
.epp .epp-swlink--ghost:hover { background: rgba(255,255,255,.06);
  border-color: var(--epp-blue); color: #fff; }
.epp .epp-swlink--ghost:active { background: rgba(255,255,255,.1);
  border-color: var(--epp-blue); }

/* ============================== GALLERY ==============================
   One main image with a thumbnail strip under it, NOT a carousel: with three
   real product images, a component that hides two of them is the wrong shape.
   Arrows stay for keyboard and touch.

   object-fit:contain, never cover: these are product renders and annotated
   panel callouts, and a crop would cut a connector or a callout label.

   NO STAGE BACKGROUND, 2026-08-04 (Kim): "remove the white background behind the
   image carousel and just leave it as transparent". The stage used to be #ffffff
   with a rule and padding, on the reasoning that the renders were photographed on
   white and a white holder turned a hard edge into a deliberate panel. That was
   solving the wrong problem: the white was not the renders' and it was not the
   page's, it was added by build-render-assets.py, which composited renders that
   have a REAL alpha channel onto an opaque white canvas. The renders are now
   transparent and take the NO SHADOW variant, so the unit sits on the dark band
   with no box at all and there is nothing left for a holder to hide.

   Keep the aspect-ratio though (see the note on __main): it is what stops the
   column resizing every time somebody switches image.

   overflow:hidden now has two jobs, and both are load-bearing. It clips the hover
   lens to the picture area (without it the magnifier circle spills over the
   accordion text next to it whenever the cursor is near an edge), and since
   2026-08-06 it is also the window the image track slides behind: the four images
   that are not on stage are laid out either side of the visible one and it is
   this rule, and only this rule, that keeps them out of sight. */
.epp-gallery__stage { position: relative; overflow: hidden; }
/* THE FOCUS FRAME. A feature pane can ask the stage to close in on the part it is
   describing (the monitor, the network ports). The transform lives on the cell's
   own <img>, so it composes with the TRACK's translateX instead of fighting it:
   the track owns which image is on stage, this owns how closely it is framed.

   THE EASE IS THE POINT, not decoration. The motion is what tells the visitor
   that the detail belongs to the unit they were already looking at, which a cut
   between two photographs cannot say. Slow out, no bounce: it should read as a
   camera pushing in, not as a UI element animating.

   Reduced motion still ZOOMS, it just arrives instantly. Removing the transition
   keeps the information (which part) and drops only the travel, where removing
   the zoom would throw away the meaning and leave the plain image-swap that was
   rejected in review. */
.epp-gallery__cell img, .epp-gallery__main {
  transition: transform .62s cubic-bezier(.22, .61, .36, 1); will-change: transform; }
@media (prefers-reduced-motion: reduce) {
  .epp-gallery__cell img, .epp-gallery__main { transition: none; } }
/* While a frame is held the stage is not a magnifier: the cursor should stop
   promising a zoom the pointer will not deliver (the lens is suppressed in JS). */
.epp-gallery.is-focused .epp-gallery__zoom { cursor: default; }
.epp-gallery.is-focused .epp-gallery__hint { opacity: 0; }
/* THE STAGE RATIO IS THE FILE'S RATIO, 1800/990. Not 16/9, and not the product's
   own shape. Measured alpha bounding boxes of the five renders, 2026-08-06:

     hero        1527x950  1.61      rear-angle  1546x949  1.63
     angle-low   1728x710  2.43      rear        1727x537  3.22
     front       1728x506  3.42

   Two things follow, and they close off the obvious "make the product bigger by
   matching the stage to the content" idea:

   1. There is no single content ratio to match. The unit is 1.61:1 in the two 3/4
      views and 3.42:1 in the front elevation, so any stage ratio is generous to one
      pair and mean to the other. The ratio has to stay ONE number regardless (it is
      what stops the column resizing when somebody switches image, i.e. it is a CLS
      guard), so it can only match the canvas, which every file shares.
   2. Against 16/9 (1.778) the 1.818 canvas was width-limited and letterboxed by
      about 1.2% of the stage width, ~8px at a 640px stage. Real, but small. Using
      the canvas ratio makes it exactly zero, which is the whole win available here.

   The dead space that is actually visible lives INSIDE the files: the front
   elevation carries 242px of transparent margin top and bottom on a 990px canvas.
   Only a re-crop in build-render-assets.py removes that, and that means five new
   CDN objects, so it waits for the CDN decision rather than being smuggled in as a
   CSS tweak. Recorded so this is not reopened as a free win: it is not free.

   History: a 1.6:1 stage letterboxed the 1.82:1 canvas by about 40px top and
   bottom, dead white that made the unit look lost in a box and gave the hover lens
   two bands with nothing to magnify. At 16/9 (1.78:1) the picture very nearly
   fills the holder. Kept as a ratio rather than a fixed height so the stage still
   scales with the column. */
.epp-gallery__main { display: block; width: 100%; aspect-ratio: 1800 / 990;
  object-fit: contain; }
/* THE TRACK. Built by the JS, one cell per image, never authored in the body.

   The two lines that do the work are `width: 100%` on the track and
   `flex: 0 0 100%` on the cell. flex-basis resolves against the TRACK's inner
   width, so every cell is exactly one stage wide and the row overflows a box
   that is still only one stage wide. That is what lets the JS say
   `translateX(-100%)` and mean "one image", with no width arithmetic and nothing
   to recompute when the column resizes.

   translate3d rather than translateX in the JS, and transform rather than `left`
   here, so the drag runs on the compositor: a drag that repaints on every
   pointermove is the difference between the picture following the cursor and the
   picture chasing it.

   The transition is the SNAP, not the drag. While a finger or a mouse is down the
   JS sets transition:none and writes the offset directly, so the picture is
   exactly where the pointer is; on release the transition takes over and eases
   into place. The curve is a decelerate: fast at the moment of release, slowing
   into the stop, which is how a released physical object behaves.
   Reduced motion is already covered by the blanket rule at the foot of this
   sheet, which kills the transition and leaves the move instant. */
.epp-gallery__track { display: flex; width: 100%;
  transition: transform 320ms cubic-bezier(.22, .61, .36, 1); }
.epp-gallery__cell { flex: 0 0 100%; min-width: 0; }
/* ARROWS, DELIBERATELY QUIET. Kim 2026-08-04: "the right and left arrows aren't
   the best as it attracts too much attention - can we fix this so its more
   subtle". They were the design system's carousel arrows: 44px filled black
   circles sitting ON the picture, so on a product shot they competed with the
   product and overlapped it.

   Now: no fill, no ring, just the chevron, held at 55% until the pointer is
   somewhere in the gallery. The 44px accessible tap target is kept as PADDING
   rather than a painted circle, so the target is still there for a thumb even
   though there is no longer a button-looking thing to see (WCAG 2.5.5 is about
   the hit area, not about how loud it looks).

   They stay visible without hover on touch, where there is no hover to reveal
   them with: the :hover/:focus-within reveal is inside a (hover: hover) query. */
.epp-gallery .eds-carousel__arrow { width: 44px; height: 44px; border-radius: 0;
  background: none; color: #fff; font-size: 30px; line-height: 1;
  opacity: .55; text-shadow: 0 1px 6px rgba(0, 0, 0, .55);
  transition: opacity var(--eds-dur) var(--eds-ease); }
.epp-gallery .eds-carousel__arrow:hover { background: none; opacity: 1; }
.epp-gallery .eds-carousel__arrow--prev { left: 0; }
/* NOTHING DRAWN ON A MOUSE PRESS. Kim 2026-08-11: a black border appears when
   pressing the image carousel.

   There is no :active rule for these controls anywhere in our CSS or the design
   system (checked all 38 on the page against every control in the gallery), so
   what shows is the browser's own ring on the pressed button: :focus lands on
   mouse-down, and only :focus-visible was ever styled here.

   :focus:not(:focus-visible) is the precise fix. It silences the pointer case
   and leaves the keyboard case completely untouched, which a blanket
   outline:none would have destroyed. The Firefox inner border and the mobile
   tap flash are the same class of browser-drawn artefact, so they go with it. */
.epp-gallery button:focus:not(:focus-visible) { outline: none; }
.epp-gallery button::-moz-focus-inner { border: 0; }
.epp-gallery button { -webkit-tap-highlight-color: transparent; }
.epp-gallery .eds-carousel__arrow--next { right: 0; }
@media (hover: hover) and (pointer: fine) {
  /* Out of the way until the visitor is actually looking at the gallery. */
  .epp-gallery .eds-carousel__arrow { opacity: 0; }
  .epp-gallery:hover .eds-carousel__arrow,
  .epp-gallery:focus-within .eds-carousel__arrow { opacity: .55; }
  .epp-gallery:hover .eds-carousel__arrow:hover,
  .epp-gallery .eds-carousel__arrow:focus-visible { opacity: 1; }
}
/* Focus must never be hidden by the reveal rule above: a keyboard user has no
   pointer to trigger it with. */
.epp-gallery .eds-carousel__arrow:focus-visible { opacity: 1;
  outline: 3px solid var(--epp-blue); outline-offset: 2px; }
/* The slide counter is the non-motion half of the gallery's feedback: with
   reduced motion on there is no crossfade, so this text and the active thumb
   are what tell you the control worked. Never hide it from sighted users. */
/* CAPTION, built by the script from each thumb's data-epp-caption. It names what
   is in the picture on the stage, which is what makes the frame legible when the
   visitor's eye is on the accordion text rather than on the gallery.

   ONE ROW OF SUPPORTING TEXT, NOT TWO. Kim, reviewing: the caption and the
   "Image 1 of 5" counter "show up line after line and the ui looks weird". They
   were two centred meta rows competing directly under the picture, one sentence
   case and one uppercase, reading as two captions rather than as a caption and a
   counter. The counter lost, because the thumbnail strip immediately below it
   already answers the same question BETTER: five thumbs with the active one
   outlined shows both which image and how many, spatially. So the counter is now
   screen-reader-only rather than deleted, see __status below.

   The min-height is MEASURED by the script, never typed, because the captions
   differ in length and an unreserved row grows and shrinks as the stage changes,
   walking the thumbnails and everything below them up and down the page. Same
   class of bug as the accordion's data-epp-reserve, and the same answer. */
.epp-gallery__caption { margin: var(--eds-space-xs) 0 0; text-align: center;
  font-size: 14px; line-height: 1.45; color: var(--epp-soft-ink); }
/* Still in the DOM, still role="status" aria-live="polite", so a screen reader
   is told "Image 2 of 5" on every change exactly as before. Clipped rather than
   display:none on purpose: a display:none live region announces nothing. */
.epp-gallery__status { position: absolute; width: 1px; height: 1px;
  margin: -1px; padding: 0; overflow: hidden; white-space: nowrap;
  clip: rect(0 0 0 0); clip-path: inset(50%); border: 0; }
/* CENTRED under the stage. Kim 2026-08-04: the strip was anchored left, which read
   as an accident next to a centred product. */
.epp-gallery__thumbs { list-style: none; margin: var(--eds-space-sm) 0 0; padding: 0;
  display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; }
.epp-gallery__thumbs li { margin: 0; }
/* 96x60 clears the 44px tap-target floor on both axes. box-sizing is explicit
   so the padding cannot eat into that floor if the global border-box rule ever
   stops applying here.

   THE TILE IS A FAINT LIGHT PANEL, not white, and NOTHING IS DIMMED WITH OPACITY.
   Kim 2026-08-04: "this as the image thumbnails look really bad". The cause was
   the two rules together: an opaque white tile, dimmed to 84% opacity when not
   selected. Opacity fades the whole element, tile included, so four white tiles
   mixed with the #333 band to grey while the selected one stayed white, and the
   strip looked like five tiles in two different colours. Now every tile is the
   same colour in every state and only the BORDER moves, which is also the more
   honest signal: an unselected thumbnail is not a disabled one.
   (Baymard's finding is that visitors miss the other images on 40% of product
   galleries, and those images are what decides the purchase, so making four of
   five look switched off was working against us.) */
.epp-gallery__thumb { box-sizing: border-box; width: 96px; height: 60px;
  padding: 5px; display: block;
  border: 1px solid rgba(255, 255, 255, .16); border-radius: var(--eds-radius-sm);
  background: rgba(255, 255, 255, .08); cursor: pointer; overflow: hidden;
  transition: border-color var(--eds-dur) var(--eds-ease),
              background var(--eds-dur) var(--eds-ease),
              transform var(--eds-dur) var(--eds-ease); }
.epp-gallery__thumb img { display: block; width: 100%; height: 100%; object-fit: contain; }
.epp-gallery__thumb:hover { border-color: rgba(255, 255, 255, .45);
  background: rgba(255, 255, 255, .14); transform: translateY(-2px); }
/* Active state is colour and a ring, never motion alone.

   WHITE, NOT BRAND BLUE. Kim 2026-08-06: "on the product image carousel, can you
   make the border white instead of this blue". Right call for two reasons beyond
   taste: the thumbnails sit on a dark band where white is the higher-contrast
   marker of the two, and brand blue on this page means "this is a link" (every
   body link, every row label on hover), so spending it on "this is the picture
   you are looking at" was teaching the visitor two different things with one
   colour. The focus ring below stays blue on purpose, because that IS an
   interaction state and it needs to be distinguishable from the selection. */
.epp-gallery__thumb[aria-current="true"] { border-color: #fff;
  background: rgba(255, 255, 255, .14);
  box-shadow: inset 0 0 0 1px #fff; }
.epp-gallery__thumb:focus-visible { outline: 3px solid var(--epp-blue); outline-offset: 2px; }
/* Narrow screens: 96px thumbs with a 10px gap need 308px for three, and the
   gallery column measures 263px at a 390px viewport, so five thumbs wrapped
   2 + 2 + 1 and read as an accident. 80x50 with an 8px gap needs 256px, so it wraps
   3 + 2 instead, which looks intentional and still clears the 44px tap-target
   floor on both axes. Wrapping rather than scrolling on purpose: all five stay
   visible, and a thumbnail nobody scrolls to is a product view nobody sees. */
@media (max-width: 700px) {
  .epp-gallery__thumbs { gap: 8px; }
  .epp-gallery__thumb { width: 80px; height: 50px; padding: 4px; }
}

/* ======================= GALLERY ZOOM AND LIGHTBOX =======================
   Why this exists at all: a gallery that can only swap pictures asks the buyer
   to take the hardware on trust. Baymard's finding is blunt about it, zoom is
   the digital substitute for picking the product up, and this is a product
   whose detail IS the sell: the port labels, the front monitor, the rack ears.

   Two levels, because they answer two different questions.

     THE LENS answers "what does that say?" without leaving the page. Hovering
     magnifies the area under the cursor in place. Desktop pointers only, see
     the matchMedia gate in the JS: on a touch screen there is no hover to
     magnify with, and the lightbox is the better answer anyway.

     THE LIGHTBOX answers "let me really look at this". Fullscreen, dark, one
     image and nothing else, with the thumb rail still reachable.

   MAGNIFICATION IS CAPPED AT 2.5x, and the number is arithmetic rather than
   taste. The stage paints about 600px wide inside the 47% column, from an
   1800px source, so there are 3x of real pixels to spend. Lens zoom Z shows a
   region of 1/Z of the displayed width using 3/Z source pixels per screen
   pixel, so anything up to 3x is still native resolution and anything past it
   is upscaling that looks soft exactly when somebody is trying to read a label.
   2.5x leaves a margin for a wider column on a big screen.

   The button wrapper, the lens and the lightbox are all built by the JS, not
   authored in the body, so every html-mode product page gets this for free and
   there is no per-page markup to keep in step. A page with JS off keeps the
   plain image it always had. */
/* SCOPED WITH .epp FOR SPECIFICITY, NOT FOR TIDINESS, and the cursor is why.
   Bootstrap ships `[type=button]:not(:disabled), button:not(:disabled)
   { cursor: pointer }`. `:not(:disabled)` counts as a class, so that selector is
   (0,1,1) and a bare `.epp-gallery__zoom` at (0,1,0) LOSES to it: the zoom-in
   cursor written here was never once painted on this page, on any of the reviews
   that signed the magnifier off. Found 2026-08-06 while checking the drag cursor,
   which only worked because it happens to carry three classes. `.epp` in front
   makes this (0,2,0) and it wins on merit, with no !important to unpick later.
   Bootstrap is a theme-level stylesheet, so this is not ELM HUB specific: any
   <button> we style in this layer needs the same treatment. */
.epp .epp-gallery__zoom { display: block; width: 100%; padding: 0; border: 0;
  background: none; cursor: zoom-in;
  /* Without this, a drag across the stage also sweeps a text selection through
     whatever sits beside the gallery, so the visitor ends a gesture with half the
     accordion highlighted blue. */
  user-select: none; -webkit-user-select: none;
  /* VERTICAL SCROLL STAYS THE BROWSER'S. pan-y tells the compositor it may keep
     handling vertical panning itself and hand us only the horizontal, so a finger
     flicking down the page over the gallery scrolls the page at native speed and
     never nudges the picture. It is also why the JS has no axis-detection code:
     the platform does that job, off the main thread, better than a pointermove
     handler can. pinch-zoom is kept because taking a browser zoom away from
     somebody who needs it is an accessibility failure. */
  touch-action: pan-y pinch-zoom; }
/* THE CURSOR IS SPLIT BY STATE, not shared. At rest the picture is a link to the
   full-screen view and the lens is what hovering does, so: zoom-in. Once a press
   has actually travelled, the picture is being carried, so: grabbing. They are
   never both true, so there are never two cursors competing on one surface, which
   is the thing that blocked drag-to-scroll here in the first place. */
.epp-gallery__stage.is-dragging .epp-gallery__zoom { cursor: grabbing; }
/* NO RING ON A MOUSE CLICK. Kim 2026-08-04: "the image has a button with a :focus
   black border ui - can you remove this". The main image is wrapped in a real
   <button> so the keyboard can reach it, and clicking it left that button focused,
   so the browser painted its default focus ring around the whole picture and it
   stayed there after the lightbox closed (focus is deliberately restored here).
   outline:none on :focus alone, paired with a real :focus-visible ring below, is
   the correct form of this: it removes the ring for the pointer, which does not
   need it, and keeps it for the keyboard, which cannot navigate without it. Never
   outline:none on its own. */
.epp-gallery__zoom:focus { outline: none; }
.epp-gallery__zoom:focus-visible { outline: 3px solid var(--epp-blue);
  outline-offset: 2px; }

/* The lens fill is the BAND colour, not white. It has to match whatever shows
   through the render's transparent area, or the magnified empty space around the
   unit reads as a white disc floating on a dark page. It was #fff back when the
   renders were flattened onto white, and that stopped being true on 2026-08-04.
   Kept as a variable with the dark band as its default, so a gallery on a light
   band overrides one value instead of editing this rule. */
.epp-gallery__lens { position: absolute; pointer-events: none; opacity: 0;
  width: 190px; height: 190px; border-radius: 50%;
  background-color: var(--epp-lens-bg, var(--epp-dark)); background-repeat: no-repeat;
  border: 2px solid rgba(255, 255, 255, .92);
  box-shadow: 0 12px 34px -10px rgba(0, 0, 0, .5), 0 0 0 1px rgba(0, 0, 0, .16);
  transition: opacity 120ms linear; }
.epp-gallery__lens.is-on { opacity: 1; }

/* The affordance. A zoom that nobody discovers is a zoom nobody has, so the badge
   says the control is there, and it steps out of the way while in use.

   ONLY ON HOVER, since 2026-08-04. It used to sit there permanently, which was
   fine when the stage was an opaque white panel with a margin to spare: the badge
   had its own corner. With the panel gone it sits directly on the product instead,
   so a permanent badge means permanently covering the thing it is advertising.
   Revealing it with the arrows is also better sequencing: it appears exactly when
   the pointer arrives, which is the moment it becomes true and useful. */
.epp-gallery__hint { position: absolute; right: 14px; bottom: 14px;
  display: inline-flex; align-items: center; gap: 7px;
  padding: 6px 11px; border-radius: 999px;
  background: rgba(20, 20, 22, .74); color: #fff;
  font-family: var(--enttec-font-body); font-size: 12px; letter-spacing: .02em;
  pointer-events: none; opacity: 0;
  transition: opacity var(--eds-dur) var(--eds-ease); }
.epp-gallery__hint svg { width: 13px; height: 13px; flex: none; }
.epp-gallery:hover .epp-gallery__hint,
.epp-gallery:focus-within .epp-gallery__hint { opacity: 1; }
/* IT NO LONGER HIDES WHILE THE LENS IS UP, and that rule was quietly costing us
   the whole badge. It was written when the stage letterboxed the render, so there
   was a band of stage you could hover WITHOUT raising the lens and the badge had
   somewhere to be read. Making the stage the canvas's own 1800/990 ratio removed
   the letterbox: the picture now fills the stage edge to edge, so the lens is up
   the entire time the pointer is over it, so `is-zooming` hid the badge for every
   moment a visitor was actually looking at the picture. The only place it showed
   was the thumbnail strip, which is the one place you would not be about to drag.

   It hides for two things instead, and both are honest:
     WHILE DRAGGING   the gesture is under way, the instruction is spent.
     ONCE DRAGGED     permanently. The visitor has done the thing the badge exists
                      to teach, so it retires rather than nagging. This is the
                      answer to Kim's 2026-08-04 note that a permanent badge sits
                      on top of the product it is advertising: it is now hover-only
                      AND self-extinguishing, so the steady state is no badge. */
.epp-gallery__stage.is-dragging .epp-gallery__hint,
.epp-gallery.has-dragged .epp-gallery__hint { opacity: 0; }

/* 600001, and the number is not superstition: this theme runs a very high
   z-index ladder, and a modal has to clear ALL of it or it is not modal. The
   navbar sits at 111111, the geo-redirect nudge at 500000, and the cookie bar is
   up there too. At a polite 4000 the lightbox opened UNDER the cookie bar, which
   ate the caption and the thumbnail rail for any first-time visitor, and the
   navbar would have painted over it the moment a scroll-up revealed it. */
/* SCROLL LOCK GUTTER: hold the scrollbar open while the lightbox is open, so the
   viewport width never changes and nothing on the page moves sideways.

   THE !important IS LOAD-BEARING. Lenis ships `.lenis.lenis-stopped { overflow:
   hidden }` and lockScroll stops Lenis on open, which puts that class on <html>.
   Two classes beats one class plus an element, so without !important Lenis wins,
   the scrollbar goes and the fixed header and cookie bar widen and slide. Only the
   y axis is forced, so Lenis keeps its overflow-x: hidden.

   The class is added by JS and ONLY where a scrollbar was actually taking up space
   (see lockScroll in product-pages.js). On macOS overlay bars, or a page too short
   to scroll, forcing overflow-y would CREATE a bar and cause the very shift this
   prevents. Mirrors html.ecp-locked in the contact dialog. */
html.epp-locked { overflow-y: scroll !important; }
.epp-lightbox { position: fixed; inset: 0; z-index: 600001; display: none; }
.epp-lightbox.is-open { display: block; }
.epp-lightbox__backdrop { position: absolute; inset: 0;
  background: rgba(10, 10, 12, .95); }
/* The bottom padding reserves room for the cookie consent bar when it is up.
   --epp-lb-bottom is measured and set by the JS at open time, defaulting to 0.
   Deliberately NOT solved by out-stacking the bar: #gdpr-notice sits at
   z-index 1000020 and this overlay stops at 600001 on purpose, because a consent
   control the visitor cannot reach is a compliance problem, whereas a caption
   nudged up by 128px is just layout. */
.epp-lightbox__inner { position: relative; height: 100%;
  display: grid; grid-template-rows: 1fr auto; gap: 14px;
  padding: 58px 20px calc(20px + var(--epp-lb-bottom, 0px));
  box-sizing: border-box; }
.epp-lightbox__figure { position: relative; margin: 0; min-height: 0;
  display: flex; align-items: center; justify-content: center;
  /* Native pinch inside the figure, which is what a phone user reaches for
     first. touch-action also stops a vertical drag scrolling the page behind
     the overlay while somebody is panning a zoomed image. */
  touch-action: pinch-zoom; }
/* No white plate behind it (the renders carry their own transparency now) and NO
   CLICK-TO-ZOOM. Kim 2026-08-04: "the zoom in functionality when the carousel is
   open is not that great", and the arithmetic agrees: the source renders are
   1800px wide and this view already paints them near 1150px, so there were only
   about 1.5x real pixels left and the old scale(2) was upscaling. It went soft
   exactly when somebody was trying to read a label, which is the one job zoom has.
   Removing it also makes "click anywhere to close" a single rule with no
   exception carved out for the picture. Detail work is served by the on-page hover
   lens, which has 3x of real pixels to spend, and by pinch on a phone. */
.epp-lightbox__img { max-width: 100%; max-height: 100%; object-fit: contain; }
/* margin-bottom, because the caption and the thumbnail rail share one grid row
   and the container's row-gap therefore does not run between them: the caption
   was sitting directly on top of the thumbnails with nothing separating a
   sentence from a row of pictures. */
.epp-lightbox__caption { color: #cfcfd2; font-family: var(--enttec-font-body);
  font-size: 14px; line-height: 1.45; text-align: center; max-width: 78ch;
  margin: 0 auto 18px; }
.epp-lightbox__count { position: absolute; top: 18px; left: 22px;
  color: #cfcfd2; font-size: 13px; letter-spacing: .06em;
  text-transform: uppercase; }
/* THE GLYPH IS AN SVG, WHICH IS WHY THIS IS CENTRED. Kim 2026-08-04, on the close
   button: "are you sure this is centered?" It was not, and flexbox was not the
   problem. These were text glyphs (U+2715 for close, U+2039/203A for the chevrons)
   and a glyph is centred on its own TYPOGRAPHIC box, not on its ink: U+2715 carries
   uneven side bearings and sits above the baseline centre, so it lands high and
   slightly left inside a 46px circle no matter how the box is aligned. The JS now
   draws all three as inline SVG with a symmetric viewBox, so the ink itself is
   centred and there is no font metric involved. */
.epp-lightbox__btn { position: absolute; display: inline-flex;
  align-items: center; justify-content: center;
  width: 46px; height: 46px; padding: 0; border-radius: 50%; border: 0;
  cursor: pointer; background: rgba(255, 255, 255, .12); color: #fff;
  transition: background var(--eds-dur) var(--eds-ease); }
.epp-lightbox__btn svg { display: block; width: 18px; height: 18px; }
.epp-lightbox__btn:hover { background: rgba(255, 255, 255, .24); }
.epp-lightbox__btn:focus-visible { outline: 3px solid var(--epp-blue);
  outline-offset: 2px; }
.epp-lightbox__btn--close { top: 12px; right: 16px; }
/* The chevrons are quieter than the close button for the same reason as the
   on-page arrows: they sit over the picture, and the picture is the point. */
.epp-lightbox__btn--prev, .epp-lightbox__btn--next { top: 50%;
  transform: translateY(-50%); background: none; opacity: .55;
  transition: opacity var(--eds-dur) var(--eds-ease); }
.epp-lightbox__btn--prev:hover, .epp-lightbox__btn--next:hover { background: none; opacity: 1; }
.epp-lightbox__btn--prev:focus-visible, .epp-lightbox__btn--next:focus-visible { opacity: 1; }
.epp-lightbox__btn--prev { left: 16px; }
.epp-lightbox__btn--next { right: 16px; }
.epp-lightbox__thumbs { display: flex; justify-content: center; flex-wrap: wrap;
  gap: 10px; margin: 0; padding: 0; list-style: none; }
/* Same fix as the on-page strip: one tile colour in every state, state carried by
   the border, no opacity dimming that would grey the tile along with the picture. */
.epp-lightbox__thumb { box-sizing: border-box; width: 84px; height: 54px;
  padding: 4px; border: 1px solid rgba(255, 255, 255, .18); border-radius: var(--eds-radius-sm);
  background: rgba(255, 255, 255, .08); cursor: pointer;
  transition: background var(--eds-dur) var(--eds-ease), border-color var(--eds-dur) var(--eds-ease); }
.epp-lightbox__thumb img { display: block; width: 100%; height: 100%; object-fit: contain; }
.epp-lightbox__thumb:hover { background: rgba(255, 255, 255, .16);
  border-color: rgba(255, 255, 255, .45); }
.epp-lightbox__thumb[aria-current="true"] { border-color: var(--epp-blue);
  background: rgba(255, 255, 255, .16); box-shadow: inset 0 0 0 1px var(--epp-blue); }
.epp-lightbox__thumb:focus-visible { outline: 3px solid var(--epp-blue); outline-offset: 2px; }
/* The page must not scroll behind an open overlay. */
body.epp-lightbox-open { overflow: hidden; }

@media (max-width: 640px) {
  .epp-lightbox__inner { padding: 52px 10px 14px; }
  .epp-lightbox__btn--prev, .epp-lightbox__btn--next { display: none; }
  .epp-lightbox__caption { font-size: 13px; }
}
.epp p.epp-figcaption { margin: 12px 2px 0; font-size: 13.5px; line-height: 1.45;
  color: var(--epp-soft-ink); }

/* ============================== CONNECTIVITY ============================== */
.epp-figures { display: flex; flex-direction: column; gap: var(--eds-space-lg); }
/* Narrower than the page measure, and centred. Two reasons, both from Kim
   2026-07-31: the images were "showing too big", and then "showing in low
   quality".
   The two are the same problem. These crops come out of the datasheet at 300 DPI
   and that is the ceiling (its pages are flattened rasters, so there is no
   higher-resolution version to extract). At the full 1500px container the panel
   callouts rendered at 0.63 of their natural size, close enough to 1:1 that every
   soft edge showed, and the callout labels baked into them came out LARGER than
   the body copy beside them, which inverts the page's type hierarchy. Halving the
   display width fixes both at once: roughly 0.3 scale hides the softness and puts
   the callout text back at caption size.
   Capping the container rather than the images keeps captions on the same measure
   as the drawing above them. */
.epp-figures--narrow { max-width: 960px; margin-inline: auto; }
.epp-figure { margin: 0; }
.epp-figure img { display: block; width: 100%; height: auto; border-radius: var(--eds-radius); }
.epp-figure figcaption { margin-top: var(--eds-space-xs); font-size: 15px; line-height: 1.5;
  color: var(--epp-body-ink); max-width: 80ch; }
/* ONE DIAGRAM NOW, so the pair grid and the drawing variant are gone with it
   (Kim 2026-08-12). Capped at its natural 1080px and centred: the labels are
   baked into the raster at about 11px, so stretching it to the 960px container
   is fine but going past its own width is what made the old crops look soft.

   The white plate stays. The diagram is drawn on white with a transparent-ish
   JPEG background, and the band behind it is #f2f2f2, so without this the image
   sits in a faintly grey box with a visible edge. */
.epp-figure--solo { max-width: 1080px; margin-inline: auto; }
.epp-figure--solo img { background: #ffffff; }

/* ============================== SPECIFICATIONS ==============================
   The project pages' .proj-specs treatment: uppercase key column, hairline
   rows, no outer frame, and the same collapse-with-a-fade expander. 25 rows is
   past what anyone reads in one go, and the previous draft printed all of them
   as a wall. Collapsed it shows the rows that decide a purchase; the rest is
   one click away. */

.epp-specs { width: 100%; border-collapse: collapse; }
.epp-specs th[scope="row"] { width: 260px; }
.epp-specs th, .epp-specs td { text-align: left; vertical-align: top; padding: 10px 14px;
  font-size: 16px; line-height: 1.5; }
/* 500, not 600. The label column is already separated from the values by being
   uppercase, letter-spaced, smaller and in the soft ink; semibold on top of all
   four made the keys shout over the answers people are actually reading. Not 400
   either: at 13px uppercase with 0.06em tracking, regular goes spindly. */
.epp-specs th { width: 30%; font-family: var(--enttec-font-body); font-weight: 500;
  color: var(--epp-soft-ink); text-transform: uppercase; letter-spacing: 0.06em;
  font-size: 13px; padding-right: 18px; }
.epp-specs td { color: var(--epp-ink); }

/* GROUP HEADINGS. Baymard's product-page study: a spec sheet over about 20 rows
   needs its specs grouped into labelled subsections, and 38% of sites fail on
   exactly this. At 36 rows a flat list is the "wall of specs" that finding
   describes, and grouping is the single change that does most of the work here. */
.epp-specs__group th { font-family: var(--enttec-font-display); font-weight: 600;
  font-size: 15px; letter-spacing: 0; text-transform: none; color: var(--epp-ink);
  width: auto; padding: var(--eds-space-md) 14px 10px;
  border-bottom: 1px solid rgba(255, 255, 255, .14); }
.epp-specs tbody:first-child .epp-specs__group th { padding-top: 0; }

/* ALTERNATING ROW TINTS, the same study's fourth finding: a band behind the pair
   is what stops the eye losing the line between a label on the left and its
   value 900px away on the right. nth-of-type(even) counts within each tbody, so
   the banding restarts per group and never depends on how many rows came before. */
/* No zebra striping. On the dark band the alternating fill read as noise and made
   a long sheet feel longer; a 1px rule separates rows just as well and lets the
   hover state be the only fill on the table, which is what makes hover legible. */
.epp-specs tbody tr:not(.epp-specs__group) + tr:not(.epp-specs__group) th,
.epp-specs tbody tr:not(.epp-specs__group) + tr:not(.epp-specs__group) td {
  border-top: 1px solid rgba(255, 255, 255, .08); }
.epp-specs tbody tr:not(.epp-specs__group) { transition: background var(--eds-dur-fast) var(--eds-ease); }
/* Neutral, not blue. A 10% brand-blue wash over the dark grey band went muddy and
   read as a selection rather than a hover (Kim: "the blue on the grey makes it
   look bad"). White at 4% reads as a hover on any band colour, and blue is kept
   for the things that are actually interactive: focus rings and links. */
.epp-specs tbody tr:not(.epp-specs__group):hover { background: rgba(255, 255, 255, .04); }

/* MOBILE SPEC LAYOUT: label above value, one row per spec, no columns.

   Two columns was tried and dropped (Kim, 2026-08-25: "hybrid isnt working and
   its hard to read"). It measured well and read badly, which is worth recording
   because the measurements were not wrong, they were the wrong thing to measure.
   Two columns for everything is TALLER than stacking on this content (3744px
   against 3614px at 390px), because 13 of the 39 values are sentences and wrap
   at about 23 characters a line in a ~190px column. Dropping only those to full
   width fixed the height and the measure, but left two different row shapes
   alternating unpredictably down the page, so the left edge went ragged and the
   alternation read as breakage rather than as design.

   One shape everywhere is the answer. A spec is a label and its value, and on a
   phone they belong on top of each other, which is also what current responsive
   guidance says for narrow viewports.

   THE BREAKPOINT IS 900, MATCHING THE ACCORDION. It was 620, which left 621 to
   900 showing the phone accordion on top of the desktop table: a 48px full-bleed
   tap target sitting over a 260px fixed label column. Measured at 660px that
   label was 46% of the table with 216px of it empty. Two controls disagreeing
   about how wide the viewport is, and the seam was visible. */
@media (max-width: 900px) {   /* 900 = the accordion's own breakpoint */
  /* THE ROW CARRIES THE RULE, NOT THE CELLS. The design system sets
     border-bottom on .eds-specs th AND td (enttec-design-system.css). That is
     correct while they sit side by side, because border-collapse merges the two
     into a single line across the row. display:block stacks them, and the one
     line silently becomes two: a short rule under the label and a full-width one
     under the value. */
  .epp-specs tbody tr:not(.epp-specs__group) {
    display: block; padding: 10px 14px;
    border-bottom: 1px solid var(--epp-rule); }
  .epp-section--dark .epp-specs tbody tr:not(.epp-specs__group) {
    border-bottom-color: rgba(255, 255, 255, .09); }
  /* th[scope="row"] is named here deliberately. .epp-specs th[scope="row"] holds
     width:260px for the desktop label column, and (0,2,1) outranks a plain
     .epp-specs th (0,1,1). A media query adds NO specificity of its own, so
     without matching that selector the stacked label kept its 260px desktop
     width and its rule stopped 75px short of the value's. */
  .epp-specs th[scope="row"], .epp-specs td {
    display: block; width: auto; padding: 0; border: 0; }
  .epp-specs th[scope="row"] { padding-bottom: 2px; }
  /* AND THE ROW SEPARATOR THE CELLS CARRY, which `border: 0` above does not
     reach. The desktop separator is a border-TOP on both cells of every row
     after the first in a group, and its selector scores (0,3,4) against that
     rule's (0,2,1), so it wins on specificity no matter that it is declared
     earlier. Side by side it draws one line across the row and looks right;
     stacked as blocks it draws one above the label and another above the value,
     which splits every label from its own value. Measured on ELM HUB at 390px:
     105 lines down the table where there should be 39.

     Repeating the selector verbatim rather than out-specifying it: same weight,
     declared later, so it simply wins, and the intent stays legible as "the
     desktop row separator is off here because the row carries its own". */
  .epp-specs tbody tr:not(.epp-specs__group) + tr:not(.epp-specs__group) th,
  .epp-specs tbody tr:not(.epp-specs__group) + tr:not(.epp-specs__group) td {
    border-top: 0; }
  .epp-specs__group th { display: block; }
}

/* COLLAPSE. Hides whole GROUPS, never a pixel height, so the cut always lands on
   a labelled boundary and reads as deliberate rather than as a table that ran out
   of room. It also sidesteps the trap the previous version hit: .is-collapsed
   used to carry a max-height TRANSITION, which made a clientHeight read taken
   straight after collapsing return the pre-transition height, so the script
   decided the table was already short enough and disabled its own control. There
   is nothing to measure now. */
/* THE CAP, replacing the old "hide four whole groups" collapse.
   Kim 2026-08-11, from _kim/mockups/elm-hub/specs-side-nav.html.

   Height, not group visibility. Hiding groups meant the reader could not tell
   there was more of the same KIND of content below, only that four headings
   had vanished; a fade says "this continues" in a way a hard cut cannot. It
   also means the cut no longer has to land on a group boundary, so the
   section can be as short as it needs to be.

   360px is roughly the first group plus the start of the second: enough to
   prove what the content is and no more. The specification is reference
   material, so the people who want it open it, and everyone else scrolls past
   the whole section in one flick. */
.epp-specs-panel.is-collapsed .epp-specs__body { max-height: 360px; overflow: hidden; }
/* Ends in the band colour exactly, so it reads as the page dissolving rather
   than a grey box laid over it. 130px against a 360px cap: at the 220px I
   first tried, the thing meant to hint at more content was hiding it. */
.epp-specs__fade { display: none; position: absolute; left: 0; right: 0; bottom: 0;
  height: 130px; pointer-events: none;
  background: linear-gradient(to bottom, rgba(51,51,51,0) 0%, rgba(51,51,51,.72) 52%,
    var(--epp-dark) 100%); }
.epp-specs-panel.is-collapsed .epp-specs__fade { display: block; }
/* The fade anchors to the CAPPED BODY, not to the panel. Anchored to the panel
   it covered the "Show all specifications" button too, which sits below the
   body, so the control the fade exists to advertise was the one thing it
   greyed out. */
.epp-specs__body { position: relative; }
@media (max-width: 900px) {
  .epp-specs-panel.is-collapsed .epp-specs__body { max-height: 300px; }
}

/* No top padding. Kim 2026-08-11: "too much spacing" on Features.

   The cause is two dark bands meeting: Overview ends with a full
   --eds-section-y and Features opens with another, and because they are the
   same colour there is no edge to justify the doubled gap, so it reads as a
   hole rather than as separation. Dropping one side closes it without
   touching the section-spacing rhythm anywhere else on the site.

   A modifier rather than an #features rule, so the next section that stacks
   on a same-coloured band can reuse it instead of growing another id
   selector. */
.epp-section--flush-top { padding-top: 0; }

/* MORE BREATHING ROOM AT THE EDGES. Kim 2026-08-11: Specifications and
   Resources sat too close to the sides.

   The design system's container is 1760px wide with only clamp(16px,4vw,40px)
   of side padding, so on a 1440 screen the content stops about 40px from the
   glass. That is fine for a full-bleed gallery or a photograph, which want the
   width, and wrong for these two: both are LEFT-ANCHORED reading columns (the
   index, then the table; the icon, then the label), so their first character
   is the thing pinned to the edge, and there is nothing between it and the
   browser chrome.

   Doubling the side padding rather than narrowing the container, so the table
   keeps its measure and only the gutters change. */
/* 10vw / 143px, up from 6vw / 88px. Kim 2026-08-11: "we need to match the
   standalone html width so that on this page it's also a bit more compact".
   The approved mockup runs its content at 1060px; this band was running at
   1172px on a 1440 viewport, so it read looser than the design she signed off.
   At 1440 the new clamp lands on 143px a side, which puts the content at
   1059px.

   MOVED ON THE MODIFIER, not on the software section alone, so Specifications
   and Resources come with it. Kim asked earlier for these bands to line up with
   each other; narrowing one of the three would have bought compactness by
   breaking that. The floor stays 24px so phones are unaffected. */
.epp-section--inset > .eds-container { padding-inline: clamp(24px, 10vw, 143px); }

/* THE ELM SECTION GOES NARROWER STILL. Kim 2026-08-11: "didnt i ask if you
   could compress it more so that the width is less for the elm section?" She
   did, and moving the shared modifier only brought it level with its
   neighbours.

   Targeted by the COMBINATION of --tight and --inset, which only the software
   section carries, so no new class and no markup change: Specifications and
   Resources keep the 1059px they just got.

   200px a side puts the content at 945px on a 1440 viewport. That figure is not
   arbitrary. This band is two columns and the right one is a picture capped at
   520px, so every pixel taken off the section comes off the COPY column: at
   1059 the explainer was running to about 437px of measure, and this brings it
   in to a comfortable line length for a paragraph somebody is reading rather
   than scanning. Much below 900 total and the picture stops holding its
   proportion against the text.

   170px is Kim's number, 2026-08-11, landing the content at 1005px on a 1440
   viewport. An earlier pass had 200px / 945px, which she found a step too far.

   GATED AT 1000px, which is where this band is still two columns. 14vw keeps
   growing on small screens: at 390px it took 55px a side against the other
   bands' 39px, leaving 226px of content where they had 257 and squeezing the
   explainer to about 30 characters a line. Extra compression is only worth
   having when there is width to give away, and below 1000px there is not, so
   the section falls back to the shared --inset padding and lines up with
   everything else. */
@media (min-width: 1000px) {
  .epp-section--tight.epp-section--inset > .eds-container {
    padding-inline: clamp(24px, 14vw, 170px); }
}

/* The design system draws spec rows with --enttec-line, which is
   rgba(0,0,0,.08): a black hairline meant for a white band. On this dark
   section it computes to a line you cannot see, so the table has been running
   without visible row separation. Repaint it with the dark-band rule token the
   rest of this section already uses.

   Scoped to .epp-section--dark, not a blanket override of .eds-specs: the same
   component on a light band still wants the black line, and this is the ONLY
   thing wrong with it. (0,2,1) against the system's (0,1,1), so it wins on
   class count without !important.

   9%, NOT the --epp-rule 16% the section uses elsewhere. Kim 2026-08-11: at 16%
   it was too bright. The reason is repetition rather than the value being wrong
   in itself: --epp-rule is drawn once per section, where 36 of them stacked
   read as a grid of lines competing with the text they are meant to separate.
   A row separator has to be the quietest line on the band. Against #333 this
   lands near #474747, which is visible as structure and invisible as ink. */
.epp-section--dark .epp-specs th,
.epp-section--dark .epp-specs td { border-bottom-color: rgba(255, 255, 255, .09); }

/* ==================== SPECIFICATION INDEX ====================
   Sits in the 24% head column the heading already owns, so the side navigation
   costs no new grid and no extra width.

   Blue is the MARKER and nothing else: no fill behind the active item, no
   tinted panel. The first version filled the active row with blue and striped
   the table, which invented two colours this page does not have.

   The highlight hangs off aria-current rather than a second class, so the
   visible state and the state a screen reader announces cannot disagree. */
/* DELIBERATELY QUIETER THAN THE TABLE. Kim 2026-08-11: the right column is the
   content and this is only the map to it, so the rail is a step down in size
   and in contrast: 13.5px against the table's 15.5px, soft ink until it is the
   current group, and a 12px count. It should be findable, not competitive.

   12px is the floor on everything here. Below that these become decoration a
   reader has to lean in for, and the count is the piece that has to stay
   legible for the rail to be worth having. */
/* Indented off the container edge. Kim 2026-08-11: it sat right in the corner
   of the page. The rail is the only thing in that column, so with nothing above
   or beside it there was no left margin anywhere for the eye to read it
   against. The heading two lines up starts at the same edge, so the indent also
   stops the index reading as a continuation of the word Specifications. */
/* STICKY NEEDS ROOM TO TRAVEL. Kim 2026-08-11 asked for the index to stay
   visible while scrolling; position:sticky was already on it and did nothing,
   because .epp-split sets align-items:start, so the head column is exactly as
   tall as the index and a sticky element cannot move inside a box its own
   size. Stretching the column to the row's full height is what gives it the
   distance to stick through.

   Only for a split that opts in: every other .epp-split__head holds a heading
   that SHOULD sit at the top of its row rather than stretch. */
.epp-split--sticky-head .epp-split__head { align-self: stretch; }

/* PINNED BELOW THE SUB-NAV, not at a flat 24px. The bar is itself sticky at
   `top: var(--eds-chrome-now)`, so a fixed offset put the index underneath it
   and the first entries disappeared behind the bar as you scrolled.

   The calc is the design system's own stack, the same one
   [data-eds-subnav-target] uses for anchor jumps: the bar's height, plus
   whatever the header-stack coordinator currently reports as site chrome, plus
   a gap. --eds-chrome-now is live, so the index follows the header as it hides
   and reappears rather than guessing a worst case. Reusing the variables also
   means the index cannot drift out of step if the bar's height changes. */
.epp-specidx { position: sticky;
  top: calc(var(--eds-subnav-h, 60px) + var(--eds-chrome-now, 0px) + 24px);
  margin-top: var(--eds-space-sm);
  min-width: 0; padding-left: clamp(10px, 1.2vw, 18px); }
.epp-specidx__title { font-size: 12px; letter-spacing: .14em; text-transform: uppercase;
  color: var(--epp-soft-ink); margin: 0 0 8px; }
.epp-specidx ul { list-style: none; margin: 0; padding: 0; }
/* Block, not a flex row with space-between. The row used to hold a count
   pushed to the far edge, which left a long empty gap mid-line between a
   two-word label and a bare digit. The count is gone (it needed a legend to
   mean anything) and with it the reason for the row to be a flex container. */
.epp .epp-specidx a { display: block; padding: 6px 0 6px 12px; text-decoration: none;
  color: var(--epp-soft-ink); font-size: 13.5px; line-height: 1.3;
  border-left: 2px solid var(--epp-rule);
  transition: color .2s ease, border-color .2s ease; }
.epp .epp-specidx a:hover { color: var(--epp-ink); border-left-color: var(--epp-soft-ink); }
.epp .epp-specidx a[aria-current="true"] { color: var(--epp-ink); border-left-color: var(--epp-blue); }
/* On a phone the rail cannot survive, so it becomes a scrollable chip row.
   min-width:0 is load-bearing: a grid item defaults to min-width:auto and the
   row would push the document wider than the screen instead of scrolling. */
@media (max-width: 900px) {
  /* The chip row runs the full measure on a phone and has to line up with the
     table under it, so the desktop indent comes back off here. */
  .epp-specidx { position: static; margin-top: var(--eds-space-sm); padding-left: 0; }
  .epp-specidx__title { display: none; }
  .epp-specidx ul { display: flex; gap: 18px; overflow-x: auto; scrollbar-width: none; }
  .epp-specidx ul::-webkit-scrollbar { display: none; }
  .epp .epp-specidx a { white-space: nowrap; border-left: 0; padding: 6px 0;
    border-bottom: 2px solid var(--epp-rule); }
  .epp .epp-specidx a[aria-current="true"] { border-bottom-color: var(--epp-blue); }
}
/* ============ MOBILE SPEC ACCORDION (prototype, 2026-08-24) ============
   Replaces the chip index below 900px. See the long note beside the accordion
   module in product-pages.js for why the chip row is the wrong pattern at this
   width; in short, it showed 2 of 6 links and hid the rest behind a suppressed
   scrollbar. The six group headings become the index instead.

   Everything here is inside the max-width:900px query, so desktop keeps the
   sticky rail, which is the right pattern where there is room for it. */
@media (max-width: 900px) {

  /* THE GUTTER. clamp(24px, 10vw, 143px) is a desktop-shaped inset: its 24px
     floor does not engage until a 240px viewport, so at 390px it still took
     39px a side, on top of the 20px --eds-container already provides. That left
     257px of content on a 390px screen (34% of the display was gutter) and
     forced 18 of 39 spec values onto two lines. Dropping the second inset here
     returns 78px, which is a 30% wider measure for the table. */
  .epp-section--inset > .eds-container { padding-inline: 0; }

  /* The chip rail goes: the six group headings ARE the index here, all visible
     at once, so a separate rail of chips says the same thing twice. */
  .epp-acc .epp-specidx { display: none; }

  /* THE SHOWMORE STAYS (Kim, 2026-09-03: "we lost the Show all specifications
     button on mobile view"). It used to be hidden here on the reasoning that two
     collapse mechanisms on one table is worse than either alone, which is still
     true, and this does not reintroduce a second one: the height clip it drove on
     desktop is switched off in accordion mode (see the .is-collapsed rule just
     below), so the button now drives the ONE mechanism that is live, opening and
     closing every group at once. The branch is in its click handler in
     product-pages.js.

     It is also the only way to see the whole table on a phone without six taps,
     and the only way to make the browser's own find-in-page work, since a closed
     group's rows are display:none. Full width, because at this size it is a
     primary action rather than a footnote under a table. */
  .epp-acc .epp-showmore { display: flex; justify-content: center; width: 100%;
    min-height: 48px; margin-top: 16px; }

  /* The height clip exists to shorten a long table. The accordion already does
     that, and leaving both on would cut the closed headings off mid-list. */
  .epp-specs-panel.epp-acc.is-collapsed .epp-specs__body { max-height: none; overflow: visible; }
  .epp-acc .epp-specs__fade { display: none; }

  /* The collapse itself. Group heading rows always stay. */
  .epp-acc tbody[data-epp-spec-group]:not([data-epp-group-open]) tr:not(.epp-specs__group) { display: none; }

  /* THE CONTROL. Full-bleed row, 48px min height: the chips it replaces were
     32px, which clears the WCAG 2.5.8 minimum of 24px but sits under Apple's
     44px guideline. A whole row is also a much easier thumb target than a word. */
  .epp-acc .epp-specs__group th { padding: 0; display: block; }
  .epp-acc .epp-specs__grouptoggle {
    display: flex; align-items: center; justify-content: space-between; gap: 12px;
    width: 100%; min-height: 48px; padding: 12px 2px;
    background: none; border: 0; border-bottom: 1px solid var(--epp-rule);
    color: inherit; font: inherit; text-align: left; cursor: pointer;
    transition: color var(--eds-dur-fast) var(--eds-ease); }
  .epp-acc .epp-specs__grouptoggle:hover { color: var(--epp-blue); }
  .epp-acc .epp-specs__grouptoggle:focus { outline: none; }
  .epp-acc .epp-specs__grouptoggle:focus-visible { outline: 2px solid var(--epp-blue); outline-offset: 2px; }
  .epp-acc .epp-specs__chev { width: 12px; height: 12px; flex: 0 0 auto;
    transition: transform var(--eds-dur) var(--eds-ease); }
  .epp-acc .epp-specs__grouptoggle[aria-expanded="true"] .epp-specs__chev { transform: rotate(180deg); }
  /* An open group needs a visible edge against the next closed heading. */
  .epp-acc tbody[data-epp-spec-group][data-epp-group-open] .epp-specs__grouptoggle { color: var(--epp-ink); }
}

/* The chevron is the only motion here and it has a non-motion equivalent
   already (aria-expanded, plus the rows themselves appearing), so reduced
   motion simply stops it turning rather than needing a substitute. */
@media (prefers-reduced-motion: reduce) {
  .epp-acc .epp-specs__chev { transition: none; }
  .epp-acc .epp-specs__grouptoggle { transition: none; }
}

/* While a search is running every group is in play, so the collapse is off and
   the control is out of the way. */
.epp-specs-panel.is-searching .epp-showmore { display: none; }

/* SEARCH. Hidden until the script un-hides it: with no JS it would be a text box
   that does nothing, which is worse than no text box. */
/* TOP RIGHT of the content column, on one line. Kim, 2026-08-04: the previous
   version "wasn't really utilizing the space well", and it was a label line, a
   full-width field and a count line stacked above the table, about 90px of height
   for one input. Now the field and the count share a row and the whole control
   right-aligns over the table, which is the section's top-right corner because
   this column starts level with the heading.

   display:none until the script adds .is-ready. The gate used to key off
   .epp-specs-panel.epp-js-ready, which broke the moment this moved out of the
   panel, so the class now lands on the control itself. */
.epp-specs-search { display: none; margin-bottom: var(--eds-space-sm); }
.epp-specs-search.is-ready { display: flex; align-items: center;
  justify-content: flex-end; gap: var(--eds-space-sm); flex-wrap: wrap; }
.epp-specs-search__field { flex: 0 1 300px; }
.epp .epp-specs-search__input { max-width: none; }
.epp p.epp-specs-search__count { margin: 0; white-space: nowrap; order: -1; }
@media (max-width: 640px) {
  .epp-specs-search.is-ready { justify-content: flex-start; }
  .epp-specs-search__field { flex: 1 1 100%; }
  /* THE STATUS LINE GOES BELOW THE FIELD HERE (Kim, 2026-09-03: "why does this
     'No matches for asd' helper text move the input field lower?").

     Because it did. The count is display:none at rest and the script un-hides it
     only while a search is running (see filter() in product-pages.js). It carries
     order:-1 so that on a wide viewport it shares one row with the field, sitting
     to its left, where appearing costs no height. Below 640 the field becomes
     flex: 1 1 100%, so it claims a whole row on its own and the count wraps ABOVE
     it: the moment it gained text it gained 20px and shoved the input down 43px,
     under the reader's own finger, mid-typing.

     Dropping the order puts it after the field in DOM order, which is also the
     right place for it on its own merits: a status message about a control
     belongs under the control, and the thing that moves is then the table below,
     which is what the search is changing anyway.

     Desktop keeps order:-1 deliberately. There it is a one-line row and moving
     the count under the field would cost a line of height for no gain. */
  .epp p.epp-specs-search__count { order: 0; }
}
/* The visible label is redundant: the placeholder already says "Search
   specifications, for example network or power", so the label above it repeated
   the same words and cost a line of height. Kept in the DOM and readable by a
   screen reader, because an input with only a placeholder for a name is not
   labelled at all once you start typing.

   NOT hoisted next to the heading, which an earlier attempt here tried: the
   search lives inside .epp-specs-panel and the heading is in a sibling, so
   position:absolute would have resolved against the wrong ancestor and parked it
   somewhere arbitrary. Above the table is where it belongs anyway. */
.epp-specs-search__label { position: absolute; width: 1px; height: 1px;
  margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%);
  white-space: nowrap; border: 0; }
/* Visible label, not a placeholder doing double duty: a placeholder disappears
   the moment someone types, and this control needs to stay self-explanatory
   while it is in use. Small and quiet, so it does not compete with the h2. */
.epp-specs-search__label { display: block; font-size: 13px; font-weight: 600;
  letter-spacing: .06em; text-transform: uppercase; color: var(--epp-soft-ink);
  margin-bottom: 8px; }
/* Scoped under .epp to reach (0,2,0). The theme styles form fields with a long
   list of attribute selectors, `input[type="color"], input[type="date"], ...
   input[type="search"] { background: #fff; color: #2c3338 }`, and each of those
   is (0,1,1), which outranks a single class. Left at one class this field
   rendered as a white box with near-black text on the charcoal band: exactly the
   "white background on the grey background" Kim flagged. Raising our own
   specificity is the fix rather than an !important, and the declarations stay
   correct once that bundle is gone. */
/* SMALLER. Kim 2026-08-11: too big next to the rest of the section. It was
   460px wide with 11px/14px padding at body size, which made it the largest
   single control on a band whose content is 12px labels and a quiet index. A
   search box is a tool, not a heading: 300px is still a comfortable target and
   it stops competing with the specification table it filters. */
.epp .epp-specs-search__input { width: 100%; max-width: 300px; box-sizing: border-box;
  font-family: var(--enttec-font-body); line-height: 1.4;
  /* 16px is not a style choice: anything smaller makes iOS Safari zoom the whole
     page when the field takes focus, and the reader loses their place.

     THIS RULE USED TO DECLARE IT TWICE, 16px here and 14.5px three declarations
     later, so the later one won and the field shipped at 14.5px: the comment
     above described an intent the CSS then cancelled. Measured on ELM HUB at
     390px before the fix. One declaration now, and it is this one. */
  font-size: 16px;
  padding: 9px 12px; color: var(--epp-ink);
  background: var(--epp-panel); border: 1px solid var(--epp-rule);
  border-radius: var(--eds-radius-sm); -webkit-appearance: none; appearance: none;
  transition: border-color var(--eds-dur-fast) var(--eds-ease), background var(--eds-dur-fast) var(--eds-ease); }
.epp .epp-specs-search__input::placeholder { color: var(--epp-soft-ink); opacity: 1; }
.epp .epp-specs-search__input:hover { border-color: var(--epp-soft-ink); }
/* ONE BLUE AT A TIME. Kim 2026-08-11: the field read as "double-blue". It was
   getting a blue border from :focus AND a blue outline ring from
   :focus-visible, so a keyboard user saw two concentric blue lines with a 2px
   gap between them.

   The ring wins, because it is the accessible signal and it is the one the
   rest of the page uses. Focus itself now shows only the lifted background and
   a slightly stronger border, which is enough to say "you are typing here" for
   a mouse user, and the border returns to its resting colour when the ring
   appears so the two never stack. */
.epp .epp-specs-search__input:focus { outline: none; border-color: var(--epp-soft-ink);
  background: rgba(255,255,255,.09); }
.epp .epp-specs-search__input:focus-visible { outline: 2px solid var(--epp-blue);
  outline-offset: 2px; border-color: var(--epp-rule); }
/* Chrome draws its own clear button on a search input and colours it from the
   UA sheet, so on a dark field it lands as a dark X on a dark background.
   Hidden, because Esc already clears the field and the count line says what is
   filtered. */
.epp .epp-specs-search__input::-webkit-search-cancel-button { -webkit-appearance: none; appearance: none; }
.epp-specs-search__field { position: relative; max-width: 460px; }
.epp-specs-search__clear { position: absolute; right: 6px; top: 50%;
  transform: translateY(-50%); width: 30px; height: 30px; border: 0;
  border-radius: 50%; background: rgba(255, 255, 255, .10); color: #fff;
  font-size: 13px; line-height: 1; cursor: pointer;
  transition: background var(--eds-dur-fast) var(--eds-ease); }
.epp-specs-search__clear:hover { background: rgba(255, 255, 255, .22); }
.epp-specs-search__clear:focus-visible { outline: 2px solid var(--epp-blue); outline-offset: 2px; }
.epp-specs-search__clear[hidden] { display: none; }
/* Matched text, so a search result shows WHY it matched rather than leaving the
   reader to find the word themselves in a 3-line value. */
.epp-specs mark { background: rgba(255, 255, 255, .14); color: inherit;
  border-radius: 2px; padding: 0 2px;
  box-shadow: inset 0 -2px 0 var(--epp-blue); }
.epp p.epp-specs-search__count { font-size: 13.5px; color: var(--epp-soft-ink);
  margin: 8px 0 0; min-height: 1.3em; }
.epp p.epp-specs-empty { margin: var(--eds-space-md) 0 0; color: var(--epp-body-ink); font-size: 16px; }
.epp-specs-empty[hidden] { display: none; }

.epp-showmore { margin-top: 18px; display: inline-flex; align-items: center; gap: 8px;
  background: none; border: 1px solid var(--epp-rule); border-radius: var(--enttec-radius-pill);
  color: var(--epp-ink); font-family: var(--enttec-font-body); font-size: 14px;
  padding: 9px 20px; cursor: pointer;
  transition: border-color var(--eds-dur) var(--eds-ease), transform var(--eds-dur) var(--eds-ease); }
.epp-showmore:hover { border-color: var(--epp-blue); transform: translateY(-2px); }
/* [hidden] has to be restated because the display:inline-flex above outranks the
   UA's own [hidden]{display:none}. The button ships hidden and the script
   un-hides it, so without this line the control is visible before it works. */
.epp-showmore[hidden] { display: none; }
.epp-showmore:focus { outline: none; }
.epp-showmore:focus-visible { outline: 2px solid var(--epp-blue); outline-offset: 3px; }
.epp-showmore svg { width: 13px; height: 13px; transition: transform var(--eds-dur) var(--eds-ease); }
.epp-showmore[aria-expanded="true"] svg { transform: rotate(180deg); }

/* Compliance marks, under the specification table where they belong (they are a
   specification, not a feature). Four marks, which is every one the site has an
   image for; WEEE has never had one and is stated in the text line instead. */
.epp-compliance { list-style: none; margin: var(--eds-space-md) 0 0; padding: 0;
  display: flex; flex-wrap: wrap; gap: var(--eds-space-sm); align-items: center; }
.epp-compliance li { margin: 0; }
.epp-compliance img { height: 42px; width: auto; }
.epp p.epp-compliance-note { margin: var(--eds-space-xs) 0 0; font-size: 15px;
  line-height: 1.5; color: var(--epp-soft-ink); }

/* ============================== RESOURCES ==============================
   Rows inside the live product page's pane accordion (Data Sheet /
   Documentation / Images), with the first pane open so the data sheet, which is
   what most people come here for, needs no click at all.

   Firmware is deliberately absent: ELM HUB runs Windows and ELM, it has no
   device firmware, and an empty Firmware pane would imply one exists.

   The ROWS themselves keep the polished treatment (hairline rules, hover nudge,
   arrow, a sub-label saying whether the row downloads or navigates). That is the
   split this whole sheet is built on after Kim's 2026-07-30 note: the product
   page's LAYOUT, the project pages' interaction quality. */
/* Three columns exactly as the live product page has them: type icon, label,
   download icon. Kim 2026-07-31.

   BORDERS. There is exactly ONE rule in here, BETWEEN rows, and nothing else.
   The previous version had a border on the wrapper's top edge, a border under
   every row including the last, and the accordion pane's own border underneath
   all of that, which drew a double line at every seam ("way too much vertical
   lines or bottom borders"). :not(:last-child) is what keeps the final row from
   doubling up with the pane border below it. */
.epp-rows { list-style: none; margin: 0; padding: 0; }
.epp-rows > li { margin: 0; }
/* NO RULE BETWEEN ROWS. Kim 2026-08-11. Each row already has its own hover
   background and rounded corners, so a hairline between them fought that: the
   tint reads as a floating pill and the rule reads as a table, and you cannot
   have both. Spacing separates them now. */
.epp-rows > li + li { margin-top: 2px; }
/* ICON 28, GLYPH 20. Kim 2026-08-06: "make the icon images smaller". They were
   40 and 28, which put a 40px picture next to a 16.5px label: the icon was more
   than twice the height of the words it was labelling, so the eye read a list of
   pictures with captions rather than a list of documents. 28 sits just above the
   label's line box, which is the relationship a file-type icon should have.
   Both dropped together to keep the download glyph secondary to the file icon
   rather than becoming its equal.
   PADDING: 12 BLOCK, 14 INLINE. Kim 2026-08-06: "add some right and left padding
   so when we hover it's better UI, and there's too much top and bottom padding".
   Both halves are about the hover tint, which spans the full width of the pane.

   The row had NO left padding at all, so on hover the icon sat hard against the
   left edge of the highlight and the tint looked like it had been cut off rather
   than drawn around the row. 14px either side gives the highlight something to
   be, and the rows read as indented under their own pane heading, which is what
   they are.

   Block padding drops 20 -> 12 because the row is carrying a 28px icon now, not a
   40px one, and 20px of air above and below a 28px icon made a 68px band for one
   line of text. 12 gives a 52px row, still comfortably over the 44px tap-target
   floor (WCAG 2.5.5) and much closer to the density a list of three or four links
   wants. */
.epp-row { display: grid; grid-template-columns: 28px minmax(0, 1fr) 20px;
  align-items: center; gap: var(--eds-space-sm);
  padding: 12px 14px;
  text-decoration: none; color: inherit;
  /* Background only. padding-left used to be in here too, and animating padding
     REFLOWS the row: the icon, the label and the download glyph all shuffled
     sideways on hover, which is what made it feel dated. Modern hover states move
     with transforms, which cannot push the layout around. Kim, 2026-08-04: "the
     hover ui looks old and weird". */
  transition: background var(--eds-dur-fast) var(--eds-ease); }
.epp a.epp-row { transition: background var(--eds-dur-fast) var(--eds-ease); }
/* HOVER IS A TINT, A LABEL COLOUR AND A NUDGE ON THE GLYPH. Nothing else.

   There WAS a 3px blue bar that wiped in from the left edge on hover. Kim
   2026-08-06: "on hover remove the blue border". She is right, and the reason is
   worth keeping: the row already says three things on hover (the background
   lifts, the label turns blue, the download glyph drops 2px), so a fourth signal
   was not adding information, it was adding noise, and a hard 3px rule against a
   list of quiet hairlines is the loudest thing on the section.

   Keyboard focus is unaffected and still obvious: the 2px outline below is a
   real focus indicator and was never the bar's job. */
/* Rounded, so the hover tint reads as a target rather than as a full-bleed
   band. Kim 2026-08-11. On the ROW, not only on :hover: a radius that appears
   at the same moment as the background makes the corners look like they are
   snapping in, and the focus outline below follows the same shape for free. */
.epp-row { position: relative; border-radius: var(--eds-radius-sm); }
.epp-row:hover { background: rgba(255, 255, 255, .05); }
.epp-row:focus-visible { outline: 2px solid var(--epp-blue); outline-offset: -2px; }
/* The two icon rules are scoped under .epp purely to reach (0,2,0). An img
   sizing rule in the minified theme bundle (cross-origin, so its selector cannot
   be read from the page) outranks a single class and was stretching these to
   their intrinsic aspect: the 100x123 icon rendered 40x49 instead of 40x40.
   Raising our own specificity is the fix rather than an !important, and both
   stay correct once that bundle is gone. */
/* MICRO-ANIMATION ON THE TWO GLYPHS. Kim 2026-08-11.

   The file icon lifts and scales a little, the download arrow dips and
   settles: the arrow's motion is the metaphor (a thing coming down to you),
   which is why it overshoots to 4px and comes back to 2 rather than sliding
   straight there. 340ms, slow enough to read as a movement and short enough
   that a fast reader never waits for it.

   Both are transform and opacity ONLY, so neither can reflow the row. That is
   the mistake this list already made once: an animated padding used to shuffle
   every element sideways on hover. */
/* object-fit is the aspect-ratio fix. The box is square and the source icons
   are not (the document icon is 100x123), so width+height alone STRETCHED them
   to fit, which is what made them look wrong. contain fits the longest side
   inside the box and leaves the rest empty, so the icon keeps its proportions.
   Kim 2026-08-11 spotted the distortion. */
.epp .epp-row__icon { width: 28px; height: 28px; object-fit: contain;
  transition: transform var(--eds-dur) var(--eds-ease); }
.epp-row:hover .epp-row__icon { transform: translateY(-1px) scale(1.08); }
.epp .epp-row__dl { width: 20px; height: 20px; justify-self: end; opacity: .7;
  transition: transform var(--eds-dur) var(--eds-ease),
              opacity var(--eds-dur-fast) var(--eds-ease); }
.epp-row:hover .epp-row__dl { transform: translateY(2px); opacity: 1;
  animation: epp-row-dl-dip .34s var(--eds-ease) 1; }
@keyframes epp-row-dl-dip {
  0%   { transform: translateY(0); }
  55%  { transform: translateY(4px); }
  100% { transform: translateY(2px); }
}
/* The reduce fallback is not "nothing happens": the opacity lift stays, so
   hover still confirms which row is live without any movement at all. */
@media (prefers-reduced-motion: reduce) {
  .epp-row:hover .epp-row__icon { transform: none; }
  .epp-row:hover .epp-row__dl { animation: none; transform: none; }
}
.epp-row__label { font-size: 16.5px; color: var(--epp-ink);
  transition: color var(--eds-dur-fast) var(--eds-ease); }
.epp-row:hover .epp-row__label { color: var(--epp-blue); }
/* Resources sits in the 24/76 split, whose right column is 967px wide. Four
   short download rows across that measure leave the download icon stranded a
   long way from its label, so the list is capped and left-aligned inside it.
   Kim 2026-07-31: "the width much smaller". */

/* ============================== RELATED PRODUCTS ==============================
   Four-across card grid, the live product page's layout, using the design
   system's own eds-card--product so these cards match every other product card
   on the site rather than inventing a second kind.

   The renders are all shot on white and the band is grey, so the media box takes
   the band's panel colour and the image is CONTAINED inside it. cover would crop
   a wide render like the Pixelator Mini Mk2 through the middle of the unit.

   NOTE for Kim: .eds-card--product h4 pins card titles at 16px, which is smaller
   than the 16.5px body text beside them. That is a design-system decision, not
   one made here, and changing it would change every product card on the site. It
   is worth a separate look. */
/* FOUR columns: three of products and a fourth holding the "Explore the full
   range" card, exactly as the live product page lays it out. Kim 2026-07-31.

   ELM HUB has four related products and only three columns, so the product side
   is a scroll-snapping carousel rather than a grid: the fourth product is one
   nudge away instead of dropped or squeezed. Scroll-snap does the work, so the
   list is still a plain scrollable list with no JS (the arrows only call
   scrollBy), and a trackpad, a touch swipe, shift-scroll and the keyboard all
   work without us implementing any of them. */
.epp-related { display: grid; grid-template-columns: 3fr 1fr;
  gap: var(--eds-carousel-gap, var(--eds-gap-card)); align-items: stretch; }
/* ONE CARD, so the 3fr/1fr split is wrong: the single card would stretch to
   three quarters of the band and read as a hero, not as a cross-sell. Equal
   columns put it beside the view-all panel at a card's natural proportions.
   Keyed on a class rather than :has(li:only-child) because the count is a
   content decision the markup already knows, and :has on a grid container is
   the kind of thing that quietly stops matching when a wrapper is added.

   THE COLUMN HUGS THE CARD (360px, the cap set below) rather than taking half
   the band. Equal 1fr columns were tried first and left a 300px hole between a
   360px card and the view-all panel, which read as a missing second card. */
/* AND THE PAIR IS CAPPED, because a 360px card beside a 960px panel holding one
   heading and one button is the same emptiness moved rather than removed. 900px
   leaves the panel about 500px, which is close to the proportion it had when
   three cards sat beside it. The section head above still spans the container. */
.epp-related--single { grid-template-columns: minmax(0, 360px) minmax(0, 1fr);
  max-width: 900px; }
/* THE CARD IS CAPPED, NOT STRETCHED, and this is the whole trap of dropping to
   one card. The slide width is derived from --epp-carousel-visible (3 by
   default), so a lone card in a 3-wide track sat at a third of its column; the
   obvious fix, letting it flex to fill, went the other way and produced a 660px
   card holding a 660x660 image at 1440, and 688x688 at 768 where the band is
   already one column. Measured both, neither is a product card: one is a
   thumbnail and the other is a hero.

   Capping the TRACK rather than the column means the same number holds in both
   the two-column desktop band and the one-column tablet band, with no second
   rule to keep in step. 360px is the width three cards had at 1440 before this,
   so a reader who has seen this section elsewhere on the site sees the same card.

   Lifted again at 620 and below, where the card becomes a full-width list row. */
.epp-related--single .epp-related__track { max-width: 360px; }
.epp-related--single .epp-related__track > li { flex: 1 1 auto; }
/* MATCHING THE LIVE SECTION. Kim 2026-08-11, comparing ours against prod's
   "Check out other ENTTEC products".

   GREY TILES, NOT WHITE. --epp-panel resolves to white on this light band, so
   a product shot on a white background had no tile at all: the ELM logo card
   looked framed and the Storm 10 and Pixelator cards looked like floating
   cut-outs. Prod uses a flat light grey behind every product image, which is
   what makes them read as one set. */
.epp-related .eds-card__media { background: #f0f0f0; }
/* NO LIFT SHADOW ON THE TILE. Kim 2026-08-11: "why is there a black thing
   showing" on hover.

   The design system puts --eds-shadow-lift (rgba(16,40,60,.35), 40px blur) on
   .eds-card__media at hover. That token is drawn for a white card floating on
   white, where a dark, slightly blue shadow reads as depth. These tiles are a
   flat grey square on a grey band, so there is no card edge for it to belong
   to and it lands as a dark smudge under the picture.

   The hover already has two signals without it: the product image scales and
   the View product arrow slides. That is enough, and it is the same restraint
   the carousel arrows just got. */
.epp-related .eds-card:hover .eds-card__media { box-shadow: none; }
.epp-related .eds-card__media > img { width: 100%; height: 100%; object-fit: contain; }
/* Names bigger. Prod runs these at display size and ours sat at body weight,
   so the product being linked to was the smallest thing on its own card.
   The card's name is an h4, not .eds-card__title: worth naming here because
   the obvious selector matches nothing and fails silently. */
.epp .epp-related .eds-card h4 { font-family: var(--enttec-font-display);
  font-size: clamp(19px, 1.5vw, 23px); font-weight: 500; line-height: 1.22; }

.epp-related__carousel { position: relative; min-width: 0; }
.epp-related__track { list-style: none; margin: 0; padding: 0;
  display: flex; gap: var(--epp-carousel-gap);
  overflow-x: auto; scroll-snap-type: x mandatory; scroll-behavior: smooth;
  /* The native bar is hidden because the arrows and the peeking fourth card
     already say "there is more", and a permanent bar under a row of product
     cards reads as a rendering fault. Keyboard and touch scrolling are
     untouched. */
  scrollbar-width: none; -ms-overflow-style: none; }
.epp-related__track::-webkit-scrollbar { display: none; }
/* Slide width is derived, not typed: N visible cards share the track minus the
   (N-1) gaps between them. Change --epp-carousel-visible and everything follows. */
.epp-related__track > li { margin: 0; scroll-snap-align: start;
  flex: 0 0 calc((100% - (var(--epp-carousel-visible) - 1) * var(--epp-carousel-gap))
                 / var(--epp-carousel-visible)); }
/* NOTE, do not "fix" the last slide's snap alignment to end. It looks like the
   cause of a track that will not scroll and it is not: a mandatory-snap
   container whose last slide cannot be start-aligned is a real failure mode, but
   this component does not have it. It was measured as scroll-behavior:smooth not
   animating under headless Chromium, a test artifact. Changing the last slide's
   alignment would quietly move the teleport target of the infinite clone set in
   section 5 of the JS, which elm-hub relies on. */

/* NO BORDER, and the icon is FontAwesome. Kim 2026-08-11.

   The 1px rule plus a drop shadow gave the button two competing edges, which
   is what made it look stuck on rather than floating over the track. The
   shadow alone does that job, so the border is gone.

   --epp-arrow-top is measured by the carousel script and is half the image
   tile's height. The hardcoded 32% it replaces was a guess at where the tile
   centre fell inside the whole card, so it drifted the moment the copy changed
   (it did: bigger product names made the cards taller). The fallback keeps the
   old guess for the paint before the script runs. */
.epp-related__arrow { position: absolute; top: var(--epp-arrow-top, 32%);
  transform: translateY(-50%);
  width: 44px; height: 44px; border-radius: 50%; border: 0;
  background: #fff; color: var(--epp-black); font-size: 15px; line-height: 1;
  cursor: pointer; z-index: 3; display: grid; place-items: center; padding: 0;
  box-shadow: 0 8px 22px -10px rgba(0,0,0,.35);
  transition: background var(--eds-dur) var(--eds-ease), color var(--eds-dur) var(--eds-ease),
              opacity var(--eds-dur) var(--eds-ease), transform var(--eds-dur) var(--eds-ease),
              box-shadow var(--eds-dur) var(--eds-ease); }
/* LIFT ONLY. Kim 2026-08-11, option 3 of six in
   _kim/mockups/elm-hub/arrow-hover-options.html.

   The version before this said four things at once (scale, chevron slide,
   deeper shadow, colour change) and read as fidgeting: with four signals the
   eye cannot tell which one is the answer to the hover. Two is the ceiling on
   a control this small, so this is 2px of rise and a deeper shadow. No colour
   change at all, which also keeps the black fill it replaced from creeping
   back in a softer form.

   TRANSLATE TWICE, do not merge the values. The -50% is what centres the disc
   on the image tile and it is not a constant: it depends on the disc's own
   height. Writing translateY(-2px) alone drops the centring and the button
   jumps half its height on hover. */
.epp-related__arrow:hover { background: #fff; color: var(--epp-black);
  transform: translateY(-50%) translateY(-2px);
  box-shadow: 0 14px 28px -12px rgba(0, 0, 0, .45); }
/* Pressed returns it to the surface, so the click has a physical answer. */
.epp-related__arrow:active { transform: translateY(-50%);
  box-shadow: 0 6px 16px -10px rgba(0, 0, 0, .4); }
@media (prefers-reduced-motion: reduce) {
  .epp-related__arrow:hover, .epp-related__arrow:active { transform: translateY(-50%); }
}
/* The chevron does NOT move. It did until 2026-08-11, and combined with the
   disc's own motion it was one signal too many. The disc lifts; that is the
   whole hover. */

/* The card is a link, so everything inside it inherits the theme's global
   a:hover colour (#444). That greyed the product NAME on hover, which reads as
   the card going inactive at exactly the moment it became active. Pin both the
   name and the excerpt to their resting colours; the blue CTA and the image
   lift are already saying "this is live". */
.epp .epp-related .eds-card:hover h4 { color: var(--epp-black); }
.epp .epp-related .eds-card:hover .eds-card__excerpt { color: var(--epp-mid); }
.epp-related__arrow:focus-visible { outline: 3px solid var(--epp-blue); outline-offset: 2px; }
/* NO BLACK RING ON A MOUSE PRESS. Kim 2026-08-11, reported twice.

   Measured with the button held down: outline computed to
   `5px auto rgb(16,16,16)`, which is the browser's own focus ring, not
   anything of ours. It appeared because only :focus-visible was styled here,
   so a pointer press fell through to the UA default.

   :focus:not(:focus-visible) silences the pointer case ONLY. A blanket
   outline:none would have taken the keyboard ring with it, which is the
   accessibility bug this pattern exists to avoid. */
.epp-related__arrow:focus:not(:focus-visible) { outline: none; }
.epp-related__arrow::-moz-focus-inner { border: 0; }
.epp-related__arrow { -webkit-tap-highlight-color: transparent; }
.epp-related__arrow--prev { left: -14px; }
.epp-related__arrow--next { right: -14px; }
/* The arrows never disable: the track loops (a 3x clone set with a no-animation
   teleport back to the middle copy), so there is no end to reach. The rule is
   kept because a future single-slide carousel would have nothing to scroll and
   should hide them rather than offer a dead click. */
.epp-related__arrow[disabled] { opacity: 0; pointer-events: none; }

/* Drag to scroll, mouse only (the script gates on pointerType). The cursor is
   the entire affordance, so it has to be right: grab at rest says the track can
   be pushed, grabbing during says it is being pushed.

   user-select is off only WHILE dragging. Off permanently it would stop a
   reader selecting a product name to search for it, which is a real thing
   people do on a spec-heavy page. */
.epp-related__track { cursor: grab; }
.epp-related__track.is-dragging { cursor: grabbing; user-select: none; }
/* THE ANCHOR IS THE PROBLEM, not just the image. Kim 2026-08-11: "i cant drag
   when on a product, just through the gaps".

   Every card is an <a href>, and a link is natively draggable: pressing on one
   and moving starts the browser's own link-drag, which fires pointercancel and
   kills the carousel gesture. So dragging only worked in the gaps BETWEEN
   cards, which is the one place no anchor sits. Images are draggable for the
   same reason.

   Chrome and Safari honour user-drag; Firefox does not, which is why the
   script also cancels dragstart. Both are needed. */
.epp-related__track a,
.epp-related__track img { -webkit-user-drag: none; user-drag: none; }
.epp-related__track img { user-select: none; }
/* Clones are visual filler. Hidden from assistive tech in the markup; this stops
   them being reachable by find-in-page or text selection as well. */
.epp-related__track > [data-epp-clone] { user-select: none; }

/* The fourth column. FILLED GREY, matching the product tiles beside it, which
   is what prod does: the four cards then read as one row of the same object
   rather than three cards and an empty bordered box. The earlier note here
   argued a fill would out-weigh the products; that was true when the fill
   would have been white, and it is not true of the same grey they sit on. */
.epp-related__viewall { display: flex; flex-direction: column;
  align-items: center; justify-content: center; text-align: center; gap: var(--eds-space-sm);
  background: #f0f0f0; border: 0; border-radius: var(--eds-radius-lg);
  padding: var(--eds-space-md); }
/* SUBTLE, NOT A PRIMARY CTA. Kim 2026-08-11: "the button is too prominent as a
   blue color". It is the way OUT of the page, competing with Contact an Expert
   at the top, which is the action that actually matters here. Prod uses an
   outlined pill with dark ink, so this borrows that: the blue is kept for the
   hover, where it confirms the target without shouting on arrival. */
.epp .epp-related__viewall .eds-btn { background: transparent; color: var(--epp-black);
  border: 1px solid rgba(0, 0, 0, .55); border-radius: 999px; font-weight: 600;
  /* 240ms on a shared easing, and every property that changes is listed. The
     first version transitioned only colour and border at --eds-dur-fast, so
     the two of them snapped while nothing else moved, which is what made it
     feel abrupt rather than smooth. */
  transition: border-color .24s var(--eds-ease), color .24s var(--eds-ease),
              transform .24s var(--eds-ease), box-shadow .24s var(--eds-ease); }
/* Lift, not a fill. One pixel up plus a soft shadow reads as the control
   coming towards you, which is the whole micro-animation: enough to confirm
   the target without the button repainting itself blue on arrival. */
.epp .epp-related__viewall .eds-btn:hover { background: transparent;
  border-color: var(--epp-blue); color: var(--epp-blue);
  transform: translateY(-1px); box-shadow: 0 4px 14px rgba(14, 165, 223, .18); }
/* Press returns it to the surface, so the click has a physical answer. */
.epp .epp-related__viewall .eds-btn:active { transform: translateY(0);
  box-shadow: 0 1px 4px rgba(14, 165, 223, .16); }
.epp .epp-related__viewall .eds-btn:focus-visible { outline: 3px solid var(--epp-blue);
  outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) {
  .epp .epp-related__viewall .eds-btn { transition: border-color .24s linear, color .24s linear; }
  .epp .epp-related__viewall .eds-btn:hover,
  .epp .epp-related__viewall .eds-btn:active { transform: none; box-shadow: none; }
}
.epp h3.epp-related__viewall-title, .epp-related__viewall h3 {
  font-family: var(--enttec-font-display); font-weight: 500; font-size: clamp(20px, 2vw, 26px);
  line-height: 1.2; color: var(--epp-ink); margin: 0; text-wrap: balance; }

@media (max-width: 1100px) { .epp { --epp-carousel-visible: 2; } }
@media (max-width: 900px) {
  .epp-related { grid-template-columns: 1fr; }
  .epp-related__viewall { flex-direction: row; justify-content: space-between; text-align: left; }
}
@media (max-width: 620px) {
  .epp { --epp-carousel-visible: 1; }
  .epp-related__viewall { flex-direction: column; text-align: center; }
  /* Off-canvas arrows have nowhere to sit once the track reaches the viewport
     edge, so they come inside. */
  .epp-related__arrow--prev { left: 4px; }
  .epp-related__arrow--next { right: 4px; }
}

/* NOTE, 2026-07-31: there is deliberately no closing CTA block here any more.
   An earlier draft ended the page with a "Talk to a lighting expert" panel. Kim
   asked whether the real product page has one; it does not (its last section is
   Related Products, then the footer), so it came out. The enquiry-only
   explanation it carried moved into the overview's last paragraph, which is
   where a reader wonders about it anyway, and the conversion point stays the
   theme's own "Contact an Expert" button in the banner. */

/* ============================== REVEAL ==============================
   Gated on .epp-js, added by the script. Without JS nothing is ever hidden, so
   a script failure cannot blank the page. */
.epp-js .epp-reveal { opacity: 0; transform: translateY(22px);
  transition: opacity var(--eds-dur-slow) var(--eds-ease), transform var(--eds-dur-slow) var(--eds-ease); }
.epp-js .epp-reveal.epp-in { opacity: 1; transform: none; }

/* ============================== REDUCED MOTION ==============================
   Blanket-kill animation and transition timing across the whole layer (the same
   way the design system does it), so every hover micro-animation degrades to an
   instant state change rather than motion. Every control keeps a NON-motion
   signal: colour for the nav, the rows and the accordion; the active thumb plus
   the "Image n of m" counter for the gallery. */
@media (prefers-reduced-motion: reduce) {
  .epp *, .epp *::before, .epp *::after { animation: none !important; transition: none !important; }
  /* scroll-behavior is NOT an animation or a transition, so the blanket rule
     above does not touch it and the carousel would keep gliding. The arrows pass
     behavior:'auto' explicitly, which wins for those, but native scrolling
     (keyboard, scroll-into-view) would still animate without this line. */
  .epp-related__track { scroll-behavior: auto !important; }
  .epp-js .epp-reveal { opacity: 1 !important; transform: none !important; }
  /* The gallery track is NOT exempted here. The blanket rule above already kills
     its transition, which is exactly right: dragging still follows the pointer
     one-to-one (direct manipulation is not animation, it is the visitor moving
     the thing themselves), and only the snap at the end of the gesture becomes
     instant. The "Image n of m" counter and the active thumb remain the
     non-motion signal that the control worked. */
  /* Remove the purely decorative hover transforms. Colour and the sub-nav
     underline still fire, so hover stays legible without anything moving. */
  .epp-row:hover .epp-row__cta::after,
  .epp-showmore:hover,
  .epp .eds-btn:hover { transform: none !important; }
}

/* ==================== ACCORDION MOTION ====================
   Kim 2026-08-11, both accordions (Features and Resources share the component,
   so this is written once).

   TWO LAYERS, ON PURPOSE.

   1. The panel's content eases in on every browser. Opacity and transform
      only, so it cannot reflow anything, and it runs whether or not the box
      itself can animate.

   2. The BOX animates its height only where the browser can do it natively.
      A <details> has never been animatable: the content has no box of its own
      to transition, which is why every accordion on the web either fakes it
      with JS measuring scrollHeight, or snaps. ::details-content plus
      interpolate-size gives that box, and `transition-behavior: allow-discrete`
      is what lets content-visibility flip at the right end of the animation
      rather than instantly. Behind @supports, so a browser without it gets the
      instant open it gets today plus the content fade, which is exactly the
      current behaviour and not a regression.

   The chevron already rotates (design system). Nothing here touches it. */
/* NO content animation here: .epp-js .epp-accordion details[open]
   .epp-accordion__panel already fades and lifts the panel with epp-panel-in,
   and it is gated on .epp-js so a scriptless page never holds content at
   opacity 0. A second animation on the same element would only be the one that
   loses the cascade. This block adds the BOX motion that one cannot do. */

/* The summary is the control, so it should answer the pointer before the click.
   THE LABEL ITSELF DOES NOT CHANGE. Kim 2026-08-11: "the font weight is one
   value and when its hovered its another value it seems as it grows a bit
   thicker". It never did: both states compute to 400, and the only property
   that changed was colour. White on this dark band blooms into the background
   (halation), the brand blue does not, so swapping one for the other reads as a
   change of weight even though the glyphs are identical. Any colour change on
   large light text over a dark field has this problem, so the answer is not a
   different blue, it is not recolouring the text.

   The signal moves to the two things that can carry it without touching the
   type: the row's own hairline goes brand blue across the full width of the
   control, and the chevron follows. Two signals, no layout shift, and nothing
   for the eye to read as bolder.

   The colour is RESTATED here rather than just removed: the design system's own
   `.eds-accordion--on-dark summary:hover` also paints the label blue, so
   deleting our rule would simply hand the problem back to that one. Two classes
   plus the element and the pseudo-class outranks it. */
.epp .epp-accordion summary { transition: border-color var(--eds-dur-fast) var(--eds-ease); }
.epp .epp-accordion summary:hover { color: var(--epp-ink);
  border-bottom-color: var(--epp-blue); }
.epp-accordion summary::after { transition: transform var(--eds-dur) var(--eds-ease),
                                            border-color var(--eds-dur) var(--eds-ease); }
.epp-accordion summary:hover::after { border-color: var(--epp-blue); }

@media (prefers-reduced-motion: reduce) {
  .epp .epp-accordion summary, .epp-accordion summary::after { transition: none; }
}

/* ---------------------------------------------------------------------------
   Accordion panel link (2026-08-14). Added for the ELM HUB Simplicity pane,
   which sends the reader to ELM's own licensing section rather than
   summarising the editions on the product page.

   NOT .epp-swlink: that is a bordered pill in --epp-ink, which is dark text on
   a dark border and would render nearly invisible inside a dark accordion
   panel. This is a plain text link sized to the panel copy.

   The arrow slide and the focus ring follow the same tokens as .epp-swlink so
   the two links behave identically, and the reduced-motion block below is the
   non-negotiable pair for any transition on this site.
   --------------------------------------------------------------------------- */
.epp .epp-accordion__link { margin: 14px 0 0; }
.epp .epp-accordion__link a { display: inline-flex; align-items: center; gap: 8px;
  color: var(--epp-blue); font-weight: 500; text-decoration: none;
  transition: gap var(--eds-dur, .3s) var(--eds-ease, cubic-bezier(.22,.61,.36,1)); }
.epp .epp-accordion__link a:hover { gap: 12px; text-decoration: underline;
  text-underline-offset: 3px; }
.epp .epp-accordion__link a:focus-visible { outline: 3px solid var(--epp-blue);
  outline-offset: 3px; border-radius: 3px; }

@media (prefers-reduced-motion: reduce) {
  .epp .epp-accordion__link a { transition: none; }
  .epp .epp-accordion__link a:hover { gap: 8px; }
}

/* ---------------------------------------------------------------------------
   Round 3 (2026-08-14).

   1. ARROW MUST NOT INHERIT THE HOVER UNDERLINE. text-decoration propagates to
      descendants and a child cannot switch it off, so the underline has to be
      applied to an inner label span rather than to the <a>. That is why the
      markup gained .epp-accordion__link-label.
   2. FIGCAPTION ABOVE THE IMAGE for the connectivity diagram. figcaption
      renders below by default; ordering it in CSS keeps the DOM order that a
      screen reader wants (caption first is also what it announces first here).
   --------------------------------------------------------------------------- */
.epp .epp-accordion__link a:hover { text-decoration: none; }
.epp .epp-accordion__link a:hover .epp-accordion__link-label { text-decoration: underline;
  text-underline-offset: 3px; }
.epp .epp-accordion__link-arw { text-decoration: none; }

.epp .epp-figure--cap-top { display: flex; flex-direction: column; }

/* CAPTION READS AS A SECTION HEADING (Kim 2026-08-14: "too small, can it match the
   ui of Specifications but middle align is fine"). The Connectivity band carries no
   <h2>, so this caption is the only title in it and 15px made it look like a
   footnote attached to the picture rather than the name of the section.

   Every value here is copied from the h2 rules above, NOT re-invented, so the two
   titles stay identical if the type scale is ever retuned: font-family and
   text-transform from `:where(.epp) h2, h3, h4`, weight/line-height/letter-spacing
   from `:where(.epp) h2`, size from `.epp h2`.

   COLOUR IS var(--epp-ink) AND NOT `inherit`. Specifications is on a dark band and
   this is on a light one, and --epp-ink already resolves per band (see the token
   blocks near the top). Copying the h2's literal colour would have hard-coded the
   dark band's ink onto a white background. Matching the UI means matching the type
   treatment, not the pixel colour.

   It stays a <figcaption> and does not become an <h2>: it labels the image, not the
   section, so promoting it would put a heading in the document outline for a band
   that deliberately has none. Looking like a heading and being one are different
   questions, and only the first was asked. */
.epp .epp-figure--cap-top figcaption {
  order: -1;
  margin: 0 0 var(--eds-space-s, 16px);
  max-width: none;
  text-align: center;
  font-family: var(--enttec-font-display);
  font-size: clamp(28px, 4vw, 38px);
  font-weight: 400;
  line-height: 1.2;
  letter-spacing: 0;
  text-transform: none;
  color: var(--epp-ink);
}

/* ---------------------------------------------------------------------------
   LICENCE WARNING AS A DISCLOSURE (2026-08-14). The block carried five sentences
   and dominated the ELM column, so it now collapses to its first line.

   .epp-swalert is still the skin (amber background, border, colour); this
   modifier only changes the box model, because the base rule is display:flex
   and a <details> must be a block for its body to sit under the summary.

   The marker is removed two ways on purpose: list-style covers Firefox and
   modern Chrome, ::-webkit-details-marker covers older WebKit. Missing either
   leaves a stray triangle next to our own warning icon.

   The Read more / Show less label is a ::after on an aria-hidden span, so the
   summary's accessible name stays the sentence itself and the expanded state
   comes from <details> natively. No JS, keyboard operable, and with JS disabled
   it simply renders open, which is the right direction to fail for a licensing
   exclusion.
   --------------------------------------------------------------------------- */
/* SPACING, corrected 2026-08-14 when the warning moved up the column. The base
   .epp-swalert rule carries margin-top:--eds-space-md (40px), which read as
   "fine print, separated from the content" while this was the last element in
   the column. Mid-column it just broke the rhythm: every other gap here is
   18-22px, and the box sat in 40px above / 26px below, so it floated and the
   asymmetry showed.

   26px both sides now: symmetric, matching the gap .epp-swtail already sets
   below it, and close enough to the column's rhythm that the box reads as part
   of the flow while still getting a little more air than a paragraph. Margins
   collapse against the paragraph above, so the top value is what wins. */
.epp .epp-swalert--toggle { display: block; margin-top: 26px; margin-bottom: 0; }
.epp .epp-swalert--toggle > summary { display: flex; gap: 11px; align-items: flex-start;
  cursor: pointer; list-style: none; }
.epp .epp-swalert--toggle > summary::-webkit-details-marker { display: none; }
.epp .epp-swalert--toggle > summary:focus-visible { outline: 2px solid #f7c08a;
  outline-offset: 3px; border-radius: 4px; }
.epp .epp-swalert--toggle .epp-swalert__head { flex: 1 1 auto; }
.epp .epp-swalert--toggle .epp-swalert__more { flex: none; align-self: center;
  font-size: 13px; text-decoration: underline; text-underline-offset: 3px;
  white-space: nowrap; opacity: .85;
  transition: opacity var(--eds-dur, .3s) var(--eds-ease, ease); }
.epp .epp-swalert--toggle > summary:hover .epp-swalert__more { opacity: 1; }
.epp .epp-swalert--toggle .epp-swalert__more::after { content: "Read more"; }
.epp .epp-swalert--toggle[open] .epp-swalert__more::after { content: "Show less"; }
/* ALIGNED TO THE SUMMARY'S TEXT COLUMN, not to the box edge (Kim 2026-08-14).
   17px icon + 11px gap = 28px, so the body starts exactly under the heading and
   every line of text shares one left edge. Flush-left looked like a staircase:
   icon, then an indented heading, then an outdented paragraph.

   This indent was briefly removed while chasing a ragged gap down the right of
   the box. That gap was never the indent, it was .epp-swlead__copy's 52ch
   measure leaking in (see the .epp-swalert p rule above). With that fixed the
   body runs to the right padding either way, so the indent costs nothing. */
.epp .epp-swalert--toggle .epp-swalert__body { margin-top: 9px; padding-left: 28px; }

@media (prefers-reduced-motion: reduce) {
  .epp .epp-swalert--toggle .epp-swalert__more { transition: none; }
}

/* Protocol names must not break across a line. "Art-Net" is the only one on this
   page with a hyphen, so it is the only one a browser will happily split into
   "Art-" / "Net", which is exactly the string a specifier is scanning for. A
   nowrap span rather than a non-breaking hyphen (U+2011), whose glyph coverage
   is less dependable across the font stack. */
.epp .epp-nb { white-space: nowrap; }

/* ===========================================================================
   S-PLAY PROGRAMMER (SKU 70100), 2026-08-26
   ===========================================================================
   Ported from the Claude Design handover in _kim/prototypes/kim-design/
   (README.md plus turn 3a desktop / 4a tablet / 4b mobile). The handover's
   inline styles are an authoring constraint of the .dc.html format, not a
   styling instruction, so every value below was moved here and expressed
   against the tokens this sheet already owns.

   WHY EVERYTHING IS SCOPED TO .epp--splay
   This sheet is shared by every html-mode product (ELM HUB today, this page
   from now on). The S-PLAY Programmer design is a LIGHT page: white and #f2f2f2
   bands, Montserrat 400 headings in black, a white sub-nav. ELM HUB is the dark
   treatment. So anything that would restyle a SHARED component carries the page
   modifier, and only genuinely new component names (.epp-section--pale,
   .epp-banner, .epp-dl and the rest) are declared unscoped. Adding a third
   html-mode product must not have to unpick this page's skin first.

   FONT SIZE FLOOR. The handover draws several labels at 10 to 11.5px (the
   "Only on the desktop" badge, the step numerals, the chapter timecodes, the CC
   chip, the mobile sub-nav). ENTTEC's floor for customer-facing type is 12px,
   so all of those are set at 12px here. It is the one place this port
   deliberately departs from the design.
   =========================================================================== */

/* A fourth band. --epp-grey (#f2f2f2) already existed as a token but no band
   used it: .epp-section--grey is the darker #d9d9d9 the prod related-products
   strip uses. This design alternates white and #f2f2f2, so it needs a band of
   its own rather than a one-off background on three sections, and the rule and
   panel colours have to move with it or every hairline vanishes. */
.epp-section--pale {
  background: var(--epp-grey);
  --epp-ink:      var(--epp-black);
  --epp-body-ink: var(--epp-mid);
  --epp-soft-ink: var(--epp-mid);
  --epp-rule:     var(--epp-light);
  --epp-panel:    #ffffff;
}
/* "Other software" repeats --pale straight after Resources, so it needs a seam
   or the two read as one 400px block of grey. */
.epp-section--seam { border-top: 1px solid var(--epp-light); }

/* ---------------------------------------------------------------------------
   PAGE SHELL
   --------------------------------------------------------------------------- */
.epp--splay {
  /* 1240px wrap with the design's own gutter ladder, replacing the ELM HUB rung
     system (calc(100% - 160px)), which is a percentage and not a cap: at 1920 it
     would run a 1760px band under a design drawn for 1240. min() of the two says
     "1240, but never closer than a gutter to the edge". */
  --eds-container: min(1240px, 100% - 2 * clamp(20px, 4vw, 60px));
  /* The design system reads this for the no-JS anchor offset. Our bar is 54px,
     not the 60px of the --bar skin. Declared on the page root, exactly as the
     design system's own note instructs. */
  --eds-subnav-h: 54px;
  /* 16px, not the 17px this first shipped with, and line-height:normal rather
     than 1.65. This is the DESIGN's own base and it is load-bearing twice
     over. Every ch-based cap on the page (the 62ch section head, the 26ch h2)
     resolves against it, and at 17px the head came out 524px wide instead of
     493, which is enough to stop "Discover our other software" wrapping where
     the design wraps it. And every element the design leaves on the base line
     box (breadcrumb, value ticks, fine print, step titles, chapter timecodes,
     spec rows, the manual card) was running a 1.65 line box against the
     design's normal: about 4px taller each, compounding down the page.
     Anything that needs a real line-height sets its own. */
  font-size: 16px;
  line-height: normal;
  background: #ffffff;
}
@media (max-width: 1100px) { .epp--splay { --eds-subnav-h: 52px; } }
@media (max-width: 640px)  { .epp--splay { --eds-subnav-h: 48px; } }

/* Section rhythm is the design's 88/100, not the design system's 110/110. */
.epp--splay .epp-section { padding: clamp(44px, 6.1vw, 88px) 0 clamp(52px, 6.9vw, 100px); }

/* Eyebrow: Montserrat 700 at .16em here, against the shared rule's Source Sans
   600 at .12em. Scoped, so ELM HUB's eyebrows do not move. */
/* p.eds-eyebrow, not .eds-eyebrow. The eyebrow is a <p> inside
   .eds-section-head, so `.epp .eds-section-head p` (two classes and a type)
   outranked the plain class selector and repainted every eyebrow on the page
   --epp-body-ink grey with `.5em 0 0` margins. Matching that specificity puts
   the blue back and restores the 14px gap under it. */
.epp--splay p.eds-eyebrow { font-family: var(--enttec-font-display); font-weight: 700;
  font-size: 13px; letter-spacing: .16em; margin: 0 0 14px;
  color: var(--epp-blue); max-width: none; }
/* The 62ch cap belongs on the LEDE, where it controls line length for reading,
   not on the block: at 16px it resolved to 493px, which is narrower than a 38px
   heading needs, so "Discover our other software" wrapped to two lines inside a
   limit meant for body copy. The h2 keeps its own 26ch cap, measured in its own
   font, which is the one that should decide where a heading breaks. */
.epp--splay .eds-section-head { max-width: none; margin-bottom: 44px; }
.epp--splay .eds-section-head h2 + p { max-width: 62ch; }
.epp--splay .eds-section-head h2 { max-width: 26ch; margin: 0; }
.epp--splay .eds-section-head h2 + p { font-size: 18px; line-height: 1.6;
  color: var(--epp-mid); margin: 14px 0 0; max-width: none; text-wrap: pretty; }
.epp--splay h2 { font-weight: 400; line-height: 1.2; }
.epp--splay h3 { font-weight: 400; }

/* ---------------------------------------------------------------------------
   BANNER
   The theme's own product banner is suppressed for this page; see the :has()
   rule at the very end of this block for why and for what goes with it.
   --------------------------------------------------------------------------- */
/* NO z-index ANYWHERE IN THE BANNER, and that is deliberate rather than an
   omission. The handover asks for z-index:2 on the band and 1 on the copy so
   the download menu paints over the sub-nav. In the real theme that does the
   OPPOSITE: position plus z-index creates a STACKING CONTEXT, which traps the
   menu at its ancestor's level, and the design system's sticky .eds-subnav sits
   at 25. The menu came out sliced along the top edge of the bar.

   So the layering is done with paint order instead. The render and the wrap are
   both positioned with z-index:auto, which puts them in the same stack level,
   painted in TREE ORDER: the render is first in the DOM and the wrap second, so
   the copy sits on top of the render without either of them opening a context.
   That is why the media element comes BEFORE the wrap in the body, and why the
   tablet reflow uses flex order rather than DOM order to put it underneath.
   With no context in the way the menu's own z-index (30, below) is compared
   against the bar's 25 directly, and wins. */
.epp-banner { position: relative; background: #ffffff;
  min-height: 660px; display: flex; align-items: center; }
/* width:100% is load-bearing: .epp-banner is a flex row, so without it the
   container is sized by its CONTENT (620px, the copy column) rather than by the
   band, and margin-inline:auto then has nothing to centre. The wrap collapsed
   to the left edge of the page with the render overlapping it. */
.epp-banner > .eds-container { position: relative; width: 100%;
  padding-block: 32px; }
.epp-banner__copy { max-width: 620px; }

/* The render is background, so it must not eat clicks meant for the copy column
   that overlaps it. object-fit:contain rather than cover: the source is a wide
   composition and cover crops the S-PLAY unit out of it. */
.epp-banner__media { position: absolute; inset: 0 0 0 auto; width: 78%;
  overflow: hidden; pointer-events: none; }
.epp-banner__media img { width: 100%; height: 100%; object-fit: contain;
  object-position: right center; }

.epp-crumbs { display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
  font-size: 14px; color: var(--epp-mid); margin-bottom: 18px; }
.epp--splay .epp-crumbs a { color: var(--epp-mid); }
.epp--splay .epp-crumbs a:hover { color: var(--epp-blue); }
.epp-crumbs__sep { opacity: .5; }

.epp--splay .epp-banner h1 { font-family: var(--enttec-font-display);
  font-weight: 400; font-size: 44px; line-height: 1.15; letter-spacing: normal;
  color: var(--epp-black); margin: 0 0 7px; }
/* Source Sans 600, deliberately NOT Montserrat: a display face at 21px directly
   under a 44px display h1 competes with it instead of supporting it. */
.epp-banner__lead { font-weight: 600; font-size: 21px; line-height: 1.45;
  color: var(--epp-black); margin: 0 0 16px; }
.epp-banner__lede { font-size: 17px; line-height: 1.65; color: var(--epp-mid);
  margin: 0 0 24px; text-wrap: pretty; }

.epp-ticks { display: flex; flex-wrap: wrap; gap: 8px 22px;
  font-size: 16px; color: var(--epp-blue); }
.epp-ticks li { display: inline-flex; align-items: center; gap: 7px; }
.epp-ticks svg { flex: none; }

.epp-banner__cta { display: flex; flex-wrap: wrap; align-items: center; gap: 14px;
  margin-top: 28px; }
/* SKU DIRECTLY UNDER THE h1, not in a foot note. Kim 2026-09-01: match the
   hardware pages (OCTO MK3 and the rest of .product-details-top, which this
   banner hides and therefore has to reproduce). A part number is a lookup
   fact, so it belongs where a reader looks for it, beside the name. "SKU:" is
   bolded and inked darker than the number so the label reads as the key and
   the value stays quiet. */
.epp-banner__sku { font-size: 15px; letter-spacing: .02em; color: var(--epp-mid);
  margin: 0 0 18px; }
.epp-banner__sku b { color: var(--epp-black); font-weight: 600; }

/* ---------------------------------------------------------------------------
   BUTTONS
   NOT .eds-btn. The design system's button is a 28px-radius pill at 15px with a
   44px floor; this design pins a 6px radius, a fixed 50px height and 16px
   Source Sans at .5px tracking, and the two halves of the split control have to
   line up with the ghost button beside them to the pixel. Reskinning .eds-btn
   that hard would be a different component wearing its name. The view-all pill
   in "Other software" IS .eds-btn, because there the design and the system
   already agree.
   --------------------------------------------------------------------------- */
.epp-btn { display: inline-flex; align-items: center; justify-content: center; gap: 10px;
  height: 50px; box-sizing: border-box; padding: 0 24px; border-radius: 6px;
  font-family: var(--enttec-font-body); font-size: 16px; font-weight: 400;
  letter-spacing: .5px; text-decoration: none; cursor: pointer; white-space: nowrap;
  transition: background .22s var(--eds-ease), border-color .22s var(--eds-ease),
              color .22s var(--eds-ease); }
/* Horizontal padding only, against a fixed height: a hover that changed the box
   would reflow the row, which is the exact fault the handover calls out. */
.epp-btn--primary { background: var(--epp-blue); border: 1px solid var(--epp-blue); }
.epp--splay a.epp-btn--primary, .epp-btn--primary { color: #ffffff; }
.epp-btn--primary:hover { background: #0b93c7; border-color: #0b93c7; }
/* The ghost never fills. The blue primary is the only filled control in the
   banner, so a secondary that also painted itself would read as a second
   primary. The border darkens, the ink stays black. */
.epp-btn--ghost { background: transparent; border: 1px solid var(--epp-light); }
.epp--splay a.epp-btn--ghost, .epp-btn--ghost { color: var(--epp-black); }
.epp-btn--ghost:hover { border-color: var(--epp-dark); }
/* 25px, one more than the primary, because this one carries a 1px border: it
   puts the two labels on the same optical inset. Banner only; the pair in the
   download panel are flex:1 and set their own. */
.epp--splay .epp-banner__cta .epp-btn--ghost { padding-inline: 25px; }
.epp-btn:focus-visible { outline: 3px solid var(--epp-blue); outline-offset: 3px; }
.epp-btn svg { flex: none; }

/* ---------------------------------------------------------------------------
   DOWNLOAD SPLIT BUTTON
   Two actions in one control: the main half downloads the platform we detected,
   the caret half lists both. A plain dropdown would cost every visitor a click
   to reach the option we already know they want, and hiding the other platform
   would be wrong for the integrator downloading on one machine for another at
   the venue. So detection changes which option is FIRST, never which exist.
   --------------------------------------------------------------------------- */
.epp-dl { position: relative; }
/* The WRAPPER carries the fill, so both halves darken as one. Each half owning
   its own :hover is what made one side change and leave the other behind in an
   earlier build. */
.epp-dl__btn { display: inline-flex; align-items: stretch; height: 50px;
  border-radius: 6px; overflow: hidden; background: var(--epp-blue);
  transition: background .22s var(--eds-ease); }
.epp-dl:hover .epp-dl__btn, .epp-dl__btn:focus-within { background: #0b93c7; }
.epp--splay a.epp-dl__main, .epp-dl__main { display: inline-flex; align-items: center;
  gap: 10px; padding: 0 24px; font-size: 16px; letter-spacing: .5px; color: #ffffff;
  text-decoration: none; cursor: pointer; }
.epp-dl__caret { display: inline-flex; align-items: center; padding: 0 16px;
  border: 0; border-left: 1px solid rgba(255,255,255,.28); background: none;
  color: #ffffff; cursor: pointer; }
.epp-dl__caret svg { transition: transform .24s cubic-bezier(.4,0,.2,1); }
.epp-dl__caret[aria-expanded="true"] svg { transform: rotate(180deg); }
/* A white ring INSIDE the fill, not a black one. Black at -3px read as a hard
   border drawn on the button rather than as a focus state, and it is off-palette
   against the blue.

   Not deleted, only restyled: this is the only thing telling a keyboard user
   which half of a split button they are on, and the two halves do different
   things (download vs open the menu). Removing it outright would leave them
   pressing Enter blind. -5px so the ring sits clear of the 6px corner radius. */
/* Same reasoning as the requirements tabs: the half in focus darkens instead of
   wearing a ring, which is the language its hover already uses, so the two
   halves stay distinct without a rectangle drawn inside the fill. */
.epp-dl__main:focus-visible, .epp-dl__caret:focus-visible {
  outline: none; background: rgba(0,0,0,.20); }

/* z-index 30 CLEARS THE STICKY SUB-NAV, which the design system pins at 25. The
   menu opens downward from a button near the bottom of the banner, so it
   overlaps the bar every time; at anything below 25 it is sliced off along the
   bar's top edge. See the note on .epp-banner for why raising the BAND instead
   makes it worse. */
.epp-dl__menu { position: absolute; top: calc(100% + 8px); left: 0; min-width: 340px;
  z-index: 30; background: #ffffff; border: 1px solid var(--epp-light);
  border-radius: 8px; box-shadow: 0 18px 44px rgba(0,0,0,.14); overflow: hidden;
  animation: epp-dl-in .2s cubic-bezier(.4,0,.2,1) both; }
/* Flipped above the button when the viewport has no room below. The script
   measures after the menu is in the DOM, because its height depends on the
   rendered text. */
.epp-dl__menu.is-up { top: auto; bottom: calc(100% + 8px); }
/* display is set on the element above, which beats the UA's [hidden]{display:none}.
   Restate it or the menu renders open on load. */
.epp-dl__menu[hidden] { display: none; }
@keyframes epp-dl-in { from { opacity: 0; transform: translateY(-6px); }
                       to   { opacity: 1; transform: none; } }

.epp--splay a.epp-dl__item, .epp-dl__item { display: grid;
  grid-template-columns: auto 1fr auto; align-items: center; gap: 2px 14px;
  padding: 13px 16px; color: var(--epp-black); text-decoration: none;
  transition: background var(--eds-dur-fast) var(--eds-ease); }
.epp-dl__item + .epp-dl__item { border-top: 1px solid var(--epp-light); }
.epp-dl__item:hover { background: var(--epp-grey); }
.epp-dl__item:focus-visible { outline: 2px solid var(--epp-blue); outline-offset: -2px; }
.epp-dl__ico { grid-column: 1; grid-row: span 2; fill: var(--epp-dark); }
.epp-dl__title { grid-column: 2; grid-row: 1; font-size: 16px; white-space: nowrap; }
.epp-dl__sub { grid-column: 2; grid-row: 2; font-size: 13px; color: var(--epp-mid); }
.epp-dl__badge { grid-column: 3; grid-row: 1; justify-self: end;
  font-family: var(--enttec-font-display); font-weight: 600; font-size: 12px;
  letter-spacing: .08em; text-transform: uppercase; color: var(--epp-blue);
  white-space: nowrap; }
/* Only the detected row gets the tint and the badge, and the script is what
   adds .is-yours. Without JS the menu is still a correct, untinted list of both
   platforms rather than a list claiming both are "your device". */
.epp-dl__item .epp-dl__badge { display: none; }
.epp-dl__item.is-yours { background: rgba(14,165,223,.08); }
.epp-dl__item.is-yours .epp-dl__badge { display: block; }

/* ---------------------------------------------------------------------------
   SUB-NAV, light skin
   The mechanics (the sticky offset that follows the navbar, the measured anchor
   jumps, the active-section highlight) are the design system's .eds-subnav and
   are reused untouched. Only the skin is ours, exactly as --bar and --chips are
   skins of the same component.
   --------------------------------------------------------------------------- */
.epp-subnav-light { background: #ffffff; border-top: 1px solid var(--epp-light);
  border-bottom: 1px solid var(--epp-light); }
.epp-subnav-light ul { height: 54px; align-items: stretch; gap: 40px; }
.epp--splay .epp-subnav-light a { display: flex; align-items: center; padding: 0;
  font-family: var(--enttec-font-display); font-weight: 600; font-size: 12px;
  letter-spacing: .14em; text-transform: uppercase; color: var(--epp-mid);
  border-bottom: 2px solid transparent;
  transition: color .2s var(--eds-ease), border-color .2s var(--eds-ease); }
.epp--splay .epp-subnav-light a:hover,
.epp--splay .epp-subnav-light a[aria-current="true"] { color: var(--epp-blue);
  border-bottom-color: var(--epp-blue); }
.epp--splay .epp-subnav-light a:focus-visible { outline: 2px solid var(--epp-blue);
  outline-offset: -2px; }
/* The --bar skin draws its own underline with an ::after; this skin uses a real
   border-bottom so the 2px rule sits on the bar's own edge. Cancel the other. */
.epp--splay .epp-subnav-light a::after { display: none; }

/* ---------------------------------------------------------------------------
   FEATURES BENTO
   --------------------------------------------------------------------------- */
/* The bento is TWO groups, because that is how the feature list was actually
   thought about: the things that save time, then the things that make it easy
   to live with. Eight equal siblings made the reader assemble that themselves.
   Quieter than the section eyebrow above it (grey, not blue) so the hierarchy
   reads section > group > tile rather than two competing eyebrows. */
.epp-bento__group { margin: 0 0 16px; font-family: var(--enttec-font-display);
  font-weight: 700; font-size: 12px; letter-spacing: .16em; text-transform: uppercase;
  color: var(--epp-mid); display: flex; align-items: center; gap: 14px; }
.epp-bento__group::after { content: ""; flex: 1; height: 1px;
  background: var(--epp-light); }
.epp-bento + .epp-bento__group { margin-top: 36px; }


/* ---------------------------------------------------------------------------
   THE FEATURE CARD: copy | steps, clip underneath both.
   Left third is the claim, right two thirds is the method, and the clip is the
   card's own last row (Kim, 2026-09-02).

   The clip was beside the copy for most of this card's life, on the argument
   that the reader should see claim and proof at once. Putting the method between
   them reads better in practice: claim, how, then proof, top to bottom, and the
   steps get a column of their own instead of a strip under two of them.
   --------------------------------------------------------------------------- */
/* ONE COLUMN AT CARD LEVEL: the copy runs the full width across the top, and the
   1/3 : 2/3 split moves down into .epp-tile__side below it.

   The copy was in the 1/3 column and it was the wrong place for it, measurably
   (1440, before this change): the heading took THREE lines instead of the two it
   is written for, stranding "content" alone on the middle one, because 413px
   cannot fit "Create complex animated content" on one line whatever the <br>
   says. The paragraph ran six lines at 45 characters, the bottom edge of a
   readable measure. And 358px of white sat under it, because the clip sets the
   card height and a text column has nothing to do with the difference.

   It was also the wrong place by rank: this is the card for the one thing only
   this product does, and its claim was in the narrowest column on the card while
   the supporting steps had two thirds. */
.epp-tile--feature { display: grid; grid-template-columns: minmax(0, 1fr); }
/* THE HEADER IS TWO COLUMNS: heading block left, copy right.
   Even halves rather than the 1/3 : 2/3 of the row below it, and the heading is
   why. Its break is authored after "content", and that needs about 350px to hold
   the first line; a third of the card is 380px before padding and could not, so
   the heading broke a second time and stranded a word. Half the card clears it
   with room, and it puts the copy on a ~590px measure, which is inside the
   comfortable range instead of the 45 characters it had in a third or the ~150 it
   would have full-bleed.

   align-items: start so the copy's first line sits level with the badge rather
   than centring against a two-line heading. */
.epp-tile--feature > .epp-tile__body { display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: 0 32px; align-items: start; }
/* The badge is a label for the card, so it spans and the heading and copy below
   it become the first thing in their own columns. Auto-placement does the rest:
   with the badge taking row 1 across, the h3 lands in row 2 column 1 and the
   paragraph in row 2 column 2, tops level, no line numbers by hand. */
.epp-tile--feature > .epp-tile__body > .epp-tile__badge { grid-column: 1 / -1; }
.epp-tile--feature > .epp-tile__body > h3 { margin-bottom: 0; }
/* THE COPY COLUMN IS RIGHT ALIGNED (Kim, 2026-09-02), and it needs both halves
   of this to look deliberate rather than accidental.

   `text-align: right` alone would rag the text against the right edge of the
   68ch measure, and that measure sits at the LEFT of a wider column, so the
   block would still have a gap to its right and read as a mistake. margin-left:
   auto pushes the capped block to the column's right edge first, so the ragged
   edge is the card's edge.

   Worth knowing if this ever gets longer: a ragged LEFT edge costs scanning
   speed, because the eye cannot predict where the next line starts. At two lines
   that is not measurable. Past three or four it is, and this would be the first
   thing to reconsider. */
.epp-tile--feature > .epp-tile__body > p { max-width: 68ch; margin-top: 0;
  margin-left: auto; text-align: right;
  /* BOTTOM OF THE CELL, not the top (Kim, 2026-09-02). The row's height comes
     from the heading beside it, which is the taller of the two, so anchoring the
     copy to the bottom lands its last line level with the heading's last line.
     Aligned to the top instead, the two blocks agreed on their first line and
     drifted apart at the bottom by however many lines they happened to differ
     by. Bottom is the edge a reader actually compares here, because the steps
     row begins immediately under it. */
  align-self: end; }
/* THE CARD'S LOWER ROW: steps in the first third, clip in the other two.
   The clip stays at ~825px here, comfortably under its own 960px, so it never has
   to be capped or upscaled the way a full-width band would be. */
.epp-tile__side { display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
  gap: 20px; min-width: 0; padding: 0 32px 28px; }
.epp-tile--feature > .epp-tile__body { padding: 30px 32px 22px; }
/* Stacked, because the steps now have a third of the card rather than two, and
   three across ~255px is the width at which they stopped reading as a filmstrip.
   Each one grows so the column ends level with the clip beside it: the surplus
   goes into three boxes rather than pooling under the last one. */
.epp-tile--feature .epp-steps { display: flex; flex-direction: column; gap: 12px; }
.epp-tile--feature .epp-step { flex: 1; display: flex; flex-direction: column;
  justify-content: center; }
/* .epp-steps-wrap carries a `grid-column: 1 / -1` span and its own padding for the
   layout it was first written for. The padding is the side column's job now, and
   the span has to be cancelled explicitly: it was harmless while .epp-tile__side
   was a flex box, and the moment that became a grid it made the steps span both
   columns and pushed the clip onto its own row at 385px. */
.epp-tile--feature .epp-steps-wrap { padding: 0; grid-column: auto; }
.epp-tile--feature .epp-steps { margin-top: 0; }
/* A radius, because the clip is a panel inside a column now rather than a band
   across the card, and a square corner beside the card's own 12px reads as a
   mistake. overflow is what clips the video to it. */
.epp-tile--feature .epp-slot--scene { border-radius: 8px; overflow: hidden;
  background: transparent; }
/* 960px IS THE CLIP'S CEILING, and the reason is the file rather than the
   design. This column renders it around 825px, under the file's own 960px, so it
   downscales and stays sharp. The cap is a guard for the day the column grows:
   painting 960px of source across more than 960px is an upscale, and the fix for
   that is a wider export, never a wider CSS width. */
.epp-tile--feature .epp-slot--scene .epp-slot__video { max-width: 960px;
  margin-inline: auto; }

/* The copy column is the shorter of the two now, so its last line is left at the
   top rather than pushed to the bottom: the badge, heading and paragraph read as
   one block against the numbered steps beside them. The bottom-anchoring that
   used to live here was for the layout where the clip set the row height. */
.epp-tile--feature > .epp-tile__body > :last-child { margin-bottom: 0; }

.epp-steps-wrap { grid-column: 1 / -1; padding: 0 28px 28px; }
.epp-tile--pair:has(.epp-steps-wrap) .epp-tile__body { padding-bottom: 20px; }

.epp-bento { display: grid; grid-template-columns: repeat(6, 1fr); gap: 20px; }

/* TWO COLUMNS LOCKED TO ONE HEIGHT.
   `stretch` is the grid default, so this declares nothing the browser would not
   already do. It is here to be explicit about the contract, because the row has
   been through the alternative: an earlier --hug set `align-items: start` so each
   side kept its own height, and the ragged bottom edge that produced was the
   thing Kim rejected. Both sides are the same height now, always, whichever one
   has more in it.

   The declaration also gives the tablet rules below a scoping hook, which is the
   practical reason not to delete the class and rely on the default. */
.epp-bento--halves { align-items: stretch; }

/* WHERE THE SURPLUS GOES ON THE STRETCHED SIDE.
   Locking the heights means one card is routinely taller than its own content,
   and the clip must not absorb the difference: stretching it is a crop, which is
   what every other rule around here exists to prevent. So the copy keeps the top
   of the card, the clip is pinned to the bottom edge, and the surplus sits
   between them where it reads as spacing. Left under the clip it would read as a
   caption that failed to load. */
.epp-bento--halves > .epp-tile--stack .epp-slot { margin-top: auto; }

/* A STACK OF CARDS OCCUPYING ONE GRID CELL. The bento places cards, not groups,
   so two cards that belong in the same column need a box of their own; the gap
   matches the bento's so the stacked seam is indistinguishable from a grid gap.
   Span is a modifier because the cell width is a layout decision per row. */
.epp-bento__col { display: flex; flex-direction: column; gap: 20px; min-width: 0; }
.epp-bento__col--w3 { grid-column: span 3; }

/* THE DEVICE CLIP KEEPS ITS OWN RESOLUTION IN A STACKED CARD.
   A stacked tile hands its slot `flex: 1` and its clip `object-fit: cover` so an
   empty placeholder fills the card. On real footage that crops the picture to
   whatever height the row happened to produce, and a cropped UI recording is a
   zoomed fragment of a table rather than a device list. flex:none + height:auto
   renders the file at its native 894x546 ratio, downscaled to the card width and
   therefore sharp, never upscaled and never cropped.

   It matters even though the card currently sets its own height: the moment the
   column beside it grows past this card, the default would silently start
   cropping, with nothing to warn you. */
/* SPECIFICITY, NOT POSITION, and this rule is the proof of why that matters.
   The first version of it read `.epp-tile--stack .epp-slot.epp-slot--devices`,
   three classes, exactly tying `.epp-tile--stack .epp-slot:has(.epp-slot__video)`
   further down this file, which therefore won on order and kept `flex: 1`. The
   clip then absorbed the row's surplus: 401px tall against its true 371px, a 7%
   vertical stretch that measured as ratio drift and looked like a slightly wrong
   aspect nobody could name. Prefixing with --halves takes both rules past the
   defaults for good.

   `object-fit: cover` had to be beaten the same way: with the slot filling, cover
   is what does the actual stretching. */
.epp-bento--halves .epp-tile--stack .epp-slot.epp-slot--devices { flex: none;
  background: transparent; }
.epp-bento--halves .epp-tile--stack .epp-slot.epp-slot--devices .epp-slot__video {
  height: auto; object-fit: initial; }

/* In a STACKED tile the clip is the last element, so filling the leftover
   height is right: the alternative is a grey band under it. */
.epp-tile--stack .epp-slot:has(.epp-slot__video) { flex: 1; }
.epp-tile--stack .epp-slot:has(.epp-slot__video) .epp-slot__video { height: 100%;
  object-fit: cover; }

/* In a SIDE-BY-SIDE tile it is not: the column's height comes from the copy
   beside it, which has nothing to do with the clip's shape, so filling it would
   crop the picture to fit a number the copy happened to produce. Here the clip
   keeps its own aspect ratio and centres against the copy, and the slot shrinks
   to the clip so no grey shows. */
.epp-tile--pair .epp-slot:has(.epp-slot__video) { flex: none; align-self: stretch; }
.epp-tile--pair .epp-slot:has(.epp-slot__video) .epp-slot__video { height: auto; }
.epp-tile { background: #ffffff; border: 1px solid var(--epp-light);
  border-radius: 12px; overflow: hidden;
  /* Transform and colour only. An earlier build put padding in the hover state
     and every tile reflowed under the cursor. */
  transition: border-color .25s cubic-bezier(.4,0,.2,1),
              transform .25s cubic-bezier(.4,0,.2,1),
              box-shadow .25s cubic-bezier(.4,0,.2,1); }
.epp-tile:hover { border-color: var(--epp-blue); transform: translateY(-3px);
  box-shadow: 0 14px 32px rgba(0,0,0,.07); }
.epp-tile--w2 { grid-column: span 2; }
.epp-tile--w3 { grid-column: span 3; }
.epp-tile--w4 { grid-column: span 4; }
.epp-tile--w6 { grid-column: span 6; }
.epp-tile--tall { grid-row: span 2; }
.epp-tile--stack { display: flex; flex-direction: column; }
/* BOTH cells need min-width:0, which is what minmax(0,1fr) says. With a plain
   1fr the media slot's intrinsic width steals the text column and the copy
   clips. */
.epp-tile--pair { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); }
.epp-tile__body { padding: 28px; min-width: 0; }
/* A stack tile's copy sits ON a media area, so the design closes it at 20px
   rather than the 28 a self-contained tile gets. :not(--w4) because the Scene
   Cues tile already carries its own 30/32/24/32. */
.epp-tile--stack:not(.epp-tile--w4) .epp-tile__body { padding-bottom: 20px; }
.epp-tile--w4 .epp-tile__body { padding: 30px 32px 24px; }
/* 600, against the page's own `.epp--splay h3 { font-weight: 400 }`. At 22px a
   400 heading sits at body weight, so the card titles read as the first line of
   the paragraph rather than as a title, and the whole bento flattens out. It also
   inverted the hierarchy: h4 on this page is 600, so the sub-headings were
   heavier than the headings above them.

   Scoped to the tiles, not raised on the page's h3 generally. The three standalone
   h3s (User manual at 26px, S-PLAY Programmer, System requirements) head their own
   bands, and at 600 they out-shout the real h2s, which are deliberately 400 at
   38px and up. Big and light for section headings, small and semi-bold for card
   titles: the size carries the rank, the weight carries presence at small sizes.

   Montserrat is loaded as a variable face (100 to 900), so 600 is a real weight
   here and not a synthesised bold, and it costs no extra download. */
.epp--splay .epp-tile h3 { font-size: 22px; line-height: 1.25; font-weight: 600;
  color: var(--epp-black); margin: 0 0 10px; }
.epp--splay .epp-tile--w4 h3 { font-size: 27px; line-height: 1.2; margin: 16px 0 10px; }

/* The feature card's heading breaks in the markup, not here: see the <br> and the
   note beside it in s-play-programmer.2026.php. A max-width plus
   text-wrap:balance lived here and was removed on 2026-09-02, because balancing
   the two halves is precisely what stops the break landing where it was asked
   to. Do not reintroduce a width cap on this heading; it will fight the <br>. */
.epp-tile p { font-size: 16px; line-height: 1.6; color: var(--epp-mid);
  text-wrap: pretty; }
/* 22px, measured off the design rather than read out of its source: there the
   icon is an inline SVG and its line box contributes the other 6. */
.epp-tile__icon { display: block; color: var(--epp-blue); margin-bottom: 22px; }
/* The badge owns its own gap to the heading below. It used to borrow one from
   `.epp-tile--w4 h3 { margin: 16px 0 10px }`, so when this tile stopped being w4
   the badge ended up sitting directly on the h3. Set here, it holds at any tile
   width. */
/* width:max-content, not just inline-block. Anchoring the copy to the bottom made
   .epp-tile__body a column flex container, and a flex item with width:auto
   stretches on the cross axis, so the badge grew to the full column and its
   border read as a bar rather than a chip. An explicit width opts out.

   500, not 700. The weight was borrowed from the section eyebrows, but those sit
   on the page background with nothing around them; this one already has a border
   and a colour doing the work, and bold on top of both read as shouting. */
.epp-tile__badge { display: inline-block; width: max-content;
  margin-bottom: 14px; padding: 5px 11px;
  border: 1px solid var(--epp-blue); border-radius: 4px;
  font-family: var(--enttec-font-display); font-weight: 500; font-size: 12px;
  letter-spacing: .13em; text-transform: uppercase; color: var(--epp-blue); }
/* The verified support caveat, not decoration: with nothing connected there is
   no DMX output, and a reader who does not know that will think the software is
   broken. */
/* p.epp-tile__inset, not .epp-tile__inset: the inset IS a <p> inside .epp-tile,
   so `.epp-tile p` above outranked it and the caveat rendered at the tile's
   16px/1.6 instead of the 14.5px/1.55 the handover specifies, 38px too tall. */
/* A notice, not just an indented paragraph. This is the one place on the page
   that corrects an expectation ("I plugged in nothing and nothing lit up"), so
   it needs to read as a heads-up at a glance rather than as more body copy.

   Blue icon on the grey ground rather than a fully blue callout: blue is this
   page's "this is the good bit" colour (the value ticks, the primary button,
   the kept row in the chain), and spending it on a caveat would dilute it. The
   icon alone is enough to mark the block as a note.

   Info, not a warning triangle. Nothing is wrong here: no DMX output with no
   hardware attached is correct behaviour, and a warning would tell the reader
   the software is broken when the next sentence is the workaround. */
/* flex-start, not center: the icon marks the note, so it belongs level with the
   note's first line rather than floating beside line three. The 2px nudge stays:
   the icon is 18px in a 22.5px line box, so 2px drops it onto the first line's
   optical centre instead of its box top. */
.epp-tile .epp-tile__inset { display: flex; align-items: flex-start; gap: 11px;
  padding: 14px 16px; background: var(--epp-grey); border-radius: 8px;
  margin: 0; }
.epp-tile__inset-icon { flex: none; margin-top: 2px; color: var(--epp-blue); }

/* ===========================================================================
   ICON MICRO-ANIMATION
   Every icon on the page, in three tiers, because "animate the icons" is not one
   decision: an icon attached to a button, a decorative icon inside a card and a
   status tick all want different things.

   TIER 1, icons that belong to a control: move in the direction of the action.
   The motion is a hint about what the control does, so it has to match the verb.
   TIER 2, decorative card icons: react to the card, not to themselves. They are
   not hoverable targets, so the trigger is the card's hover/focus-within, and
   two of them move the way their own glyph implies.
   TIER 3, status icons (ticks, info): draw once or stay still. A checkmark that
   draws itself reads as confirmation; an info icon that moves reads as an alert
   it is not.

   ONE DURATION TOKEN so the whole page shares a tempo, and the semantic
   exceptions (the sweep, the turn) are the only things allowed to run longer,
   because they travel further.

   NOTHING HERE IS THE ONLY SIGNAL. Every state these decorate is already carried
   by colour, position or text, so the reduced-motion block at the end can switch
   all of it off without removing information.
   =========================================================================== */
.epp { --epp-icon-dur: .28s; }

/* --- TIER 1: control icons ------------------------------------------------ */
.epp-dl__main svg, .epp-dl__ico, .epp-btn svg, .epp-reqs__tab svg,
.epp-manual__icon svg, .epp-manual__arrow {
  transition: transform var(--epp-icon-dur) var(--eds-ease); }
/* Down, because it is a download arrow and down is where the file goes. */
.epp-dl__btn:hover .epp-dl__main svg { transform: translateY(2px); }
/* The OS logos are identity, not direction, so they scale. */
.epp-dl__item:hover .epp-dl__ico { transform: scale(1.12); }
.epp-btn:hover svg { transform: scale(1.1); }
.epp-reqs__tab:hover svg { transform: scale(1.1); }
/* The manual link's glyph pair moves as one gesture: the book leans in, the
   diagonal arrow travels along its own axis, up and to the right. */
.epp-manual:hover .epp-manual__icon svg { transform: scale(1.08); }
.epp-manual:hover .epp-manual__arrow { transform: translate(2px, -2px); }

/* --- TIER 2: card icons --------------------------------------------------- */
/* focus-within as well as hover, so a keyboard user reaching a link inside the
   card gets the same feedback a mouse user does. */
.epp-tile__icon { transition: transform var(--epp-icon-dur) var(--eds-ease); }
.epp-tile:hover .epp-tile__icon,
.epp-tile:focus-within .epp-tile__icon { transform: translateY(-2px) scale(1.09); }

/* The stopwatch holds still and its HAND sweeps a quarter turn. The glyph itself
   deliberately does not scale here: scaling the dial while the hand pivots about
   a fixed point in the dial makes the pivot visibly drift off centre. Slight
   overshoot on the easing, because a sweep hand settles rather than stopping
   dead. transform-box is set explicitly so the origin is read in viewBox units
   (12,14 is the dial's centre) rather than the element's own tight bounding box. */
.epp-tile__icon-hand { transform-box: view-box; transform-origin: 12px 14px;
  transition: transform .45s cubic-bezier(.34, 1.32, .64, 1); }
.epp-tile:hover .epp-tile__icon--timer,
.epp-tile:focus-within .epp-tile__icon--timer { transform: translateY(-2px); }
.epp-tile:hover .epp-tile__icon--timer .epp-tile__icon-hand,
.epp-tile:focus-within .epp-tile__icon--timer .epp-tile__icon-hand {
  transform: rotate(90deg); }

/* The cycle glyph turns, since turning is what it depicts. A HALF turn, not a
   full one: the glyph is two arcs 180 degrees apart, so 180 lands it back on
   itself and reads as one continuous rotation. A full 360 reads as a spinner,
   which means "waiting" and is the wrong word for this card. */
.epp-tile__icon--cycle { transition: transform .5s var(--eds-ease); }
.epp-tile:hover .epp-tile__icon--cycle,
.epp-tile:focus-within .epp-tile__icon--cycle {
  transform: translateY(-2px) rotate(180deg); }

/* --- TIER 3: status icons ------------------------------------------------- */
/* The checkmarks draw themselves on arrival, left to right, 70ms apart. The path
   is `M20 6 9 17l-5-5`, about 23 user units long, so 24 covers it with no
   remainder showing. Banner ticks run on load because they are above the fold;
   the resources panel's run off .epp-in so they draw when scrolled to rather
   than having finished before anyone saw them. */
@keyframes epp-tick-draw { from { stroke-dashoffset: 24; } to { stroke-dashoffset: 0; } }
.epp-ticks svg path,
.epp-js .epp-reveal .epp-dlpanel__ticks svg path {
  stroke-dasharray: 24; stroke-dashoffset: 24; }
.epp-ticks li:nth-child(1) svg path { animation: epp-tick-draw .5s var(--eds-ease) .10s forwards; }
.epp-ticks li:nth-child(2) svg path { animation: epp-tick-draw .5s var(--eds-ease) .17s forwards; }
.epp-ticks li:nth-child(3) svg path { animation: epp-tick-draw .5s var(--eds-ease) .24s forwards; }
.epp-js .epp-reveal.epp-in .epp-dlpanel__ticks li:nth-child(1) svg path { animation: epp-tick-draw .5s var(--eds-ease) .06s forwards; }
.epp-js .epp-reveal.epp-in .epp-dlpanel__ticks li:nth-child(2) svg path { animation: epp-tick-draw .5s var(--eds-ease) .13s forwards; }
.epp-js .epp-reveal.epp-in .epp-dlpanel__ticks li:nth-child(3) svg path { animation: epp-tick-draw .5s var(--eds-ease) .20s forwards; }
.epp-js .epp-reveal.epp-in .epp-dlpanel__ticks li:nth-child(4) svg path { animation: epp-tick-draw .5s var(--eds-ease) .27s forwards; }
/* Without JS there is no .epp-in to wait for, so they are simply drawn. */
.epp:not(.epp-js) .epp-dlpanel__ticks svg path { stroke-dasharray: none; }

/* The info icon gets the quietest move on the page, and only because its card is
   already moving under it. It must not read as an alert: it is a footnote. */
.epp-tile__inset-icon { transition: transform var(--epp-icon-dur) var(--eds-ease); }
.epp-tile:hover .epp-tile__inset-icon { transform: scale(1.06); }

/* --- REDUCED MOTION ------------------------------------------------------- */
/* Everything above, off. The ticks are left DRAWN rather than mid-animation,
   which is the state that carries the meaning; the rest sit at rest, and no
   information is lost because none of it was motion-only to begin with. */
@media (prefers-reduced-motion: reduce) {
  .epp-dl__main svg, .epp-dl__ico, .epp-btn svg, .epp-reqs__tab svg,
  .epp-manual__icon svg, .epp-manual__arrow,
  .epp-tile__icon, .epp-tile__icon-hand, .epp-tile__inset-icon {
    transition: none; }
  .epp-dl__btn:hover .epp-dl__main svg,
  .epp-dl__item:hover .epp-dl__ico,
  .epp-btn:hover svg,
  .epp-reqs__tab:hover svg,
  .epp-manual:hover .epp-manual__icon svg,
  .epp-manual:hover .epp-manual__arrow,
  .epp-tile:hover .epp-tile__icon,
  .epp-tile:focus-within .epp-tile__icon,
  .epp-tile:hover .epp-tile__icon--timer,
  .epp-tile:focus-within .epp-tile__icon--timer,
  .epp-tile:hover .epp-tile__icon--timer .epp-tile__icon-hand,
  .epp-tile:focus-within .epp-tile__icon--timer .epp-tile__icon-hand,
  .epp-tile:hover .epp-tile__icon--cycle,
  .epp-tile:focus-within .epp-tile__icon--cycle,
  .epp-tile:hover .epp-tile__inset-icon { transform: none; }
  .epp-ticks svg path,
  .epp-dlpanel__ticks svg path { animation: none; stroke-dasharray: none;
    stroke-dashoffset: 0; }
}

/* A tile sharing a grid row with a taller one gets stretched past its content.
   Pushing the notice to the bottom turns that slack into a deliberate top/bottom
   split rather than a hole under the last line. margin-top:auto only bites when
   there IS slack, so an unstretched tile is unchanged. */
.epp-tile:has(.epp-tile__inset) { display: flex; flex-direction: column; }
.epp-tile:has(.epp-tile__inset) .epp-tile__body { flex: 1; display: flex;
  flex-direction: column; }
/* THE GUARD THAT KEEPS A PAIR TILE A PAIR. The rule above is 0,2,0 and lands
   after `.epp-tile--pair { display: grid }` at 0,1,0, so the moment a
   side-by-side tile gained a notice it silently collapsed to a single flex
   column and the media dropped under the copy. It bit the instant the device
   list tile became w6 (2026-09-02); nothing warns you, the layout just changes.
   Same 0,2,0 specificity, declared later, so grid wins back. The body keeps the
   flex column it got above, which is what the notice's auto margins need. */
.epp-tile--pair:has(.epp-tile__inset) { display: grid; }
/* Where the row slack goes. Measured at 1512 and 1920, which are the same: the
   grid is a fixed six-column container, so the row is 460px at both, set by the
   video tile beside it. This tile's own content wants 382px, leaving 78px.

   It used to grow into that slack, capped at 170px, which failed twice over: 52px
   landed inside the box (the text floating with ~26px of dead grey above and
   below it) and the remaining 27px leaked out under the box anyway. The same
   grow-and-cap trick still works on .epp-chain next door because three rows there
   split 52px between them, about 16px each, which nobody reads as a gap. One box
   asked to swallow 78px does.

   Growth is also incompatible with the icon sitting on the first line above: the
   icon can only line up with line one if the text is top-aligned too, which it
   cannot be while the box is taller than its text. So the box hugs its text and
   the leftover is split by the two auto margins, 39px each once the paragraph
   above it has taken its 16px join: 55px of air above the note, 67px below it with
   the card's own padding. Pinning it to the bottom instead was tried and
   rejected, it only moved the whole 78px into one visible band. Split, no single
   gap reads as a hole. */
.epp-tile .epp-tile__inset { margin-top: auto; margin-bottom: auto; flex: none; }
.epp-tile .epp-tile__inset p { font-size: 14.5px; line-height: 1.55;
  color: var(--epp-mid); text-wrap: pretty; margin: 0; }

/* Visually hidden, still announced. Same clip technique as .epp-gallery__status,
   promoted to a utility because it is now wanted in more than one place. Clipped
   rather than display:none, which would remove it from the accessibility tree
   and defeat the point. */
.epp-vh { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
  overflow: hidden; white-space: nowrap; clip: rect(0 0 0 0); clip-path: inset(50%);
  border: 0; }

/* A screenshot used as evidence rather than decoration, on the same grey ground
   and radius as .epp-tile__inset so the two read as one family of supporting
   blocks instead of two separate inventions.

   The image is pinned to its native 269px and never scaled up. It is a crop of
   real 11px UI type, so stretching it to the tile width would blur the only two
   words the tile is actually about. max-width keeps it inside the body once the
   bento collapses to one column on a phone.

   Stretches into row slack the way the inset does: this tile shares its grid row
   with the taller interface carousel, so without it a hole opens under the
   caption. */
.epp-tile .epp-tile__shot { display: flex; flex-direction: column;
  align-items: center; justify-content: center; gap: 10px;
  margin: 16px 0 0; padding: 14px 16px; flex: 1 1 auto;
  background: var(--epp-grey); border-radius: 8px; }
/* The crop carries the app's own white row background, which would otherwise
   read as a transparent PNG sitting wrong on the grey. The hairline and radius
   make it read as a deliberate fragment of the interface instead. */
.epp-tile__shot img { display: block; width: 269px; max-width: 100%; height: auto;
  background: #ffffff; border: 1px solid var(--epp-light); border-radius: 6px; }
.epp .epp-tile__shot figcaption { font-size: 14px; line-height: 1.5;
  color: var(--epp-mid); text-align: center; text-wrap: pretty; }
.epp-tile:has(.epp-tile__shot) { display: flex; flex-direction: column; }
.epp-tile:has(.epp-tile__shot) .epp-tile__body { flex: 1; display: flex;
  flex-direction: column; }

/* The generic `:where(.epp) p { margin: 0 0 1.2em }` puts 19.2px under every
   tile paragraph. The design spaces these three joins by hand: nothing before
   the step cards (they carry the 22px themselves), 16px before the grey inset,
   20px before the chain. Stated per join rather than by moving the generic,
   which the rest of the page still wants. */
.epp--splay .epp-tile__body > p:has(+ .epp-steps)       { margin-bottom: 0; }
/* 16px, and it is now the ONLY source of that join. The inset used to carry the
   16px itself, so this was zeroed to stop the two adding into a 32px trench
   (flex children, whose adjacent margins do not collapse). The inset now uses
   auto margins to split its row slack, and auto resolves to zero wherever there
   is no slack: below 900 the tiles are a single column and size to their own
   content, so without this the note would butt straight onto the paragraph.
   Stated here rather than on the inset because a fixed margin and an auto one
   cannot both live on the same side. Measured at 390px. */
.epp--splay .epp-tile__body > p:has(+ .epp-tile__inset) { margin-bottom: 16px; }
.epp--splay .epp-tile__body > p:has(+ .epp-tile__shot)  { margin-bottom: 0; }
.epp--splay .epp-tile__body > p:has(+ .epp-chain)       { margin-bottom: 20px; }

.epp-steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px;
  margin-top: 22px; }
/* The steps are the clip's chapters now, so they are real buttons: they seek the
   video below, and it marks them back as it plays. text-align and the appearance
   resets are because a <button> brings its own centring and chrome. */
.epp-step { display: block; width: 100%; text-align: left; font: inherit;
  color: inherit; border: 1px solid transparent; cursor: pointer;
  padding: 14px 16px; background: var(--epp-grey); border-radius: 8px;
  transition: background .25s var(--eds-ease), border-color .2s var(--eds-ease),
              transform .25s cubic-bezier(.4,0,.2,1); }
.epp-step:hover { background: rgba(14,165,223,.09); transform: translateY(-2px); }
.epp-step:focus-visible { outline: 2px solid var(--epp-blue); outline-offset: 2px; }
/* The playing chapter. Colour AND the progress bar, never motion alone, so the
   state survives prefers-reduced-motion and colour-blindness both. */
.epp-step[aria-current="true"] { background: rgba(14,165,223,.09);
  border-color: var(--epp-blue); }
.epp-step__d { display: block; margin-top: 5px; font-size: 14px; line-height: 1.5;
  color: var(--epp-mid); }

/* A 2px rule under the active step showing how far through that chapter the
   clip is. Reserved on every step (not just the active one) so nothing shifts
   by 2px as the highlight moves along. */
.epp-step__bar { display: block; margin-top: 12px; height: 2px; border-radius: 2px;
  background: rgba(0,0,0,.08); overflow: hidden; }
.epp-step__fill { display: block; height: 100%; width: 0;
  background: var(--epp-blue); }
.epp-step[aria-current="true"] .epp-step__fill { transition: width .25s linear; }
.epp-step__n { display: block; font-family: var(--enttec-font-display); font-weight: 700;
  font-size: 12px; letter-spacing: .12em; text-transform: uppercase;
  color: var(--epp-blue); }
.epp-step__t { display: block; margin-top: 5px;
  font-family: var(--enttec-font-display); font-size: 16px;
  color: var(--epp-black); }
.epp-step p { margin: 5px 0 0; font-size: 14px; line-height: 1.5; }

/* The old chain struck through, then the replacement. Two greys and one blue
   row: the contrast IS the argument the tile is making. */
/* ---------------------------------------------------------------------------
   THE CHAIN
   Two things that go away, then the one thing that replaces them. Stacked, so
   the blue item lands last and reads as the conclusion: side by side the three
   looked parallel and the before/after argument flattened out. Order is
   load-bearing, do not reorder these.

   Each row carries the icon for what it actually IS rather than a generic tick
   or cross: a key for the package you had to licence, a clock for the hours
   spent recording back in real time, the app itself for what replaces both.
   --------------------------------------------------------------------------- */
.epp-chain { display: flex; flex-direction: column; gap: 8px; flex: 1; }
.epp-tile:has(.epp-chain) { display: flex; flex-direction: column; }
.epp-tile:has(.epp-chain) .epp-tile__body { flex: 1; display: flex;
  flex-direction: column; }

.epp-chain__item { display: flex; align-items: center; gap: 14px;
  padding: 14px 16px; border: 1px solid var(--epp-light);
  border-radius: 10px; background: var(--epp-grey); }
.epp-chain__icon { flex: none; color: var(--epp-mid); }
.epp-chain__t { font-size: 15px; line-height: 1.45; color: var(--epp-mid);
  text-wrap: pretty; }
.epp-chain__item--old .epp-chain__t { text-decoration: line-through; }
.epp-chain__item--new { border-color: var(--epp-blue);
  background: rgba(14,165,223,.07); }
.epp-chain__item--new .epp-chain__icon { color: var(--epp-blue); }
.epp-chain__item--new .epp-chain__t { color: var(--epp-black); }

/* The strike-through and the blue tint say "gone" and "kept" with colour and
   decoration only, neither of which a screen reader conveys. This carries the
   same distinction in words. */
.epp-chain__sr { position: absolute; width: 1px; height: 1px; margin: -1px;
  padding: 0; overflow: hidden; white-space: nowrap; clip: rect(0 0 0 0);
  clip-path: inset(50%); border: 0; }

/* Above 900 this tile shares a grid row with the interface carousel, which is
   taller, so the card is stretched past its content. The three rows grow to take
   that slack, with their text centred, rather than leaving an empty band at the
   bottom of the card. Capped, or a taller carousel would silently inflate these
   into three enormous bars; past the cap the remainder is ordinary padding.

   Below 900 the tiles are a single column and size to their own content, so
   there is no slack to absorb and no growth: the rows must be free to be as
   tall as their text needs, which at 390 is three lines. */
@media (min-width: 901px) {
  .epp-chain__item { flex: 1 1 auto; max-height: 96px; }
}
@media (max-width: 900px) {
  .epp-chain__icon { width: 22px; height: 22px; }

  /* THE STEP CARDS STACK, they do not fold away. Below 640 they used to be
     hidden outright, on the grounds that 3 across gives each card 86px at 390px
     and that stacking cost 468px. Three across at that width is genuinely
     unreadable, but hiding them was the wrong conclusion from it: these are the
     clip's chapters, so hiding them takes the section's only interaction away on
     the one device where scrubbing a video by hand is hardest. It also took the
     "20 to 60 FPS" figure with it, which lives in step 03's description and
     nowhere else on the page, so a phone reader lost a sourced claim outright.

     900 rather than 640 because three across stops working well above a phone,
     and 900 is already this sheet's single-column line rather than a new number.
     Measured per card: 153px wide and 212px tall at 641, 196x172 at 768, 240x151
     at 900. Only from about 900 up do the three read as a row rather than as
     three slivers of wrapped text.

     Stacking as authored cost 448px at 390. Hanging the number beside the title
     instead of above it brings that to 370px, for a 819px tile that is one phone
     screen, with rows at 99px, well over the 44px tap target. Nothing is cut. */
  .epp-steps { grid-template-columns: 1fr; gap: 8px; margin-top: 14px; }
  /* The number in a narrow first column, the title and its description sharing
     the second so they hang off a common indent. The bar spans both, because it
     measures the whole chapter rather than either column. */
  .epp-step { display: grid; grid-template-columns: auto 1fr; column-gap: 10px;
    padding: 11px 14px; }
  .epp-step__n { grid-column: 1; align-self: baseline; }
  .epp-step__t { grid-column: 2; margin-top: 0; }
  .epp-step__d { grid-column: 2; margin-top: 3px; }
  .epp-step__bar { grid-column: 1 / -1; margin-top: 9px; }
}

.epp-tile--stats { display: grid; grid-template-columns: 1.1fr 2fr; gap: 36px;
  align-items: center; padding: 28px 32px; }
.epp-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; }
.epp-stat { transition: transform .25s cubic-bezier(.4,0,.2,1); }
.epp-stat:hover { transform: translateY(-2px); }
.epp-stat__n { font-family: var(--enttec-font-display); font-weight: 400;
  font-size: 34px; line-height: 1; color: var(--epp-blue); }
.epp-stat__l { margin-top: 6px; font-size: 15px; line-height: 1.4;
  color: var(--epp-mid); }

.epp-bento__note { margin: 26px 0 0; font-size: 15px; line-height: 1.6;
  color: var(--epp-mid); max-width: 80ch; }

.epp-tile code { background: rgba(14,165,223,.12); padding: 1px 6px;
  border-radius: 4px; font-size: .94em; }

/* ---------------------------------------------------------------------------
   MEDIA PLACEHOLDERS
   Every screen recording and screenshot on this page is still to be shot. The
   placeholder carries the brief (length, aspect, what has to be on screen) so
   whoever shoots it knows what is needed. An empty grey box would lose that the
   first time somebody tidied the markup.
   --------------------------------------------------------------------------- */
.epp-slot { position: relative; background: var(--epp-light);
  display: grid; place-items: center; padding: 24px; overflow: hidden; }
.epp-tile .epp-slot { border-top: 1px solid var(--epp-light); flex: 1; }
.epp-tile--pair .epp-slot { border-top: 0; border-left: 1px solid var(--epp-light);
  min-height: 230px; }
/* Sized by class, not by a style attribute on the element. An inline style
   cannot be beaten by a media query, so while these two heights lived in the
   markup every tablet and mobile override written for them below was dead and
   both slots kept their 1440 height all the way down to 390. */
.epp-slot--scene   { min-height: 330px; }
.epp-slot--devices { min-height: 190px; }
/* A slot holding real media takes the MEDIA's shape rather than imposing its
   own. The placeholder heights below exist to reserve space while a slot is
   still empty; once a clip is in, cropping it to a height that was only ever a
   guess throws away picture for no reason. So: drop the placeholder padding and
   centring, let the video lay out normally at width:100%, and the slot's height
   follows the file's own aspect ratio.

   The point of doing it this way rather than declaring an aspect-ratio is that
   there is no number to keep in sync. Re-shoot the clip at a different shape and
   the tile just follows it, at every breakpoint, with no CSS to revisit.

   flex/min-height are reset because .epp-tile .epp-slot sets flex:1 to make an
   empty placeholder fill the tile, which would stretch the video back out. */
/* No rule above real media, of any kind. The divider exists to separate copy
   from an empty grey placeholder; over footage or a screenshot it reads as a
   seam, and the media's own top edge already ends the copy.

   Keyed on "does this slot contain media" rather than on each media class, so a
   new kind of slot cannot quietly reintroduce the line. Restated in the tablet
   block below because that block re-adds a top border for the pair tile. */
.epp-slot:has(.epp-slot__video) { display: block; padding: 0; flex: none;
  min-height: 0; }
.epp-tile .epp-slot:has(video, img),
.epp-tile--pair .epp-slot:has(video, img) { border-top: 0; border-left: 0; }
.epp-slot__video { display: block; width: 100%; height: auto; }
/* The brief badge sits on top of the media rather than under it. */
.epp-slot__badge { z-index: 1; }

/* ---------------------------------------------------------------------------
   UI CAROUSEL
   Seven screenshots of the S-PLAY web interface, cycling with the page name on
   each. Same principle as the video slot above: the slot takes the media's
   shape, so there is no height to keep in sync with the assets.

   The slides are STACKED IN ONE GRID CELL rather than absolutely positioned.
   That way every slide still contributes to sizing, so the container is as tall
   as the images actually are and nothing collapses to zero height the way an
   all-absolute stack would.
   --------------------------------------------------------------------------- */
.epp-slot--ui { display: block; padding: 0; flex: none; min-height: 0;
  background: #ffffff; }
.epp-shots { display: grid; margin: 0; padding: 0; list-style: none; }
.epp-shot { grid-area: 1 / 1; margin: 0; position: relative;
  opacity: 0; visibility: hidden;
  transition: opacity .5s var(--eds-ease), visibility 0s linear .5s; }
/* visibility rides along so a hidden slide is out of the accessibility tree and
   cannot be tabbed into, which opacity alone does not do. */
.epp-shot.is-active { opacity: 1; visibility: visible; transition-delay: 0s; }
/* Cropped to 16:9, anchored to the top of each shot.

   Their true shape is 1.19:1, which makes this tile about 180px taller than the
   card beside it and stretches the whole grid row with it. 16:9 keeps what
   carries the argument (the ENTTEC mark, the page nav, the top of the working
   area) and is a touch taller than the 2:1 this first shipped with.

   Anchored to the top, and a single shared ratio, for a second reason: the
   seven captures do NOT share a content extent. Cues and Playlists fill their
   frame, while Events, Triggers and Interfaces are 36 to 38% blank white below
   the fold of the captured window. Sized naturally the carousel takes the
   tallest, so three slides in seven render as mostly nothing and the height
   jumps as it cycles.

   Cues and Playlists do have real content below this crop. If it is wanted, the
   fix is re-capturing those two tighter, not lengthening the crop, which puts
   the empty slides back. */
.epp-shot img { display: block; width: 100%; height: auto;
  aspect-ratio: 16 / 9; object-fit: cover; object-position: top center; }

.epp-shot__label { position: absolute; left: 12px; bottom: 12px;
  padding: 5px 11px; background: rgba(0,0,0,.72); border-radius: 4px;
  font-family: var(--enttec-font-display); font-weight: 600; font-size: 12px;
  letter-spacing: .08em; text-transform: uppercase; color: #ffffff; }

/* The dots sit ON the screenshot, and a screenshot of a UI is busy, pale and
   unpredictable underneath. A scrim gives them a constant ground so the active
   dot is findable on the Cues table and on an empty Interfaces page alike,
   instead of only where the pixels behind it happen to cooperate. */
.epp-shots__dots { position: absolute; right: 12px; bottom: 12px;
  display: flex; gap: 4px; padding: 3px 5px;
  background: rgba(255,255,255,.88); border-radius: 999px;
  box-shadow: 0 1px 4px rgba(0,0,0,.12); }
/* 20px of tappable area around a 7px dot. The dot is what you see, the button
   is what you hit: seven 7px targets in a row would fail every touch guideline
   going (Fitts, and the 24px minimum). */
.epp-shots__dot { width: 18px; height: 18px; padding: 0; border: 0;
  background: none; cursor: pointer; display: grid; place-items: center; }
.epp-shots__dot::before { content: ""; width: 7px; height: 7px;
  border-radius: 50%; background: rgba(0,0,0,.28);
  transition: background .2s var(--eds-ease), transform .2s var(--eds-ease); }
.epp-shots__dot:hover::before { background: rgba(0,0,0,.5); }
.epp-shots__dot[aria-current="true"]::before { background: var(--epp-blue);
  transform: scale(1.25); }
.epp-shots__dot:focus-visible { outline: 2px solid var(--epp-blue);
  outline-offset: 1px; border-radius: 50%; }

/* The pair tile puts this slot in a narrow column. Once it carries a carousel
   the tile stacks instead, so the screenshots get the tile's full width: at
   304px a 943px screenshot renders at 0.32x and stops being recognisable,
   which is the one thing this tile needs it to be. */
/* Behaves as a stack tile once it carries the carousel, padding included: a
   self-contained tile closes at 28px, but a tile whose copy sits ON a media area
   closes at 20px, and this is now the latter. Without this the gap under this
   heading was 8px deeper than under every other heading above a media area. */
/* One column, spanning two rows, with the two short tiles stacked beside it.
   Full width the card ran 619px tall and dwarfed the grid; side by side its copy
   column sat nearly empty. Three columns puts the screenshots at ~608px, which
   is where the page nav is comfortably readable, and the two tiles beside it
   very nearly fill the same height on their own. */
.epp-tile--tall:has(.epp-slot--ui) .epp-slot--ui { align-self: start; }

.epp-tile--pair:has(.epp-slot--ui) .epp-slot--ui { border-left: 0;
  border-top: 0; }

@media (prefers-reduced-motion: reduce) {
  .epp-shot { transition: none !important; }
}

.epp-slot__note { max-width: 46ch; text-align: center; font-size: 14px;
  line-height: 1.5; color: var(--epp-mid); margin: 0; }
.epp-slot__badge { position: absolute; top: 12px; left: 12px; padding: 5px 10px;
  background: rgba(0,0,0,.72); border-radius: 4px;
  font-family: var(--enttec-font-display); font-weight: 600; font-size: 12px;
  letter-spacing: .08em; text-transform: uppercase; color: #ffffff;
  pointer-events: none; }

/* ---------------------------------------------------------------------------
   VIDEO DEMO
   --------------------------------------------------------------------------- */
.epp-player { border: 1px solid var(--epp-light); border-radius: 14px;
  overflow: hidden; background: var(--epp-grey); }
.epp-player__bar { display: flex; align-items: center; gap: 10px;
  padding: 12px 16px; border-bottom: 1px solid var(--epp-light); background: #ffffff; }
.epp-player__dots { display: flex; gap: 6px; }
.epp-player__dots span { width: 9px; height: 9px; border-radius: 50%;
  background: var(--epp-light); }
.epp-player__dots span:first-child { background: var(--epp-blue); }
.epp-player__file { font-size: 13px; color: var(--epp-mid); }
/* 16:9, not a fixed 640px. The stage holds a 16/9 poster and then a 16/9
   iframe, and a fixed height letterboxes one or crops the other at some width.
   Black ground so the moment between click and first frame is not a white
   flash. */
.epp-player__stage { position: relative; aspect-ratio: 16 / 9; background: #000000; }
.epp-player__stage .epp-slot { position: absolute; inset: 0; }

/* THE YOUTUBE FACADE.

   A real <button>, not a clickable div: focusable in sequence, Enter and Space
   activate it, and a screen reader announces it as a control. All three would
   have to be rebuilt by hand otherwise, and are usually rebuilt wrong.

   The poster is served from this plugin, never hotlinked from i.ytimg.com, so
   the page makes no Google request until someone presses play. See the markup
   comment in s-play-programmer.2026.php for why that is load-bearing. */
.epp-yt { position: absolute; inset: 0; display: block; width: 100%; padding: 0;
  border: 0; background: #000000; cursor: pointer; overflow: hidden; }
.epp-yt__thumb { display: block; width: 100%; height: 100%; object-fit: cover;
  transition: transform .5s cubic-bezier(.4,0,.2,1), opacity .3s linear; }
.epp-yt:hover .epp-yt__thumb,
.epp-yt:focus-visible .epp-yt__thumb { transform: scale(1.03); opacity: .88; }
.epp-yt__play { position: absolute; top: 50%; left: 50%;
  transform: translate(-50%, -50%); display: flex; align-items: center;
  justify-content: center; width: 76px; height: 76px; border-radius: 50%;
  background: var(--epp-blue); color: #ffffff;
  box-shadow: 0 10px 30px rgba(0,0,0,.45);
  transition: transform .3s cubic-bezier(.4,0,.2,1); }
/* A triangle centred by its bounding box reads left of centre inside a circle,
   because its visual mass sits left of its geometric middle. */
.epp-yt__play svg { margin-left: 4px; }
.epp-yt:hover .epp-yt__play,
.epp-yt:focus-visible .epp-yt__play { transform: translate(-50%, -50%) scale(1.08); }
.epp-yt__dur { position: absolute; right: 12px; bottom: 12px; padding: 3px 7px;
  border-radius: 4px; background: rgba(0,0,0,.8); font-size: 13px;
  font-variant-numeric: tabular-nums; color: #ffffff; }
.epp-yt:focus-visible { outline: 3px solid var(--epp-blue); outline-offset: -3px; }
.epp-yt__frame { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }

/* Hover and focus still have to change something without motion, or the control
   loses its affordance for anyone running reduce. The dim stays, the movement
   goes. */
@media (prefers-reduced-motion: reduce) {
  .epp-yt__thumb, .epp-yt__play { transition: none; }
  .epp-yt:hover .epp-yt__thumb,
  .epp-yt:focus-visible .epp-yt__thumb { transform: none; opacity: .88; }
  .epp-yt:hover .epp-yt__play,
  .epp-yt:focus-visible .epp-yt__play { transform: translate(-50%, -50%); }
}


/* 1px gaps showing the band through, rather than borders: four cells with
   borders would double the rule at every seam. */

.epp-chapters { display: flex; gap: 1px; background: var(--epp-light); }
.epp-chapter { flex: 1; padding: 14px 18px; background: #ffffff; border: 0;
  text-align: left; cursor: pointer; box-shadow: inset 0 -3px 0 transparent;
  transition: background .2s var(--eds-ease), box-shadow .2s var(--eds-ease); }
.epp-chapter:hover { background: var(--epp-grey); }
.epp-chapter[aria-current="true"] { box-shadow: inset 0 -3px 0 var(--epp-blue); }
.epp-chapter:focus-visible { outline: 2px solid var(--epp-blue); outline-offset: -2px; }
/* display:block on BOTH, and it is not cosmetic. These are spans, so without it
   the timecode and the title ran together on one line and the title's
   margin-top did nothing on an inline box: the strip came out 56px of one
   wrapped line instead of the design's 67px of two stacked ones. */
.epp-chapter__time { display: block; font-family: var(--enttec-font-display);
  font-weight: 700; font-size: 12px; letter-spacing: .12em; text-transform: uppercase;
  color: var(--epp-mid); transition: color .2s var(--eds-ease); }
.epp-chapter__title { display: block; margin-top: 4px; font-size: 15px;
  color: var(--epp-mid); transition: color .2s var(--eds-ease); }
.epp-chapter[aria-current="true"] .epp-chapter__time { color: var(--epp-blue); }
.epp-chapter[aria-current="true"] .epp-chapter__title { color: var(--epp-black); }

/* THE VIDEO AND ITS CHAPTERS MUST BE ON SCREEN TOGETHER. Picking a chapter is
   the only interaction this section has, and it is pointless if choosing one
   pushes the picture out of view.

   THE MEASUREMENT THAT FORCED THIS. Stacked, at 1920x1080 with the section
   scrolled to the top of the viewport, the strip's bottom landed at 1204px:
   124px below the fold. The stage is 16/9, so its height follows the container
   width, and a 1240px container makes a 696px stage. Add the bar, the caption
   and the strip and the component is 866px before the section's own 88px of
   padding and 119px of heading are counted.

   SHRINKING THE VIDEO WAS THE OBVIOUS FIX AND THE WRONG ONE. Capping the
   player's width did work, but it bought vertical room by making the picture
   smaller and leaving it adrift between two gutters, and the saving shrank again
   as soon as the narrower strip wrapped its titles onto second lines.

   So the chapters move BESIDE the video instead. A wide screen has horizontal
   room going spare, which is the resource actually available, and the same trade
   YouTube makes for a playlist. The video stays full size, the strip becomes a
   vertical list, and the whole component drops to roughly the height of the
   stage alone.

   1101px, not 1200px, and the extra 99px are deliberate. Stacked, the component
   is 829px tall at 1024x768, so every landscape tablet had the same problem as
   the desktop: an iPad Air is 1180x820 and would have missed the fix by 20px.
   1101 is one pixel clear of the existing max-width:1100 tablet block, so the
   two never both apply. Below it, stacked is correct anyway: width is the scarce
   axis there, and a 330px column would leave the video too small to read. */
@media (min-width: 1101px) {
  /* WHITE, not --epp-grey. Whichever column is shorter leaves its cell partly
     empty, and the card's own background fills the hole. Measured at 1280x800:
     the chapter column runs 28px taller than the 16/9 stage, so a 28px band of
     grey sat under the video, right where the picture should meet the card edge.
     Same mistake as the strip painting its grey into space it did not fill.

     translateZ(0) IS NOT DECORATION, and it is the fix for the dark nick Kim saw
     at a bottom corner while the video was playing. A playing YouTube iframe is
     promoted to its own compositing layer, and on some GPU and driver
     combinations a composited layer paints past an ancestor's border-radius,
     poking its square corner out through the card's curve. Forcing this element
     to composite too puts the rounded clip on the same layer as the thing it has
     to clip. It reproduces on hardware-accelerated Chrome and NOT headless,
     which is why the fix is structural rather than something screenshotted. */
  .epp-player { display: grid; grid-template-columns: minmax(0, 1fr) 330px;
    align-items: start; background: #ffffff; transform: translateZ(0); }
  /* Bar spans both columns so the window chrome still reads as one frame. */
  .epp-player__bar { grid-column: 1 / -1; }
  /* WHITE, not --epp-light. The strip paints its own grey and shows it through
     1px flex gaps to draw the dividers, so any space the strip owns but does not
     fill comes out as a grey band. Letting the column carry white means the
     leftover under the last chapter reads as padding. */
  .epp-player__side { display: flex; flex-direction: column;
    align-self: stretch; border-left: 1px solid var(--epp-light);
    background: #ffffff; }
  /* flex:0 0 auto, NOT 1 1 auto. Growing the strip to meet the bottom of the
     video left 17px of its own grey background under the last chapter: a stray
     bar across the foot of the column. The strip is now exactly as tall as its
     six rows and the column's white takes the remainder. */
  .epp-chapters { flex-direction: column; flex: 0 0 auto; }
  /* Tighter rows than the horizontal strip's 14px, because there are eight of
     them stacked now rather than six in a row. At 58px each, eight rows plus
     their hairlines come to 471px against a 469px stage at 1280: the list and
     the picture end level, which is the whole point of the two-column layout. */
  .epp-chapter { flex: 0 0 auto; padding: 9px 18px; }
  /* Centred, because the two columns cannot be the same height at every width:
     the stage's height follows its column width, the list's follows the row
     count. Whichever is shorter, the leftover now splits above and below the
     picture and reads as padding, instead of pooling into one gap at the foot of
     the column. Only bites when there IS slack. */
  .epp-player__stage { align-self: center; }

  /* ALL FOUR CORNERS KEEP THE 14px RADIUS. Worth recording why this was briefly
     squared off and then put back, so nobody squares it again.

     The corner that looked broken was the bottom RIGHT, and the radius was not
     what was wrong with it: the strip was growing past its own rows and painting
     17px of grey across the foot of the column, so the clip was rounding a grey
     bar against white and drawing attention to itself. Squaring the bottom
     removed the symptom and introduced a worse one, a card with rounded chrome
     and a flat base.

     With the column white (see above) the bottom-right clip is white on white
     and invisible, and the bottom-left rounds the video's own corner, which
     reads as a deliberate rounded picture rather than a bite out of one. Fix the
     fill, not the geometry. */
  /* The active marker moves from the bottom edge to the leading edge. A 3px
     underline under a row in a vertical list reads as a divider between two
     rows, not as "you are here", and it points at the wrong neighbour. */
  .epp-chapter[aria-current="true"] { box-shadow: inset 3px 0 0 var(--epp-blue); }
}

/* Two-column is not enough on a SHORT landscape window. Measured at 1180x820:
   72px of section padding plus 148px of heading and intro plus a 44px gap spend
   264px before the video starts, and the last chapter lands just off screen.

   Only the spacing that is pure air is tightened, never the content. Scoped with
   :has() to the one section that has the problem, rather than touching
   .eds-section-head, which the whole catalogue shares. */
@media (min-width: 1101px) and (max-height: 900px) {
  .epp-section:has(.epp-player) { padding-top: 48px; padding-bottom: 56px; }
  .eds-section-head:has(+ .epp-player) { margin-bottom: 24px; }
  .eds-section-head:has(+ .epp-player) > p:last-child { margin-bottom: 0; }
}

/* ---------------------------------------------------------------------------
   RESOURCES
   --------------------------------------------------------------------------- */
.epp-resgrid { display: grid; grid-template-columns: 1.05fr 1fr; gap: 22px;
  align-items: stretch; margin-bottom: 44px; }

.epp-dlpanel { background: #ffffff; border: 1px solid var(--epp-light);
  border-radius: 12px; padding: 28px; display: flex; flex-direction: column; }
.epp-dlpanel__head { display: flex; align-items: center; gap: 16px; }
/* True size 125x62, so width plus height:auto and never a square box, or the
   mark squashes. The logo is INLINE SVG rather than an <img>: the copy on the
   CDN is the WHITE variant and this panel is white, so the black one has to be
   uploaded before a src would work. Swap to an <img> the day it is there. */
.epp-dlpanel__logo { width: 84px; height: auto; flex: none; display: block; }
.epp--splay .epp-dlpanel h3 { font-size: 23px; line-height: 1.2;
  color: var(--epp-black); margin: 0 0 3px; }
.epp-dlpanel__meta { font-size: 14.5px; color: var(--epp-mid); margin: 0; }
.epp-dlpanel__buttons { display: flex; gap: 12px; margin-top: 22px; }
.epp-dlpanel__buttons .epp-btn { flex: 1; height: 52px; padding-inline: 14px; }
.epp-dlpanel__ticks { display: flex; flex-direction: column; gap: 10px;
  margin-top: 22px; padding-top: 20px; border-top: 1px solid var(--epp-light); }
.epp-dlpanel__ticks li { display: flex; align-items: flex-start; gap: 11px;
  font-size: 15px; line-height: 1.5; color: var(--epp-mid); }
.epp-dlpanel__ticks svg { flex: none; margin-top: 3px; color: var(--epp-blue); }

.epp-reqs { background: #ffffff; border: 1px solid var(--epp-light);
  border-radius: 12px; overflow: hidden; display: flex; flex-direction: column; }
.epp-reqs__head { padding: 22px 24px 0; }
.epp--splay .epp-reqs h3 { font-size: 20px; line-height: 1.25;
  color: var(--epp-black); margin: 0; }
.epp-reqs__tabs { display: flex; gap: 26px; padding: 16px 24px 0;
  border-bottom: 1px solid var(--epp-light); }
.epp-reqs__tab { display: inline-flex; align-items: center; gap: 8px;
  padding: 0 0 12px; border: 0; background: none; cursor: pointer;
  font-family: var(--enttec-font-display); font-weight: 600; font-size: 13.5px;
  letter-spacing: .03em; color: var(--epp-mid);
  box-shadow: inset 0 -2px 0 transparent;
  transition: color .2s var(--eds-ease), box-shadow .2s var(--eds-ease); }
.epp-reqs__tab[aria-selected="true"] { color: var(--epp-blue);
  box-shadow: inset 0 -2px 0 var(--epp-blue); }
/* No ring. A 2px blue outline offset off a text tab read as a box drawn around
   the words. A tint of the same blue marks focus without adding a shape, and it
   suits both skins: underlined tabs at desktop, the segmented pill at mobile. */
/* BOOTSTRAP'S REBOOT PUTS A RING ON EVERY BUTTON, and it is a plain :focus:
       button:focus { outline: 1px dotted; outline: 5px auto -webkit-focus-ring-color }
   Plain :focus fires on a MOUSE CLICK, while every focus style on this page is
   written as :focus-visible, which a click on a button does not match. So the
   ring appeared on click and none of our rules were in the running to stop it.
   It also lives in the bootstrap CDN stylesheet, which is cross-origin, so it is
   invisible to any script that walks document.styleSheets: it has to be found by
   reading the computed outline, not by searching the rules.

   Cancelled for this page's own controls only, at a specificity that beats
   (0,1,1). The :focus-visible tints below then supply the keyboard state. */
.epp--splay button:focus,
.epp--splay a:focus { outline: none; }

.epp-reqs__tab:focus-visible { outline: none; background: rgba(14,165,223,.12); }
.epp-reqs__tab svg { flex: none; fill: currentColor; }
.epp-reqs__panel { flex: 1; display: flex; flex-direction: column;
  justify-content: center; padding: 6px 24px 18px; }
/* Same trap as the download menu: display is set on the element above, which
   beats the UA's [hidden]{display:none}, so both panels rendered stacked and the
   card showed the Windows table and the macOS table at once. */
.epp-reqs__panel[hidden] { display: none; }
.epp-reqs__row { display: flex; align-items: baseline; justify-content: space-between;
  gap: 24px; padding: 15px 0; }
.epp-reqs__row + .epp-reqs__row { border-top: 1px solid var(--epp-grey); }
/* Transparent, not absent: without it the first row is 1px shorter than the
   other four and the whole table sits a pixel high inside the card. */
.epp-reqs__row:first-child { border-top: 1px solid transparent; }
.epp-reqs__label { font-size: 15.5px; color: var(--epp-mid); }
.epp-reqs__value { font-size: 15.5px; font-weight: 600; color: var(--epp-black);
  text-align: right; }

/* Two strings the 4b board shortens at 390, where the long form runs to three
   lines of 12.5px fine print directly above the banner image. display:none
   takes the hidden one out of the a11y tree too, so nothing is read twice. */
.epp-narrow-only { display: none; }
@media (max-width: 640px) {
  .epp-wide-only { display: none; }
  .epp-narrow-only { display: inline; }
}

.epp--splay .epp-manual-title { font-size: 26px; line-height: 1.25;
  color: var(--epp-black); margin: 0 0 20px; }
.epp--splay a.epp-manual, .epp-manual { display: flex; align-items: center; gap: 16px;
  max-width: 620px; padding: 18px 20px; background: #ffffff;
  border: 1px solid var(--epp-light); border-radius: 10px; color: inherit;
  text-decoration: none;
  transition: border-color .2s var(--eds-ease), transform .2s var(--eds-ease); }
.epp-manual:hover { border-color: var(--epp-blue); transform: translateX(4px); }
.epp-manual:focus-visible { outline: 3px solid var(--epp-blue); outline-offset: 3px; }
.epp-manual__icon { width: 38px; height: 38px; flex: none; border-radius: 8px;
  background: var(--epp-grey); display: grid; place-items: center;
  color: var(--epp-blue); }
.epp-manual__title { display: block; font-family: var(--enttec-font-display);
  font-weight: 400; font-size: 16px; color: var(--epp-black); }
.epp-manual__sub { display: block; font-size: 14px; color: var(--epp-mid); }
.epp-manual__arrow { flex: none; margin-left: auto; color: var(--epp-mid); }

/* ---------------------------------------------------------------------------
   OTHER SOFTWARE
   Reuses .epp-related wholesale (carousel, product cards, view-all pill) so
   these two cards match every other product card on the site. Three overrides
   only: the design's 22px gap, a white view-all tile because this band is
   #f2f2f2 and the shared #f0f0f0 fill would disappear into it, and a bordered
   media well to match.
   --------------------------------------------------------------------------- */
.epp--splay .epp-related { gap: 22px; }
.epp--splay .epp-related__track { gap: 22px; }
.epp--splay { --epp-carousel-gap: 22px; }
.epp--splay .epp-related__viewall { background: #ffffff;
  border: 1px solid var(--epp-light); border-radius: 12px; padding: 24px;
  transition: border-color .24s cubic-bezier(.4,0,.2,1); }
/* No blue border on hover. The card's own pill button already answers the
   pointer, and lighting the whole card up as well made a quiet 'see everything
   else' tile compete with the two real product cards beside it. */
.epp--splay .epp-related .eds-card__media { background: #f0f0f0;
  border: 1px solid var(--epp-light); border-radius: 12px; }
/* The shared card runs a fluid 1.5vw name and a 15px excerpt in the design
   system's slate; this design pins 21px and 16px/1.6 in --epp-mid. */
.epp--splay .epp-related .eds-card h4 { font-size: 21px; }
.epp--splay .epp-related .eds-card__excerpt { font-size: 16px; line-height: 1.6;
  color: var(--epp-mid); }
/* View-all tile: 16px between the two children, children stretched so the
   heading gets the full width, and the shared pill held to the design's 20px
   heading and 14px label instead of the system's fluid 26px and 15px. */
.epp--splay .epp-related__viewall { gap: 16px; align-items: stretch; }
.epp--splay .epp-related__viewall h3 { font-size: 20px; font-weight: 500;
  line-height: 1.25; }
.epp--splay .epp-related__viewall .eds-btn { align-self: center; width: max-content;
  padding: 12px 24px; font-size: 14px; }

/* CENTRED ONLY WHILE THE PANEL IS A ROW. Between 621 and 900 the shared rule
   turns this tile into "heading left, pill right", but align-items:stretch above
   is still in force, so the heading's box stretched to the full row height and
   its text sat pinned to the top while the pill sat centred beside it.

   The range is deliberately closed at both ends. Above 900 the panel is a column
   and stretch is what gives the heading the full width. At 620 and below it goes
   back to a centred column, where shrinking the heading to its content would
   make a two-line title ragged instead of centred on the full width. Only the
   row state is wrong, so only the row state is touched. */
@media (min-width: 621px) and (max-width: 900px) {
  .epp--splay .epp-related__viewall { align-items: center; }
}

/* ON A PHONE THIS STOPS BEING A CAROUSEL, because on this page it is a carousel
   with no way to drive it. The shared component gets its arrows and its
   drag-to-scroll from section 5 of the JS, which needs [data-epp-carousel] plus
   prev and next buttons in the markup. Only elm-hub.php ships those. Here the
   track is a bare overflow-x list, and the native scrollbar is deliberately
   hidden, so there is no arrow, no drag, and no bar: on a phone a swipe still
   works, but in desktop device-emulation or a narrow window there is no way to
   reach the second card at all, and `cursor: grab` promises a drag that no
   script is listening for.

   Two products do not need a carousel. Stacking them shows both at once, which
   is what Kim was asking for when she said only one was showing, and it removes
   the hidden-affordance problem rather than dressing it up with a peeking card.

   Scoped to .epp--splay so elm-hub's carousel, which IS wired up and has more
   cards, keeps scrolling. */
@media (max-width: 620px) {
  /* .epp-related--single joins .epp--splay here (2026-09-03). Both are tracks
     with nothing to scroll: S-PLAY has two cards and no arrows wired, ELM HUB now
     has one. A page that DOES wire the carousel and has cards to page through is
     deliberately not included, which is why this is still two explicit scopes and
     not a bare .epp-related__track. */
  .epp--splay .epp-related__track,
  .epp-related--single .epp-related__track { display: grid; grid-template-columns: 1fr;
    gap: 12px; overflow-x: visible; scroll-snap-type: none; cursor: auto; }
  .epp--splay .epp-related__track > li,
  .epp-related--single .epp-related__track > li { flex: none; width: auto; }
  /* The 360px cap above is a card width, and this is no longer a card. */
  .epp-related--single .epp-related__track { max-width: none; }

  /* THE CARD BECOMES A LIST ROW, because as a card it was mostly picture.
     Measured at 390px: 494px tall, of which 335px (68%) was a 335x335 square
     holding a 600x600 logo. Two of them came to 985px on an 844px viewport, so
     the cross-sell at the very foot of the page cost more than a full screen of
     scrolling and the second product was effectively unreachable.

     A thumbnail beside the text is the standard mobile list row, the media
     object: recognition is all the image owes us here, and 96px does that. Both
     products and the view-all panel now fit one screen, which is the difference
     between a cross-sell that gets seen and one that does not.

     GRID RATHER THAN FLEX because the card is four flat children of a single <a>
     (media, h4, excerpt, cta) and the thumbnail has to span all three text rows.
     That also means no markup changes: the desktop card is untouched.

     The excerpt is clamped to two lines. Unclamped it ran to four at full width
     and would run to six in a 195px column, which would put the row back where
     it started. */
  /* THE ROWS ARE DECLARED, and that is not tidiness. With only
     grid-template-columns set, the explicit grid has no rows, so "-1" on the
     thumbnail's grid-row resolved to line 1 and the span collapsed to a single
     row: the 96px picture sat in row 1 on its own and shoved the excerpt below
     it, leaving a hole under the product name. Naming three rows gives "-1"
     something to mean. */
  /* NO LONGER SCOPED TO .epp--splay (Kim, 2026-09-03, asking for the same thing
     on ELM HUB: "it should show the image on the left and then the product name
     and description on the right side"). It was page-local because it shipped
     with that page; the reasoning below was never page-specific, and a second
     page asking for it is the signal to share it rather than to copy it. Safe for
     any card count: it restyles the card, not the track. */
  /* NO LEFT PADDING (Kim, 2026-09-03). It inset the tile to x34 while the
     section's eyebrow and heading start at x20, so the one row of content in
     this band was the only thing on the page not on the container's left edge.
     Measured at 390 and 620: tile now starts level with the heading above it.
     Kept on the other three sides, where it is separating the card from the band
     rather than from an alignment everything else follows. This diverges from
     the live S-PLAY card, which keeps all four sides, and that is her call on
     this page rather than an oversight in the port. */
  .epp-related .eds-card { display: grid;
    grid-template-columns: 96px minmax(0, 1fr);
    grid-template-rows: auto auto auto;
    column-gap: 14px; row-gap: 2px;
    align-items: start; align-content: center; padding: 14px 14px 14px 0; }
  /* THE TILE'S BORDER AND RADIUS COME OFF .epp--splay TOO (Kim, 2026-09-03,
     comparing this against the live S-PLAY card: "missing a border on the
     image?"). She is right, and it was half-ported: the layout was shared but
     the tile's own 1px hairline and 12px radius were still page-scoped, so ELM
     HUB got a borderless 8px tile. One tile treatment, both pages.

     ALIGNED TO THE TOP, not centred, and that is the second half of the same
     report ("looks not aligned"). Measured against the live card: there the
     media and the product name both start at y289 because the text column
     happens to be exactly the media's 96px, so centring is a no-op. Here the
     text column is taller (see the CTA note below), so align-self: center
     pushed the tile 7px down while the name stayed at the top and the two
     stopped lining up. Top alignment is what the eye reads as aligned, and it
     holds whatever the text does. */
  .epp-related .eds-card__media { grid-column: 1;
    grid-row: 1 / span 3; width: 96px; height: 96px; align-self: start;
    background: #f0f0f0; border: 1px solid var(--epp-light); border-radius: 12px; }
  .epp .epp-related .eds-card h4 { grid-column: 2; grid-row: 1;
    font-size: 18px; }
  .epp-related .eds-card__excerpt { grid-column: 2; grid-row: 2;
    font-size: 14px; line-height: 1.45;
    display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
    overflow: hidden; }
  /* AND THE 44px TAP-TARGET FLOOR COMES OFF, in this layout only. It is set on
     .epp .eds-card__cta in the mobile pass above, for standalone targets. This
     one is not a target at all: it is a <span> inside the card's own <a>, so the
     44px was never doing accessibility work here, and the tap target is the whole
     124px card. What it WAS doing is making the text column 111px against a 96px
     tile, which is the misalignment above. Removing the cause beats compensating
     for it, and it brings the card back to the live card's 124px. */
  .epp .epp-related .eds-card__cta { grid-column: 2; grid-row: 3;
    font-size: 13px; min-height: 0; }
}

/* ---------------------------------------------------------------------------
   TABLET, 1100 and below (the handover's own banner breakpoint)
   Copy and render stop competing: copy first, then the render as a full-bleed
   band. The bento drops to two columns and the Scene Cues tile spans both.
   --------------------------------------------------------------------------- */
@media (max-width: 1100px) {
  /* Column, not block: the render is FIRST in the DOM (see the paint-order note
     above) and has to end up underneath the copy, and flex order is the only
     way to move it without re-introducing a stacking context. */
  .epp-banner { min-height: 0; display: flex; flex-direction: column;
    align-items: stretch; }
  /* CLEAR THE HEADER, which is an overlay and not part of the flow. Its wrapper is
     position:absolute at tablet and position:fixed on a phone, both at top 0 with
     z-index 111111 and a transparent background, so the banner render is MEANT to run
     full-bleed underneath it. Only the copy has to get out of the way, which is why
     this is padding on the container and not a margin on .epp-banner.

     86px is 70 + 16: the header measures 70px tall at every width from 390 up, and 16
     is the smallest gap that does not read as a collision. Below this the breadcrumb
     sat at y22 on a phone and y36 at 1024, both inside a header running to y70, so
     "Home / Products / Software" ran straight through the logo and nav.

     Desktop needs none of this and gets none: above 1100 the banner is min-height 660
     with align-items:center, so the copy is centred well clear of the header (measured
     y149 at 1440). Fixed 2026-09-03. */
  .epp-banner > .eds-container { order: 1; padding-block: 86px 0; }
  .epp-banner__copy { max-width: none; }
  /* Out of the corner and back into the flow, under the copy. Full width by
     construction (it is a child of the band, not of the wrap), so no negative
     margins are needed to bleed past the gutter. */
  .epp-banner__media { order: 2; position: static; width: 100%; height: 300px;
    margin-top: 24px; }
  .epp-banner__media img { object-fit: cover; object-position: center; }
  .epp--splay .epp-banner h1 { font-size: 38px; }
  .epp-banner__lead { font-size: 19px; }
  .epp-banner__lede { font-size: 16px; }

  .epp-subnav-light ul { height: 52px; gap: 30px; }

  .epp-bento { grid-template-columns: repeat(2, 1fr); gap: 16px; }
  .epp-tile--w2, .epp-tile--w3 { grid-column: span 1; }
  /* THE "SAVES TIME" ROW GOES ONE-UP BELOW 1100. The line above drops a w3 card
     to half of a two-column grid, which is right for the second group's cards but
     wrong for this row: the stacked column spans the full width here, so the
     device card sat alone on its own row with 340px of empty grid beside it,
     measured at 768. Scoped through --halves so only this row is affected. */
  .epp-bento--halves > .epp-bento__col--w3,
  .epp-bento--halves > .epp-tile--w3 { grid-column: 1 / -1; }
  .epp-tile--w4, .epp-tile--w6 { grid-column: 1 / -1; }
  .epp-tile--tall { grid-row: auto; }
  /* THE THREE STEP CARDS STAY ON TABLET NOW. They used to be hidden from 1100
     down, on the grounds that "at two columns they are 150px wide". That was
     measured while the Scene Cues tile was stuck two-up, which was a specificity
     bug (see below). With the tile at one column they get 196px each at 768 and
     nothing clips, so the reason has gone: 3 across costs 222px on a 573px tile
     and buys back the numbered walkthrough and the clip-seeking buttons.

     They still fold away on a phone, where the second half of that original
     reasoning does hold. See the 640 block. */
  .epp-slot--scene { min-height: 300px; }
  .epp-tile--pair { grid-template-columns: minmax(0,1fr); }
  /* The feature card goes one column below 1100: at 673px the copy third would be
     224px. Its steps then have the full width, so they stay three across. */
  .epp-tile--feature > .epp-tile__body { padding: 28px 28px 18px; }
  /* One column below 1100: a third of 673px is 218px, too narrow for a step card
     with a description in it, and half of it is too narrow for the heading's
     authored first line. */
  .epp-tile__side { grid-template-columns: minmax(0, 1fr); padding: 0 28px 24px;
    gap: 16px; }
  .epp-tile--feature > .epp-tile__body { grid-template-columns: minmax(0, 1fr);
    gap: 10px; }
  /* Stacked, the copy sits directly under a left-aligned heading, so a right rag
     there is just a broken-looking paragraph. */
  .epp-tile--feature > .epp-tile__body > p { text-align: left; margin-left: 0; }
  /* THE SCENE CUES TILE NEEDS ITS OWN COLLAPSE, and this is a specificity trap
     rather than a missing rule. The line above says one column, but
     `.epp-tile--w6.epp-tile--pair:has(.epp-slot--scene)` up in the desktop
     section pins two columns at (0,3,0) plus a :has, so it beat (0,1,0) at every
     width. The tile stayed two-up on a phone: 166px columns, the whole body
     crushed into half of a 335px card. Restated at matching specificity so the
     collapse actually lands. */
  .epp-tile--w6.epp-tile--pair:has(.epp-slot--scene) {
    grid-template-columns: minmax(0, 1fr); }
  .epp-tile--pair .epp-slot { border-left: 0; border-top: 1px solid var(--epp-light);
    min-height: 200px; }
  .epp-tile--pair .epp-slot:has(video, img) { border-top: 0; border-left: 0; }
  .epp-tile--stats { grid-template-columns: 1fr; gap: 20px; }

  /* Height was clamped here when the stage was a fixed-height placeholder. It
     is a 16/9 video now, so the aspect ratio sizes it at every width and a
     height override would only reintroduce letterboxing. */
  .epp-chapters { flex-wrap: wrap; }
  .epp-chapter { flex: 1 1 45%; }

  .epp-resgrid { grid-template-columns: 1fr; }

  /* -------------------------------------------------------------------------
     THE 834 TYPE LADDER, read off board 4a. The design does not simply scale
     the desktop page down: it sets its own sizes and its own vertical rhythm at
     this width, and the shared fluid clamps land 2 to 4px away from every one
     of them. Pinned here rather than left fluid so the tablet reads as the
     drawing rather than as an interpolation between two other widths.
     ------------------------------------------------------------------------- */
  /* 40px gutters. The desktop clamp(20px,4vw,60px) resolves to 33px at 834,
     which the board does not draw. */
  .epp--splay { --eds-container: min(1240px, 100% - 80px); }
  .epp--splay .epp-section { padding: 56px 0 64px; }

  .epp-crumbs { font-size: 13px; margin-bottom: 14px; }
  .epp--splay .epp-banner h1 { margin-bottom: 14px; }
  .epp-banner__lead { margin-bottom: 14px; }
  .epp-banner__lede { margin-bottom: 20px; }
  .epp-ticks { font-size: 15px; gap: 8px 20px; margin-bottom: 22px; }
  .epp-banner__cta { gap: 12px; margin-top: 0; }
  .epp-banner__sku { font-size: 14.5px; margin-bottom: 16px; }

  /* The head loses its 62ch cap here and runs the full wrap, with only the
     lede still capped. Without this the h2 wraps to two lines at 834 where the
     board keeps it on one. */
  .epp--splay p.eds-eyebrow { font-size: 12px; margin-bottom: 12px; }
  .epp--splay .eds-section-head { max-width: none; margin-bottom: 0; }
  .epp--splay .eds-section-head h2 { font-size: 30px; max-width: none;
    margin-bottom: 12px; }
  .epp--splay .eds-section-head h2:last-child { margin-bottom: 28px; }
  .epp--splay .eds-section-head h2 + p { font-size: 16.5px; max-width: 62ch;
    margin: 0 0 28px; }

  .epp-tile__body, .epp-tile--w4 .epp-tile__body, .epp-tile--stats { padding: 24px; }
  .epp--splay .epp-tile h3 { font-size: 20px; margin-bottom: 9px; }
  .epp--splay .epp-tile--w4 h3 { font-size: 24px; margin: 14px 0 9px; }
  .epp-tile p { font-size: 15.5px; }
  .epp-stats { gap: 18px; }
  .epp-stat__n { font-size: 30px; }
  .epp-stat__l { font-size: 14px; }

  .epp--splay .epp-dlpanel h3 { font-size: 22px; }
  .epp--splay .epp-reqs h3 { font-size: 19px; }
  .epp-reqs__row { padding: 14px 0; gap: 20px; }
  .epp-reqs__label, .epp-reqs__value { font-size: 15px; }
}

/* ---------------------------------------------------------------------------
   MOBILE, 640 and below
   --------------------------------------------------------------------------- */
@media (max-width: 640px) {
  .epp--splay .epp-banner h1 { font-size: 30px; }
  .epp-banner__lead { font-size: 17px; }
  .epp-banner__lede { font-size: 15px; }
  .epp-banner__media { height: 220px; }
  /* Breadcrumb truncated to "Software / S-PLAY Programmer", as the design
     does. Hiding the first four children takes Home, its separator, Products
     and its separator, leaving the two crumbs that actually place the page.
     Hidden rather than removed, so the trail is still complete for a crawler
     and for Yoast's own breadcrumb schema. */
  .epp-crumbs > :nth-child(-n + 4) { display: none; }
  .epp-ticks { flex-direction: column; gap: 7px; }
  /* Full-width stacked buttons, and the split button keeps its caret half. */
  .epp-banner__cta { flex-direction: column; align-items: stretch; gap: 10px; }
  .epp-dl, .epp-banner__cta .epp-btn { width: 100%; }
  .epp-dl__btn { width: 100%; height: 52px; }
  .epp-dl__main { flex: 1; justify-content: center; }
  .epp-dl__menu { min-width: 0; width: 100%; }
  .epp-banner__cta .epp-btn { height: 52px; }

  /* A REAL scrolling track, never clipped with overflow:hidden. The 20px
     gutters at both ends stop the first and last item touching the screen
     edge. */
  .epp-subnav-light ul { height: 48px; gap: 22px; justify-content: flex-start;
    padding-inline: 20px; -webkit-overflow-scrolling: touch; }

  .epp-bento { grid-template-columns: 1fr; gap: 14px; }
  .epp-tile--w2, .epp-tile--w3, .epp-tile--w4, .epp-tile--w6 { grid-column: 1 / -1; }
  .epp-tile__body, .epp-tile--w4 .epp-tile__body, .epp-tile--stats { padding: 22px; }
  .epp--splay .epp-tile--w4 h3 { font-size: 21px; }
  .epp--splay .epp-tile h3 { font-size: 19px; }
  .epp-slot--scene { min-height: 210px; }
  .epp-slot--devices, .epp-tile--pair .epp-slot { min-height: 170px; }
  .epp-stats { grid-template-columns: 1fr 1fr; gap: 18px; }
  .epp-stat__n { font-size: 28px; }

  /* Follows .epp-tile__body down from 28px to 22px just above, so the steps stay
     flush with the copy they sit under. The stacking itself is at 900, not here. */
  .epp-steps-wrap { padding: 0 22px 22px; }

  /* Same as above: 16/9 handles the phone width too. */
  .epp-yt__play { width: 58px; height: 58px; }
  .epp-yt__play svg { width: 24px; height: 24px; }

  /* TWO UP, NOT ONE, and it is the same fault the desktop layout was fixed for.
     Eight chapters at full width made the strip 551px, so the 187px video and
     its markers could not be on screen together on a phone: picking a chapter
     scrolled the picture out of view, and picking a chapter is the only thing
     this section does. Two columns brings the strip to four rows and the whole
     component back under a phone viewport.

     Tighter rows and a smaller title, because a 166px column wraps most of
     these labels onto a second line and the 68px row was sized for one. */
  .epp-chapter { flex: 1 1 calc(50% - 1px); padding: 10px 12px; }
  .epp-chapter__title { font-size: 14px; line-height: 1.3; }

  .epp-dlpanel__head { flex-direction: column; align-items: flex-start; gap: 12px; }
  .epp-dlpanel__buttons { flex-direction: column; }
  /* REAL VERTICAL PADDING instead of the 52px fixed height. The base .epp-btn is
     height-only with padding:0 24px, which is right on desktop (a hover that
     resized the box would reflow the row) but leaves these two stacked
     full-width buttons looking cramped on a phone. min-height keeps the 44px tap
     floor, and padding-block means a label that wraps grows the button instead of
     spilling out of it. Nothing here changes on hover, so the reflow the base
     rule guards against still cannot happen. */
  .epp-dlpanel__buttons .epp-btn { height: auto; min-height: 56px;
    padding-block: 15px; }
  /* Label above value. Right-aligned values wrap badly at this width and the
     two columns collide. */
  .epp-reqs__row { flex-direction: column; align-items: stretch; gap: 3px; }
  .epp-reqs__value { text-align: left; }
  .epp--splay .epp-manual-title { font-size: 21px; }

  /* -------------------------------------------------------------------------
     THE 390 TYPE LADDER, read off board 4b, on the same reasoning as the 834
     one above. Sizes the board draws below 12px are raised to 12 (the eyebrow
     is 11.5 there, the sub-nav 11): ENTTEC has a hard 12px floor on
     customer-facing text and it is not negotiable for a phone layout.
     ------------------------------------------------------------------------- */
  /* 20px gutters. The tablet block pins 40px, which the 390 board does not
     draw: at 40 the copy column is 310px and the H1 breaks after "S-PLAY". */
  .epp--splay { --eds-container: min(1240px, 100% - 40px); }
  .epp--splay .epp-section { padding: 44px 0 52px; }
  /* Same 70+16 clearance as the 1100 rule above; the header does not shrink here. */
  .epp-banner > .eds-container { padding-block: 86px 0; }
  .epp-crumbs { font-size: 12px; margin-bottom: 12px; }
  .epp--splay .epp-banner h1 { margin-bottom: 12px; }
  .epp-banner__lead { margin-bottom: 12px; }
  .epp-banner__lede { margin-bottom: 16px; }
  .epp-ticks { font-size: 14.5px; margin-bottom: 18px; }
  .epp-banner__cta { margin-top: 0; }
  .epp-banner__sku { font-size: 14px; margin-bottom: 14px; }
  .epp-banner__media { margin-top: 20px; }

  .epp--splay .epp-subnav-light a { letter-spacing: .13em; }
  .epp-tile p { font-size: 15px; }
  .epp-stat__l { font-size: 13.5px; }
  .epp--splay p.eds-eyebrow { font-size: 12px; margin-bottom: 10px; }
  .epp--splay .eds-section-head h2 { font-size: 26px; margin-bottom: 10px; }
  .epp--splay .eds-section-head h2:last-child { margin-bottom: 22px; }
  .epp--splay .eds-section-head h2 + p { font-size: 15.5px; margin: 0 0 24px; }

  .epp-resgrid { gap: 14px; }
  .epp-dlpanel { padding: 22px; }
  .epp-dlpanel__logo { width: 80px; }
  .epp--splay .epp-dlpanel h3 { font-size: 20px; }
  .epp-dlpanel__meta { font-size: 14px; }
  .epp--splay .epp-reqs h3 { font-size: 18px; }

  /* The requirements tabs stop being underlined tabs and become a segmented
     control, as the 4b board draws them. Two full-width targets instead of two
     13px words sitting in the top-left corner: at 390 the underline tab row
     reads as a heading rather than as something you can press, and the tap
     targets were 91px wide against a 293px card. Same buttons, same
     aria-selected, skin only. The glyphs stay (the board drops them, but they
     are the fastest way to tell the two apart at a glance and there is room). */
  .epp-reqs__head { padding: 18px 20px 12px; }
  .epp-reqs__tabs { gap: 3px; margin: 0 20px 14px; padding: 3px;
    background: var(--epp-grey); border-radius: 7px; border-bottom: 0; }
  .epp-reqs__tab { flex: 1; justify-content: center; gap: 7px; padding: 8px 0;
    border-radius: 5px; font-size: 12px; box-shadow: none;
    transition: color .2s var(--eds-ease), background-color .2s var(--eds-ease),
      box-shadow .2s var(--eds-ease); }
  .epp-reqs__tab[aria-selected="true"] { background: #ffffff;
    box-shadow: inset 0 -2px 0 var(--epp-blue); }
  /* The rule under the head block moves here, because the pill now floats
     inside the padding rather than sitting on the block's own bottom edge. */
  .epp-reqs__panel { padding: 4px 20px 14px; border-top: 1px solid var(--epp-light); }
  .epp-reqs__row { padding: 12px 0; }
  .epp-reqs__label { font-size: 14px; }
  .epp-reqs__value { font-size: 15px; }
}

/* Reduced motion for this page is mostly already handled by the blanket rule in
   the REDUCED MOTION section above, which kills every animation and transition
   inside .epp with !important. Two things that rule cannot reach are listed
   here: the transform-based hover states, which have to be cancelled by NAME or
   a hover simply teleports the tile 3px, and the menu's entry animation, whose
   `both` fill mode would otherwise strand it at opacity 0. Every control keeps
   a non-motion signal (colour on the tiles, the nav and the chapters; the
   aria-current and aria-selected states everywhere else). */
@media (prefers-reduced-motion: reduce) {
  .epp-tile:hover, .epp-step:hover, .epp-stat:hover, .epp-manual:hover {
    transform: none !important; box-shadow: none !important; }
  .epp-dl__menu { animation: none !important; opacity: 1 !important; transform: none !important; }
  .epp-dl__caret[aria-expanded="true"] svg { transform: rotate(180deg) !important; }
  .epp-subnav-light ul { scroll-behavior: auto !important; }
}

/* ---------------------------------------------------------------------------
   SUPPRESSING THE THEME'S OWN PRODUCT BANNER ON THIS PAGE
   This is the one rule here that reaches outside the article, and it is the
   largest judgement call in the port, so it is written where it can be found.

   The theme prints .product-details-top (banner image, breadcrumb, h1, short
   description, price, add-to-cart, SKU meta) ABOVE the body. ELM HUB keeps it
   and restyles it from the outside. This design cannot: it carries its own
   breadcrumb, its own h1, its own value ticks and its own primary call to
   action, and shipping both would give the page two h1s and two openings that
   disagree with each other.

   :has() rather than a body class, because a body class would have to be added
   by the plugin and this is a purely presentational consequence of which body
   rendered. display:none keeps the markup in the DOM, so WooCommerce's JSON-LD
   (emitted at wp_footer, not here) is unaffected.

   WHAT GOES WITH IT, stated plainly rather than discovered later: the theme's
   price and its add-to-cart button. S-PLAY Programmer is a free download whose
   call to action is the download button, so there is nothing to lose today. If
   this product ever gains a price, this rule has to come out and the banner has
   to be reconciled with the design instead.
   --------------------------------------------------------------------------- */
/* !important, and it is load-bearing. The theme's own
   `body.single-product.newLookEnabled .product-details-top` is (0,3,1) against
   this selector's (0,2,1), so without it the theme wins and the page ships TWO
   <h1>s reading "S-PLAY Programmer", two breadcrumbs and two openings. Raising
   the specificity instead would only tie, and the tie would be decided by
   whichever file the minifier happened to concatenate last. The EMU page solves
   the identical problem the identical way. */
body:has(.epp--splay) .product-details-top { display: none !important; }

/* THE EYEBROW IS ENTTEC BLUE AGAIN (Kim, 2026-09-03), on every band of every
   html-mode product page rather than just the one she was looking at.

   IT WAS GREY BY OMISSION, NOT BY DECISION, which is why this is a one-line fix
   and not a design change. The eyebrow is a <p> inside .eds-section-head, so
   `.epp .eds-section-head p { color: var(--epp-body-ink) }` at (0,2,1) outranks
   `.epp .eds-eyebrow { color: var(--epp-blue) }` at (0,2,0) and repaints every
   eyebrow #4c4c4c. Measured on the related band: rgb(76, 76, 76).

   The S-PLAY page hit exactly this and fixed it as `.epp--splay p.eds-eyebrow`,
   with a note saying it was scoped "so ELM HUB's eyebrows do not move". They
   should have moved: the design system ships eyebrows blue, this sheet says blue
   twice (.epp .eds-eyebrow, and .epp .epp-section--dark .eds-eyebrow for the
   dark bands), and all three were being overridden. So the fix belongs at .epp,
   which is where the intent was already written down.

   p.eds-eyebrow, matching (0,2,1), and placed here at the end of the sheet so it
   wins on source order rather than on weight. Nothing is scoped away from
   .epp--splay: its own rule is later still and keeps its Montserrat 700. */
.epp p.eds-eyebrow { color: var(--epp-blue); }

/* ================== ZOOMABLE FIGURE ==================
   The trigger on the page, and the zoom layer inside .epp-lightbox--zoom.

   EVERYTHING HERE IS SCOPED, and that is deliberate rather than tidy. The gallery
   lightbox shares every .epp-lightbox class above and has NO zoom on purpose (see
   the note on .epp-lightbox__img). So the modifier class carries all of it: the
   gallery cannot inherit a transform, a touch-action or a cursor from this block,
   which is what makes "the gallery is unchanged" a property of the selectors and
   not something to re-test by hand.

   The behaviour, the tablet floor and the reasoning live in product-pages.js,
   section 1b. */

/* THE TRIGGER. Bottom right, INSIDE the figure and over the picture's own
   corner rather than in a row underneath it: the diagram is the thing being
   offered, and a control parked below reads as a caption. Fitts's law argues for
   the same corner, because the pointer is already on the picture at the moment
   the thought occurs.

   WHY NOT THE TOP RIGHT, which is the more usual place for an expand control and
   was tried first: this diagram carries its callout labels in the raster, and the
   top right is where "Installation-ready 3RU rackmount chassis" sits. A control
   there covers the content it is offering to enlarge. The bottom right is empty
   white space on all three language variants (the compliance marks sit bottom
   left), so it never hides anything.

   HIDDEN BELOW THE TABLET FLOOR, which is the whole mobile rule in one line. A
   phone keeps the browser's own page pinch and gets no trigger at all. Written
   as a max-width hide rather than a min-width show so that a browser without
   media query support, or with CSS half-loaded, fails towards the control being
   present rather than absent.

   IT ONLY APPEARS ON HOVER (Kim, 2026-09-03), so the diagram is never sharing its
   corner with a control while somebody is reading it. Three things make that safe
   rather than a control nobody can find, and none of them is optional:

     - THE HIDE IS SCOPED TO (hover: hover) and (pointer: fine). A tablet is above
       the 768px floor and has no hover at all, so an unscoped rule would leave
       every tablet with a permanently invisible button. On a touch device it
       therefore stays visible, which is the only honest reading of "on hover".
     - IT COMES BACK ON :focus-visible. A keyboard user has no pointer to hover
       with, and a focusable control that cannot be seen when focused is worse
       than one that is always visible.
     - THE ZOOM-IN CURSOR AND THE IMAGE CLICK stay on the whole picture, so the
       affordance survives the pill being hidden and the function is reachable
       even if the pill is never noticed.

   opacity, not display or visibility: it has to remain focusable while hidden for
   the focus rule above to have anything to reveal. pointer-events: none goes with
   it so an invisible pill cannot swallow a click meant for the picture, and that
   does not affect keyboard focus. */
.epp figure.epp-figure--zoomable { position: relative; }
.epp .epp-figzoom { position: absolute; right: 10px; bottom: 10px; z-index: 2;
  display: inline-flex; align-items: center; gap: 7px;
  /* 44px floor, same as the mobile tap-target pass above: this is reachable on a
     tablet, which is a touch device even though it is above the phone floor. */
  min-height: 44px; padding: 0 14px 0 12px; border: 0; border-radius: 999px;
  background: rgba(18, 18, 20, .88); color: #fff; cursor: pointer;
  font-family: var(--enttec-font-display); font-size: 13px; letter-spacing: .02em;
  -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
  transition: opacity var(--eds-dur) var(--eds-ease),
              background var(--eds-dur) var(--eds-ease),
              transform var(--eds-dur) var(--eds-ease); }
.epp .epp-figzoom svg { display: block; width: 17px; height: 17px; }
@media (hover: hover) and (pointer: fine) {
  .epp .epp-figzoom { opacity: 0; pointer-events: none; }
  .epp figure.epp-figure--zoomable:hover .epp-figzoom,
  .epp .epp-figzoom:focus-visible { opacity: 1; pointer-events: auto; }
}
.epp .epp-figzoom:hover { background: rgb(12, 13, 16); transform: translateY(-1px); }
.epp .epp-figzoom:focus-visible { outline: 3px solid var(--epp-blue);
  outline-offset: 2px; }
.epp .epp-figzoom:active { transform: translateY(0); }
/* The picture advertises the same thing the button does, so a click on it is not
   a surprise. Only once JS has marked the figure, and only where the trigger is
   actually available. */
@media (min-width: 768px) {
  .epp figure.epp-figure--zoomable > img { cursor: zoom-in; }
}
@media (max-width: 767px) {
  .epp .epp-figzoom { display: none; }
}

/* ---- THE ZOOM LAYER, inside the overlay --------------------------------- */
/* touch-action: none is REQUIRED, not tidiness: without it the browser claims
   the pinch and the pan for its own page zoom and the pointermove handlers never
   see them. It overrides the pinch-zoom the gallery's figure sets, which is
   correct here because this overlay implements pinch itself.

   -webkit-user-drag and user-select are also load-bearing. On a bare <img> a
   press-and-move starts the browser's native image drag-and-drop, which fires
   pointercancel a few pixels in, so the pan dies almost immediately and the
   picture creeps along in little steps with every press. */
.epp-lightbox--zoom .epp-lightbox__figure { touch-action: none; }
.epp-lightbox--zoom .epp-lightbox__img { touch-action: none;
  transform-origin: center center; will-change: transform;
  user-select: none; -webkit-user-select: none; -webkit-user-drag: none; }
/* ---- Stacking order, stated once ----------------------------------------
   will-change: transform makes the picture a stacking context, and it sits
   BEFORE the chrome in the DOM but wins on paint order the moment it is scaled
   past the frame. With everything left at z-index: auto the picture covers the
   close and zoom controls: still there, still clickable in theory, invisible and
   unhittable in practice. Picture on layer 0, every control on layer 2. Two
   numbers, one rule, no per-control guessing later. */
.epp-lightbox--zoom .epp-lightbox__img { position: relative; z-index: 0; }
.epp-lightbox--zoom .epp-lightbox__zoom,
.epp-lightbox--zoom .epp-lightbox__btn { z-index: 2; }
/* No transition by default, so wheel and pinch track the input exactly.
   .is-smooth is added by the JS only for the discrete jumps (button, keyboard,
   refit) that should glide, and never under reduced motion. */
.epp-lightbox--zoom .epp-lightbox__img.is-smooth { transition: transform .24s var(--eds-ease); }
.epp-lightbox--zoom .epp-lightbox__img.is-panning { transition: none; }
.epp-lightbox--zoom .epp-lightbox__img.is-zoomed { cursor: grab; }
.epp-lightbox--zoom .epp-lightbox__img.is-zoomed.is-panning { cursor: grabbing; }
/* Zoom controls, sharing the close button's shape and position so the overlay
   reads as one set of tools rather than three unrelated widgets. Stacked under
   the close button at the same right edge, out of the diagram's way. */
.epp-lightbox--zoom .epp-lightbox__zoom { position: fixed; top: 68px; right: 16px;
  display: flex; flex-direction: column; gap: 8px; }
/* DARK PLATE, NOT THE GALLERY'S WHITE WASH, and this is a correctness fix rather
   than a preference. The gallery's controls sit on rgba(255,255,255,.12), which
   works because nothing ever slides underneath them: its picture is letterboxed
   inside the frame. Here the picture is zoomed and panned THROUGH the controls,
   so a 12% white wash over a dark photograph leaves the button a barely-visible
   smudge. Verified by hit test that they are still on top and clickable, so this
   was purely a contrast failure, which is the worse kind: the control works and
   looks broken. An own dark plate plus a light hairline holds against any part of
   any image. Same treatment for the close button, which has the same problem. */
.epp-lightbox--zoom .epp-lightbox__zbtn,
.epp-lightbox--zoom .epp-lightbox__btn--close { background: rgba(12, 13, 16, .74);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .22);
  -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); }
.epp-lightbox--zoom .epp-lightbox__zbtn:hover:not(:disabled),
.epp-lightbox--zoom .epp-lightbox__btn--close:hover { background: rgba(12, 13, 16, .92);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .4); }
.epp-lightbox--zoom .epp-lightbox__zbtn { display: inline-flex;
  align-items: center; justify-content: center;
  width: 46px; height: 46px; padding: 0; border-radius: 50%; border: 0;
  cursor: pointer; color: #fff;
  transition: background var(--eds-dur) var(--eds-ease),
              box-shadow var(--eds-dur) var(--eds-ease),
              opacity var(--eds-dur) var(--eds-ease); }
.epp-lightbox--zoom .epp-lightbox__zbtn svg { display: block; width: 20px; height: 20px; }
.epp-lightbox--zoom .epp-lightbox__zbtn:focus-visible { outline: 3px solid var(--epp-blue);
  outline-offset: 2px; }
/* Disabled, not hidden: a control that disappears at the end of its range moves
   its neighbour under a cursor already travelling towards it. */
.epp-lightbox--zoom .epp-lightbox__zbtn:disabled { opacity: .34; cursor: default; }
/* The hint says what the gesture in front of you does RIGHT NOW, which is how
   the zoom stops being something to discover by accident. It also carries the
   only statement of how to get out while zoomed, because click-anywhere is
   suspended there. */
.epp-lightbox--zoom .epp-lightbox__hint { margin: 0; text-align: center;
  color: #9aa0aa; font-family: var(--enttec-font-display); font-size: 12px;
  letter-spacing: .04em; }
/* The caption is the diagram's label and stops earning its space once somebody
   is reading the diagram itself. Fades rather than collapsing, so the foot does
   not change height and shift the picture underneath it. */
.epp-lightbox--zoom:has(.epp-lightbox__img.is-zoomed) .epp-lightbox__caption { opacity: 0; }
.epp-lightbox--zoom .epp-lightbox__caption { margin-bottom: 8px;
  transition: opacity var(--eds-dur) var(--eds-ease); }
/* The RU body authors no figcaption (its alt text does the describing), so the
   caption element is built and left empty there. Collapse it rather than leaving
   18px of margin holding nothing open. */
.epp-lightbox--zoom .epp-lightbox__caption:empty { display: none; }

@media (max-width: 900px) {
  /* On a tablet the top-right corner is where the thumb reaches worst and the
     close button already sits, so the pair moves to the bottom edge, clear of
     the caption and of the consent-bar reserve. */
  .epp-lightbox--zoom .epp-lightbox__zoom { top: auto; right: 14px;
    bottom: calc(84px + var(--epp-lb-bottom, 0px)); }
}

@media (prefers-reduced-motion: reduce) {
  /* The zoom still works, it just arrives instead of gliding. .is-smooth is
     never added by the JS under reduce, and this is the belt to that braces.
     Nothing is lost: every zoom change is also reported by the picture's new
     size, by the hint text and by the buttons' disabled state, so no feedback
     here is motion-only. */
  .epp .epp-figzoom,
  .epp-lightbox--zoom .epp-lightbox__img,
  .epp-lightbox--zoom .epp-lightbox__img.is-smooth,
  .epp-lightbox--zoom .epp-lightbox__zbtn,
  .epp-lightbox--zoom .epp-lightbox__caption { transition: none; }
  /* The hover lift is the one transform that is decoration rather than state,
     so it is the only one dropped. The picture's own transform is written
     inline by the JS and must survive: it IS the zoom, not an animation of it. */
  .epp .epp-figzoom:hover,
  .epp .epp-figzoom:active { transform: none; }
}
