/* =============================================================================
   NP STUDY — BRIGHT SKIN
   =============================================================================
   HOW THIS SHIPS  (changed 2026-07-30 — no manual pasting any more)
   inc/setup.php prints a <link> to this file at wp_head priority 101, so it lands
   in the same cascade slot the old copy-paste instructions produced. Just edit
   this file and deploy it:
     cd site/tools && ./wp-live.py sync ../themes/flatsome-child \
         wp-content/themes/flatsome-child --yes --purge
   To roll back: remove the wp_head hook at the bottom of inc/setup.php.

   Previously this file was NEVER loaded and had to be pasted into
   WP Admin -> Flatsome -> Theme Options -> Style -> Custom CSS. That step was
   easy to forget, and forgetting it silently breaks every opt-in pattern below
   (the six-card roadmap collapses to overlapping 16%-wide columns).

   WHY IT WINS THE CASCADE
   Flatsome prints the Custom CSS panel at wp_head priority 100; stylesheets
   print at priority 8. This CSS is therefore last in the document and wins at
   equal specificity. Never add !important.

   WHY design-system.css STILL SHIPS LIGHT VALUES
   The panel cannot edit theme files, so this file shadows a few rules in
   design-system.css rather than fixing them at source. That redundancy is
   deliberate — see spec section 7.3.

   THE ZONES
     1. TOKENS   <-- the only zone you should edit
     2. CANVAS   -- page + typography
     3. EFFECTS  -- wash, beam, shimmer, glass (leave alone)
     4. ELEMENTS -- the five patterns + opt-in patterns
     5. RESCUE   -- mostly retired on a light canvas

   OPT-IN CLASSES (type these into an element's Class field in UX Builder)
     nps-spectrum  -- on a [row]: six pastel numbered cards
     nps-wash      -- on a [section]: pastel gradient ground
     nps-sunrise   -- on a [button]: high-emphasis sunrise CTA
   ============================================================================= */


/* =============================================================================
   1. TOKENS  <-- THE ONLY ZONE YOU EDIT
   Change a hex here and the whole site follows.
   Every value below is verified by tests/contrast.py — if you change one,
   run:  python3 tests/contrast.py
   ============================================================================= */
:root{
  /* Ground. Canvas is a near-white biased toward navy; cards are pure white
     and float above it, so depth starts before any shadow is drawn. */
  --nps-canvas:    #FDFCFF;
  --nps-surface:   #FFFFFF;
  --nps-surface-2: #F3F6FB;

  /* Ink */
  --nps-ink:   #0A2342;
  --nps-ink-2: #46566F;
  --nps-ink-3: #647087;

  /* Hairlines — alpha, so one token composites over white, mist, or a photo */
  --nps-line:        rgba(10,35,66,.10);
  --nps-line-strong: rgba(10,35,66,.17);

  /* Brand — unchanged. --nps-accent and --nps-accent-2 are FILLS; the -600
     variants are the matching inks for text. */
  --nps-accent:       #FF7A2F;
  --nps-accent-600:   #C2410C;
  --nps-accent-2:     #12B3A8;
  --nps-accent-2-600: #0B7A72;
  --nps-navy-900:     #061627;

  /* Spectrum — sunrise is stop 2 and aqua is stop 4, which is what anchors the
     ramp to the brand (spec D9). Those two stops REFERENCE the brand inks
     rather than repeating their hex, so the relationship cannot silently drift
     apart the way it did during Task 1. */
  --hue-1:#C92A62;                  --tint-1:#FDEEF4;
  --hue-2:var(--nps-accent-600);    --tint-2:#FFF1E6;
  --hue-3:#8D6708;                  --tint-3:#FFF8E1;
  --hue-4:var(--nps-accent-2-600);  --tint-4:#E6F7F5;
  --hue-5:#1565C0;                  --tint-5:#E8F2FD;
  --hue-6:#4527A0;                  --tint-6:#EDE9F8;

  /* Depth — two-stop: tight contact shadow + wide ambient. */
  --nps-shadow-sm: 0 1px 2px rgba(10,35,66,.04), 0 1px 3px rgba(10,35,66,.06);
  --nps-shadow:    0 4px 12px rgba(10,35,66,.06), 0 12px 32px rgba(10,35,66,.08);
  --nps-shadow-lg: 0 8px 24px rgba(10,35,66,.08), 0 24px 60px rgba(10,35,66,.12);

  --nps-r-md:   16px;
  --nps-r-pill: 999px;
  --nps-ease:   cubic-bezier(.22,.61,.36,1);
}


/* =============================================================================
   2. CANVAS
   ============================================================================= */
body{ background:var(--nps-canvas); color:var(--nps-ink); }


/* =============================================================================
   3. EFFECTS  (leave alone)
   ============================================================================= */

/* Registered so --nps-angle can be animated. Without @property a conic
   gradient angle is a plain string and will not interpolate. */
@property --nps-angle{ syntax:'<angle>'; initial-value:0deg; inherits:false; }
@keyframes nps-spin{ to{ --nps-angle:360deg; } }

/* Pastel wash — STATIC layered gradient. No animation, no blur filter:
   this is a full-width ground and must stay cheap on mid-range Android. */
.nps-wash{ position:relative; overflow:hidden; }
.nps-wash::before{
  content:''; position:absolute; inset:0; z-index:0; pointer-events:none;
  /* Colours reference Zone 1 tokens rather than repeating literals, so the
     wash cannot drift from the palette the way it did before — tests/contrast.py
     only validates :root, so a hardcoded stop here is invisible to the guard.

     Keep a SINGLE space between `at` and the x-position below. This panel is
     emitted through flatsome_minify_css() (helpers-frontend.php:57), which runs
       str_replace(array("\r\n","\r","\n","\t",'  ','    ','    '), '', $css)
     — a run of two spaces is DELETED, not collapsed.

     These stops used to be column-aligned as `at  2%   0%` (line 1) and
     `at 74%  96%` (line 4). Minification produced `at2% 0%` and `at 74%96%`.
     Only the FIRST of those is broken, and only one of them had to be:
       `at2%`    the digit fuses onto the keyword, giving the single ident token
                 `at2` where a `<position>` was expected — invalid.
       `74%96%`  VALID. `%` terminates a numeric token, so no separator is
                 needed; this tokenises as `74%` then `96%` and computes to
                 `at 74% 96%`. Same for `26%8%`.
     One invalid layer poisons the whole comma-separated `background:` shorthand,
     so that single fused token silently blanked all four gradients — the section
     read as pure #fff on every capture. Verified in a browser: repairing only
     `at2% 0%` restores all four layers; `74%96%` and `26%8%` were never at risk.

     So the rule to remember is narrow: two spaces are only destructive where
     deleting them would fuse tokens that do not self-terminate — a keyword
     followed by a digit is the dangerous shape, `%`- or `)`-adjacent is not.
     test_skin_blocks.php calls the real flatsome_minify_css() on this file and
     asserts all four positions survive, so this cannot regress unnoticed. */
  background:
    radial-gradient(ellipse 70% 90% at 2% 0%, color-mix(in srgb, var(--hue-6) 13%, transparent) 0%, transparent 62%),
    radial-gradient(ellipse 60% 80% at 26% 8%, color-mix(in srgb, var(--hue-1) 9%, transparent) 0%, transparent 60%),
    radial-gradient(ellipse 70% 90% at 98% 100%, color-mix(in srgb, var(--nps-accent-2) 15%, transparent) 0%, transparent 62%),
    radial-gradient(ellipse 60% 70% at 74% 96%, color-mix(in srgb, var(--hue-5) 8%, transparent) 0%, transparent 60%);
}
.nps-wash > *{ position:relative; z-index:1; }

/* Beam border — a conic gradient masked to a 1.5px ring.
   Animates ONLY while hovered on a real pointer device, so an idle page costs
   zero GPU and a tapped card cannot latch :hover and repaint forever.
   Takes --card-hue when the element sets one, else sunrise. */
.nps-beam{ position:relative; }
.nps-beam::before{
  content:''; position:absolute; inset:0; border-radius:inherit; padding:1.5px;
  /* --card-hue re-colours the beam here; its sibling --card-tint is NOT used in
     this zone — Task 8 sets it and Zone 4's .icon-inner consumes it. The pair
     is split across zones by design; neither is missing. */
  background:conic-gradient(from var(--nps-angle),
    transparent 0%, var(--card-hue, var(--nps-accent)) 16%,
    var(--nps-accent-2) 30%, transparent 46%);
  -webkit-mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
          mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite:xor;
          mask-composite:exclude;
  opacity:0; transition:opacity .45s var(--nps-ease);
  pointer-events:none; z-index:3;
}
/* Pointer devices only. On Android Chrome a tap latches :hover until the user
   taps elsewhere; a conic gradient re-rasterises every frame on the main thread
   and cannot be composited, so a latched card would burn CPU indefinitely on
   exactly the mid-range phones this skin exists to stay cheap for. Touch simply
   never shows the ring — the base rule above keeps opacity:0. */
@media (hover:hover){
  .nps-beam:hover::before{ opacity:1; animation:nps-spin 3s linear infinite; }
}

/* Glass — used by [text_box] over a photo.
   CSS has no @extend, so the one place that needs this primitive is named in
   the selector list rather than duplicating the declarations in Zone 4. That
   is a deliberate, contained exception to "Zone 3 knows nothing about
   Flatsome": one selector here beats six duplicated properties there.

   THE TARGET IS `.text-box .text`, AND IT IS DERIVED, NOT GUESSED.
   This selector read `.banner .text-box-content` until 2026-07-16. That class
   does not exist and never did: `grep -rn "text-box-content" themes/flatsome/`
   returns zero. It was invented, so this rule never matched, the glass never
   painted, and Zone 4's white `.banner-layers` ink fell straight onto the photo
   on 600 banners. text_box.php:93-101 emits the real shape:
     .text-box.banner-layer > [hover/parallax/animate wrapper?] > .text > .text-inner
   Of those three, `.text` is the panel:
     - Flatsome ITSELF paints `.text`. text_box.php:112-124 maps the shortcode's
       `bg` att to `.text`'s background-color and `radius` to `.text`'s
       border-radius. The element the theme fills is the element we frost.
     - flatsome.css already gives `.text-box .text` position:relative + z-index:10
       — a real paint layer above the scrim, which backdrop-filter requires.
     - `.text-box` is the wrong box: `.banner-layers>*` forces position:absolute
       on it and text_box.php maps the `width`/`height` atts to it, so a
       [text_box height="60"] would frost a tall empty slab detached from the
       copy. `.text` hugs its content.
     - The shaped variants re-shape `.text`, not `.text-box`
       (`.text-box-circle .text{border-radius:100% !important}`), so frosting
       `.text` follows the shape and replaces Flatsome's opaque black panel.
   DESCENDANT, NOT CHILD: `.text-box > .text` would be a fresh invented-DOM bug.
   text_box.php:94-96 injects hover/parallax/animate wrappers between the two,
   and ux_banner.php:166's fallback path passes animate="fadeIn" by DEFAULT — so
   the wrapper is the common case, not the exotic one.
   Scoped through `.text-box` rather than a bare `.banner .text`: price_table.php:91
   also emits a `.text`, and a price table inside a banner must not be frosted.
   Specificity (id, class/pseudo-class, type): `.banner .text-box .text` is
   (0,3,0), which clears Flatsome's `.text-box-square .text,.text-box-circle .text`
   background-color at (0,2,0). */
.nps-glass,
.banner .text-box .text{
  background:rgba(255,255,255,.74);
  -webkit-backdrop-filter:blur(14px) saturate(1.5);
          backdrop-filter:blur(14px) saturate(1.5);
  border:1px solid rgba(255,255,255,.65);
  border-radius:var(--nps-r-md);
  box-shadow:var(--nps-shadow);
}

/* Reduced motion — kill every animation and transition this skin adds.
   The hover selector MUST be named here. A media query contributes no
   specificity of its own, so `.nps-beam::before` (0,1,1) loses to
   `.nps-beam:hover::before` (0,2,1) and cancels only the base rule, which never
   animated. Matching the hover selector at (0,2,1) and relying on source order
   is what actually stops the spin. */
@media (prefers-reduced-motion: reduce){
  .nps-beam::before,
  .nps-beam:hover::before{ animation:none; transition:none; }
}


/* =============================================================================
   4. ELEMENTS
   ============================================================================= */

/* --- CARD — [ux_image_box] + [featured_box] + [blog_posts] -----------------
   [ux_image_box] and [featured_box] emit `.box` / `.icon-box` as the card root.
   [blog_posts] does NOT: it emits a `.col.post-item` GRID COLUMN whose
   `.col-inner > a.plain` wraps the real card, `.box.box-blog-post`
   (themes/flatsome/inc/shortcodes/blog_posts.php:231-234). `.post-item` must
   therefore never appear here — it CONTAINS a `.box`, so listing it painted a
   second card around the first: two borders, two radii, the outer fill covering
   the column's 30px gutter, and — since :hover matched both — two nested
   translateY(-4px) compounding to an 8px lift. Blog cards are styled for free
   by `.box` alone, which the inner element already matches. 5,290 uses. */
.box, .icon-box{
  position:relative; background:var(--nps-surface);
  border:1px solid var(--nps-line); border-radius:var(--nps-r-md);
  box-shadow:var(--nps-shadow-sm); overflow:hidden;
  transition:transform .4s var(--nps-ease), box-shadow .4s var(--nps-ease);
}
.box:hover, .icon-box:hover{
  transform:translateY(-4px); box-shadow:var(--nps-shadow-lg);
}
.box-image{ overflow:hidden; }
/* Two shapes, not one. Flatsome emits `.box-image > div` for an UNLINKED image
   box, but wraps the inner div in an anchor the moment a link is set —
   `.box-image > a > div` (ux_image_box.php:52,92-98). A link is the common
   case, so matching only the first shape meant the zoom never ran in practice.
   Both are named here and in the hover rule below. */
.box-image > div,
.box-image > a > div{ transition:transform .6s var(--nps-ease); }
.box:hover .box-image > div,
.box:hover .box-image > a > div{ transform:scale(1.04); }
.box-text{ padding:20px; }
.box-text p{ color:var(--nps-ink-2); }

/* Icon Box — the icon tile picks up --card-tint / --card-hue when set. */
.icon-box{ padding:24px; }
.icon-box .icon-inner{
  border-radius:12px;
  color:var(--card-hue, var(--nps-accent-600));
  background:var(--card-tint, var(--tint-2));
  transition:transform .4s var(--nps-ease);
}
.icon-box:hover .icon-inner{ transform:scale(1.08) rotate(-4deg); }
.icon-box-text p{ color:var(--nps-ink-2); }

/* Reduced motion — every mover above is cancelled here.
   A media query contributes NO specificity, so `transition:none` on a base
   selector only TIES the base rule; it cannot reach a `:hover` rule that sets
   transform. Each hover selector is therefore repeated below at its own
   specificity, and this block sits last in the zone so source order breaks the
   tie in its favour. The arithmetic, (id, class/pseudo-class, type):
     .box,.icon-box                     (0,1,0) -> cancelled at (0,1,0)
     .box:hover,…                       (0,2,0) -> cancelled at (0,2,0)
     .box-image > div                   (0,1,1) -> cancelled at (0,1,1)
     .box-image > a > div               (0,1,2) -> cancelled at (0,1,2)
     .box:hover .box-image > div        (0,3,1) -> cancelled at (0,3,1)
     .box:hover .box-image > a > div    (0,3,2) -> cancelled at (0,3,2)
     .icon-box .icon-inner              (0,2,0) -> cancelled at (0,2,0)
     .icon-box:hover .icon-inner        (0,3,0) -> cancelled at (0,3,0)
   Every pair is EQUAL, so later-wins applies to all eight. Note the two zoom
   shapes score DIFFERENTLY — the anchor adds a type, taking (0,3,1) to (0,3,2)
   — so naming only the `> div` form here would leave the linked-image zoom
   running for motion-sensitive users. Both are listed. Shipping the base
   selectors alone is the bug that let Zone 3's beam spin for these users.

   No @media (hover:hover) gate here, unlike Zone 3's beam: these hovers are
   one-shot transitions (lift, shadow, scale), not infinite animations. A tap
   that latches :hover on Android leaves a card lifted — visually harmless and
   self-correcting on the next tap. Zone 3 gates because a latched conic
   gradient repaints forever; nothing here repaints after it settles. */
@media (prefers-reduced-motion: reduce){
  .box, .icon-box, .box-image > div, .box-image > a > div,
  .icon-box .icon-inner{
    transition:none;
  }
  .box:hover, .icon-box:hover{ transform:none; }
  .box:hover .box-image > div,
  .box:hover .box-image > a > div{ transform:none; }
  .icon-box:hover .icon-inner{ transform:none; }
}


/* --- BUTTON — [button] ------------------------------------------------------
   2,360 uses. Default is the navy pill: white on navy is 15.77:1.
   D8: sunrise NEVER takes white ink — that combination is 2.60:1 and fails AA.
   Both figures are tests/contrast.py's own, measured from Zone 1 — do not
   restate a contrast number here that the gate cannot print. */
.button{
  position:relative; overflow:hidden;
  border-radius:var(--nps-r-pill);
  background:var(--nps-ink); color:var(--nps-surface);
  border:0; box-shadow:var(--nps-shadow-sm);
  transition:transform .3s var(--nps-ease), box-shadow .3s var(--nps-ease);
}
.button:hover{ transform:translateY(-2px); box-shadow:var(--nps-shadow); }

/* Shimmer — sweeps once per hover.
   The white is a specular gloss, not a palette colour: it must stay white
   whatever --nps-surface becomes, so it is a literal here exactly as Zone 3's
   glass is. Every BRAND colour in this zone goes through a token instead. */
/* :not(.loading) is load-bearing, not cosmetic. WooCommerce's AJAX add-to-cart
   sets .button.loading and Flatsome draws the spinner with .button.loading:after
   — which centres itself with left/top:50% + margin:-8px but sets NO transform.
   Our shimmer's translateX(-100%) would leak in and shove every add-to-cart /
   place-order spinner ~16px off-centre, then slide it on hover. Excluding the
   loading state keeps the purchase flow's spinner where Flatsome puts it. */
.button:not(.loading)::after{
  content:''; position:absolute; inset:0; pointer-events:none;
  background:linear-gradient(105deg, transparent 42%,
    rgba(255,255,255,.28) 50%, transparent 58%);
  transform:translateX(-100%);
  transition:transform .7s var(--nps-ease);
}
.button:not(.loading):hover::after{ transform:translateX(100%); }

/* High-emphasis CTA — opt in with [button class="nps-sunrise"]. Navy ink (D8).
   The far stop is --nps-sunrise-600 (#F26418), the FILL. It is NOT --nps-accent-600
   (#C2410C): that is an INK, minted for text, and it is the value this gradient
   shipped with. Navy on it is 3.52:1 — under the far end of the ramp the label
   fell to 4.03:1 worst case with ~31% of its glyph pixels below AA, which turned
   the very button that fixes white-on-sunrise into a fresh AA failure of its own.
   NEVER paint an ink. --nps-accent-600's only correct use in this file is the
   `color:` at line 213. Worst stop is now 5.74:1 — the figure line 314 cites.
   CROSS-FILE: --nps-sunrise-600 is defined in design-system.css:17, not Zone 1.
   The theme enqueues that file at wp_head priority 8 and this panel at 100, so it
   is always defined before this rule resolves. Do NOT copy it into Zone 1 to
   "fix" the dependency — a second copy is the drift this project keeps fighting.
   tests/contrast.py measures both stops as sunrise-fill-600 / accent-600. */
.button.nps-sunrise{
  background:linear-gradient(135deg, var(--nps-accent) 0%, var(--nps-sunrise-600) 100%);
  color:var(--nps-navy-900);
}
/* color-mix on the token, NOT rgba(255,122,47,.5): that literal is --nps-accent
   spelled out by hand. tests/contrast.py only reads :root, so it cannot see a
   glow left orange after the token moved — which is exactly how the superseded
   #D6336C shipped. Tinting a token is always color-mix(… N%, transparent). */
.button.nps-sunrise:hover{
  box-shadow:0 10px 30px -8px color-mix(in srgb, var(--nps-accent) 50%, transparent);
}

/* Secondary — [button style="outline"] */
.button.is-outline{
  background:var(--nps-surface); color:var(--nps-ink);
  border:1px solid var(--nps-line-strong);
}
.button.is-outline::after{ display:none; }

/* The NP-Widget buttons carry the SAME defect: design-system.css:165 sets
   .nps-btn--primary { background: var(--nps-sunrise); color: #fff } — white on
   sunrise, 2.60:1. That is the homepage hero CTA (hero.php:71) and the CTA band
   (ctaband.php:37). We cannot edit that file (no-PHP/no-files constraint), so
   shadow the rule here; the panel prints last and wins. This is the shadowing
   described in spec §7.3.
   :hover MUST be named. design-system.css:166 re-asserts color:#fff on hover at
   (0,2,0); our base .nps-btn--primary is only (0,1,0) and would lose to it, so
   the ink would flip back to white — 3.18:1 on the darker --nps-sunrise-600 —
   for exactly as long as the pointer is on the button. Matching (0,2,0) and
   printing later is what holds it. Navy on sunrise-600 is 5.74:1. */
.nps-btn--primary,
.nps-btn--primary:hover{ color:var(--nps-navy-900); }

/* Reduced motion — every mover this zone introduces is cancelled here.
   NOT every mover on the page: .nps-btn--primary above lifts translateY(-2px)
   from design-system.css:166 and is NOT cancelled — this skin only shadows that
   rule's `color`. A known, deliberately deferred NP-Widget gap (see the note
   above); the widget's motion defects are a later spec's work, not this zone's.
   Same arithmetic as the card block: a media query contributes NO specificity,
   so `transition:none` on a base selector only TIES the base rule and can never
   reach a `:hover` rule that sets transform. Each hover selector is repeated at
   its own weight, and this block is last in the zone so source order breaks
   every tie in its favour. (id, class/pseudo-class, type):
     .button                          (0,1,0) -> cancelled at (0,1,0)
     .button:not(.loading)::after     (0,2,1) -> cancelled at (0,2,1)
     .button:hover                    (0,2,0) -> cancelled at (0,2,0)
     .button:not(.loading):hover::after (0,3,1) -> cancelled at (0,3,1)
   Note ::after is a pseudo-ELEMENT and scores a type; :not() scores its
   argument. So the sweep rule is (0,3,1), and the reduced-motion park must
   reach the same (0,3,1) or it strands the band mid-sweep.
   The shimmer parks at translateX(-100%), NOT `none`: `none` resolves to
   translateX(0), which strands the bright band across the middle of every
   button permanently. Reduced motion must hide the effect, not freeze it
   mid-sweep.
   No @media (hover:hover) gate, as with the cards and unlike Zone 3's beam:
   the shimmer is a one-shot transition that settles. A tap that latches :hover
   on Android leaves it swept and idle — nothing repaints after it lands. */
@media (prefers-reduced-motion: reduce){
  .button, .button:not(.loading)::after{ transition:none; }
  .button:hover{ transform:none; }
  /* Park at (0,3,1) to match the .button:not(.loading):hover::after sweep rule;
     the old (0,2,1) park would now lose to it and strand the band mid-sweep. */
  .button:not(.loading):hover::after{ transform:translateX(-100%); }
}


/* --- BANNER — [ux_banner] ---------------------------------------------------
   1,418 uses; 606 contain text, 812 are image-only.

   THE OLD CENSUS SAID 39 IMAGE-ONLY / 1,379 TEXT-BEARING. IT WAS MEASURED
   AGAINST FICTION — do not restore it, and read this before re-measuring.
   site/database.sql escapes newlines as the two characters backslash-n, so a
   banner whose content is only blank lines reads as the literal text `\n\n\n`
   in the dump. A naive grep sees that as non-empty and files the banner under
   "has text". Un-escape first (\n, \r, \t, \\, \', \") and the real split is
   812 image-only / 606 text-bearing / 1,418 total: 773 banners hold nothing but
   escaped whitespace — INCLUDING the client's homepage hero, which the old "39"
   therefore never counted at all. Reproduce with a parser, never a grep:
     blocks = re.findall(r'\[ux_banner\b[^\]]*\](.*?)\[/ux_banner\]', dump, re.S)
     image_only = sum(1 for b in blocks if unescape(b).strip() == '')
   No rendering ever depended on the wrong number — the gate below is derived
   from content at match time, not from this census, and it is correct for all
   812. But the figure understated the shipped scrim bug by ~20x and inverted
   D7's rationale: the gate protects 57% of banners, not 2.8%.

   D7: the scrim exists ONLY to make text legible over a photo. On an
   image-only banner it would just dim the picture — and the homepage hero
   slider is exactly that case ([ux_banner height="32%" bg="2609"] x4, empty).
   So :has() gates it on real content. Where :has() is unsupported no scrim
   renders: text is less legible, but no image is wrongly dimmed. That is the
   safe failure direction and it is deliberate. NEVER add an ungated
   .banner-bg::before — that rule would dim all 1,418.

   THE GATE ARGUMENT IS `.banner-layers > :not(.fill)`, AND THE `:not()` IS THE
   WHOLE GATE. It read `:has(.banner-layers *)` until 2026-07-16, which gated
   NOTHING: ux_banner.php:147 emits
     <div class="banner-layers …"><?php echo $start_link; ?><div class="fill banner-link"></div>…
   and that inner div is literal HTML OUTSIDE the PHP tags — every banner gets
   one, unconditionally, text or not. `.banner-layers` is therefore never empty,
   `*` matched the banner-link on all 1,418, and all 812 image-only banners were
   scrimmed INCLUDING the homepage hero this gate exists to protect (measured
   max channel delta 138 against an unstyled control).
   `.fill` is the discriminator because it is exactly what Flatsome injects and
   never what an author writes: the banner-link carries it, and when link= is set
   ux_banner.php:121 wraps it in `<a class="fill">` — so the DIRECT child of
   .banner-layers carries `.fill` in both machine-generated shapes. Real content
   carries neither: [text_box] emits `.text-box.banner-layer` (text_box.php:64)
   and raw copy emits bare h3/p.
   Verified in Chrome against all four real shapes: bare hero -> no scrim; hero
   with link= -> no scrim; text banner -> scrim; [text_box] banner -> scrim.
   On the `>`: measured, a descendant `:has(.banner-layers :not(.fill))` gives the
   SAME answer on all four shapes today, because the linked shape's inner
   banner-link carries `.fill` too — so the child combinator is not load-bearing
   right now and this comment will not pretend otherwise. It is still the right
   selector: `.banner-layers > *` is precisely what Flatsome treats as a layer
   (flatsome.css positions exactly those), so `>` asks the question we actually
   mean — "does this banner have a direct content LAYER" — instead of the broader
   "is there any non-.fill element anywhere underneath", which would start
   matching the moment a Flatsome wrapper gained a non-.fill descendant.

   SPECIFICITY, and why raising the argument moved every number below.
   `:has()` takes the specificity of its most specific argument and adds nothing
   of its own, exactly like `:is()`. The universal selector contributes nothing,
   so the old `:has(.banner-layers *)` argument scored (0,1,0) and `.banner:has(…)`
   was (0,2,0). The new argument is `.banner-layers`(0,1,0) + `:not(.fill)`, which
   takes its own argument's (0,1,0), for (0,2,0) — so `.banner:has(…)` is now
   (0,3,0). Every scrim selector below gained exactly one class as a result, and
   Zone 4's ink rules had to gain it too or they would have started losing.

   position:relative is ours, not borrowed. Flatsome's own CSS sets it, but this
   file must stand alone in the panel and both the ::before scrim and the .fill
   children need .banner to be the containing block; without it overflow:hidden
   does not even clip the photo it is here to round. */
.banner{
  position:relative;
  border-radius:var(--nps-r-md); overflow:hidden;
  box-shadow:var(--nps-shadow-sm);
}
/* The dark stop is --nps-navy-900 through color-mix, NOT rgba(6,22,39,.86):
   that literal IS the token spelled out by hand, and tests/contrast.py only
   reads :root, so it could never see the scrim left behind after the token
   moved — which is exactly how the superseded #D6336C shipped. Same rule for
   the paragraph ink below: it is --nps-surface at 82%, not rgba(255,255,255,.82).
   Unlike Zone 3's glass or the button shimmer, neither of these is a specular
   gloss that must stay a fixed colour — both are palette colours and both go
   through their token.

   `isolation:isolate` IS LOAD-BEARING. WITHOUT IT THE SCRIM PAINTS OVER THE TEXT.
   The ::before needs z-index:1 to clear `.bg.fill`, which is a POSITIONED sibling
   later in tree order (ux_banner.php:140) and would otherwise cover it — a
   pseudo-element is its host's first child, so at z-index:auto the scrim would
   hide behind the very photo it is dimming. But z-index only stays local if some
   ancestor is a stacking context, and NONE of them is: flatsome.css gives
   `.fill` position:absolute with no z-index, so `.banner-bg` and `.banner-inner`
   are both positioned-but-z-index:auto, which does NOT create one. The scrim's
   z-index:1 therefore escaped into the banner's context and outranked
   `.banner-layers` — position:relative, z-index:auto, i.e. level 0 — so the scrim
   painted ON TOP of the copy it exists to make legible.
   Measured in Chrome against the real markup, isolate off vs on (figures are
   glyph samples, so they wobble a little with anti-aliasing):
     h3, computed rgb(255,255,255): RENDERED (137,145,153) — flat grey, not white
       — worst effective contrast ~3.8:1 (fails AA) -> ~16.7:1 with isolate.
     [text_box] glass: composited to (124,143,147) instead of (~197,210,210),
       taking its heading from ~10.4:1 down to ~5.0:1.
   Note the ink COMPUTES correct in both states — getComputedStyle says white, and
   says navy on the glass, exactly as the rules intend. Only the rendered pixels
   disagree. A test that reads computed style cannot see this; it has to sample
   the glyph.
   `isolation:isolate` creates a stacking context on `.banner-bg` with no other
   effect — no paint change, no layout change — which confines z-index:1 to the
   inside of `.banner-bg`, above `.bg`/video/overlay, while `.banner-bg` as a whole
   stays below `.banner-layers` by tree order. Gated on the same :has() as the
   scrim: no scrim, no change.
   This is not defensive garnish. It shipped broken and the preview hid it by
   hand-writing `style="z-index:2"` onto `.banner-layers` — a z-index Flatsome
   does not set and the panel cannot add from a stylesheet alone. */
.banner:has(.banner-layers > :not(.fill)) .banner-bg{ isolation:isolate; }
.banner:has(.banner-layers > :not(.fill)) .banner-bg::before{
  content:''; position:absolute; inset:0; z-index:1; pointer-events:none;
  background:linear-gradient(90deg,
    color-mix(in srgb, var(--nps-navy-900) 86%, transparent) 0%,
    color-mix(in srgb, var(--nps-navy-900) 42%, transparent) 58%,
    transparent 100%);
}
/* Every text element is named, not just .banner-layers. Setting `color` on the
   layer and trusting INHERITANCE is not enough: an inherited value loses to any
   rule that matches the child DIRECTLY, at ANY specificity — specificity never
   even enters into it. design-system.css:71 sets `h1,…,h6{ color:var(--nps-navy) }`
   sitewide, so the heading over the scrim rendered navy-on-navy — invisible, the
   exact illegibility the scrim exists to prevent. Naming h1-h6 at (0,4,1) beats
   that (0,0,1) rule with a direct match of our own. The `p` only ever worked
   because it was spelled out here. If a future element (li, span, a) starts
   carrying banner copy, it must be added to this list too.
   Arithmetic, with `.banner:has(…)` now at (0,3,0) per the header note:
     .banner:has(…) .banner-bg::before   (0,4,1)
     .banner:has(…) .banner-layers       (0,4,0)
     .banner:has(…) .banner-layers h1-h6 (0,4,1)
     .banner:has(…) .banner-layers p     (0,4,1)
   Each is one class heavier than before the gate was fixed. */
.banner:has(.banner-layers > :not(.fill)) .banner-layers{ color:var(--nps-surface); }
.banner:has(.banner-layers > :not(.fill)) .banner-layers h1,
.banner:has(.banner-layers > :not(.fill)) .banner-layers h2,
.banner:has(.banner-layers > :not(.fill)) .banner-layers h3,
.banner:has(.banner-layers > :not(.fill)) .banner-layers h4,
.banner:has(.banner-layers > :not(.fill)) .banner-layers h5,
.banner:has(.banner-layers > :not(.fill)) .banner-layers h6{ color:var(--nps-surface); }
.banner:has(.banner-layers > :not(.fill)) .banner-layers p{
  color:color-mix(in srgb, var(--nps-surface) 82%, transparent);
}

/* [text_box] over a banner. The glass itself is defined once in Zone 3
   (.nps-glass, .banner .text-box .text) — do NOT redeclare it here. Zone 3 owns
   those six properties deliberately; CSS has no @extend, and copying them down
   here is the drift this project keeps fighting.

   THE TARGET IS `.text-box`, the real class. These rules named
   `.text-box-content` until 2026-07-16 — an invented class with zero hits in
   themes/flatsome/ (see the Zone 3 note for the real DOM). They matched nothing,
   so a text_box heading took the white h1-h6 rule above and rendered WHITE with
   no glass behind it on 600 banners; where the scrim's gradient has reached
   transparent — a right-positioned text_box — that was white text on a bright
   photo.
   Ink goes on `.text-box`, not on `.text` where the glass goes: `.text-box` is
   the whole panel and covers the copy wherever the hover/parallax/animate
   wrappers (text_box.php:94-96) put it. The glass lives on `.text` because that
   is what Flatsome paints; the ink covers everything inside the box.

   This block re-asserts ink ONLY, because the :has() scrim rule above sets
   .banner-layers to white and a text_box sits inside it. Specificity, with
   `.banner:has(…)` at (0,3,0):
     scrim    .banner:has(…) .banner-layers p     (0,4,1)
     this     .banner:has(…) .text-box p          (0,4,1)  tie -> later wins
     scrim    .banner:has(…) .banner-layers h1-h6 (0,4,1)
     this     .banner:has(…) .text-box h1-h6      (0,4,1)  tie -> later wins
   Both pairs are EQUAL and this block wins only because it prints later: do not
   reorder these two blocks. Do NOT "tidy" the :has() out of this one either — a
   bare `.banner .text-box p` is (0,2,1), it would lose to the scrim, and the
   paragraph would stay white on the glass at about 1.2:1. */
.banner:has(.banner-layers > :not(.fill)) .text-box{ color:var(--nps-ink); }
.banner:has(.banner-layers > :not(.fill)) .text-box h1,
.banner:has(.banner-layers > :not(.fill)) .text-box h2,
.banner:has(.banner-layers > :not(.fill)) .text-box h3,
.banner:has(.banner-layers > :not(.fill)) .text-box h4,
.banner:has(.banner-layers > :not(.fill)) .text-box h5,
.banner:has(.banner-layers > :not(.fill)) .text-box h6{ color:var(--nps-ink); }
.banner:has(.banner-layers > :not(.fill)) .text-box p{ color:var(--nps-ink-2); }
/* Ungated fallback for a browser without :has(): it applies neither the scrim
   nor the white ink, but Zone 3's glass is ungated and still paints, so the box
   needs plain ink on that glass. Deliberately light — (0,2,0), (0,2,1) — so the
   gated rules above always outrank it where :has() is supported.

   h1-h6 MUST BE SPELLED OUT HERE, for the SAME reason they are spelled out on
   the gated path above: `.banner .text-box{color:…}` sets an INHERITED value,
   and an inherited value loses to any rule matching the child DIRECTLY, at ANY
   specificity. flatsome.css's `.dark h1,…,.dark h6{ color:#fff }` matches the
   heading directly — text_box.php:79 puts `.dark` on the element whenever
   text_color="light", which is the DEFAULT — so the box's navy never reached the
   heading and it rendered WHITE ON THE FROSTED WHITE GLASS. The `p` survived
   only because it was spelled out; nothing matches it directly.
   Measured in Chrome with every :has() rule stripped, before vs after (figures
   are glyph samples against a text-transparent twin, so they wobble a little
   with anti-aliasing):
     h4 on the glass: rendered (255,255,255) — 1.41:1 -> navy, 11.68:1.
     p  on the glass: rendered (70,86,111) — 5.62:1 both before and after.
   This comment used to claim the fallback was "correct there too"; it was
   correct for everything except the headings, which is where it mattered most.
   Arithmetic (id, class/pseudo-class, type):
     .banner .text-box h1-h6   (0,2,1)  beats  .dark h1-h6 (0,1,1) — direct match
     .banner:has(…) .text-box h1-h6 (0,4,1) still outranks this on a modern engine
   The scrim's white h1-h6 is untouched: that rule matches headings under
   `.banner-layers` that are NOT inside a `.text-box`, and this selector cannot
   reach them. */
.banner .text-box{ color:var(--nps-ink); }
.banner .text-box h1,.banner .text-box h2,.banner .text-box h3,
.banner .text-box h4,.banner .text-box h5,.banner .text-box h6{ color:var(--nps-ink); }
.banner .text-box p{ color:var(--nps-ink-2); }

/* No reduced-motion block: this pattern introduces no transition, transform or
   animation. The banner's only hover is Zone 3's beam when .nps-beam is opted
   in, and Zone 3 already cancels that at its own specificity. */


/* --- SLIDER — [ux_slider] ---------------------------------------------------
   985 uses. Flatsome drives this with Flickity; we restyle chrome only and
   touch no behaviour.

   THE CLASS NAMES ARE STOCK FLICKITY, AND THAT IS VERIFIED, NOT ASSUMED.
   flatsome.js bundles Flickity unmodified — PageDots' _create() does
   `this.holder.className = "flickity-page-dots"` and appends `.dot` children,
   and _createPrevNextButtons() builds `.flickity-prev-next-button.previous/.next`.
   Flatsome neither wraps nor renames them; it only adds MODIFIER classes to the
   host element (ux_slider.php:53-79). So `.flickity-page-dots .dot` and
   `.flickity-prev-next-button` are real. This is the one thing the brief got
   right about this element, and it is the reason the rest of this comment is
   about specificity rather than about invented DOM.

   EVERYTHING IS SCOPED THROUGH `.slider`, AND THE SCOPE IS LOAD-BEARING.
   It is not decoration and it is not defensive habit — without it, most of the
   colour below is dead. `.slider` is Flatsome's universal Flickity host:
   ux_slider.php:53 puts it on [ux_slider], helpers-shortcode.php:122 puts it on
   [row]'s `slider row-slider`, and the product galleries carry it too
   (woocommerce/content-single-product-lightbox.php:17). So scoping to `.slider`
   costs no coverage — it reaches every Flickity instance on the site — and it
   buys the one class that decides these rules.

   THE BRIEF'S DOT COLOUR WOULD HAVE BEEN DEAD ON 575 OF 985 SLIDERS.
   ux_slider.php:22 defaults `nav_color` to 'light' and line 67 turns that into
   `.slider-nav-light`. A census of database.sql says 410 sliders set
   nav_color="dark" explicitly and the other 575 take the default — so the
   MAJORITY of sliders carry `.slider-nav-light`, and flatsome.css ships
     .slider-nav-light .flickity-page-dots .dot.is-selected{background-color:#fff}
   at (0,4,0). The brief's `.flickity-page-dots .dot.is-selected` is (0,3,0) and
   LOSES to it outright — source order never gets consulted, because specificity
   is decided first. The active dot would have stayed white on 58% of sliders and
   the sunrise pill would have appeared only on the 410 dark ones. Naming
   `.slider` takes our selector to (0,4,0), which TIES that rule, and the panel
   prints last (wp_head 100 vs 8) so the tie goes to us. Same arithmetic saves
   the inactive dot from `.nav-dots-small`/`.row-slider … .dot{opacity:0.2}`
   (0,3,0) and the hover shadow from `.slider .flickity-prev-next-button:hover{
   box-shadow:none}` (0,3,0) — the brief's (0,2,0) hover rule was dead too.
   Arithmetic (id, class/pseudo-class, type):
     .slider .flickity-page-dots .dot            (0,3,0) ties .row-slider … (0,3,0)
     .slider .flickity-page-dots .dot.is-selected(0,4,0) ties .slider-nav-light … (0,4,0)
     .slider .flickity-prev-next-button          (0,2,0) ties .slider-nav-reveal … (0,2,0)
     .slider .flickity-prev-next-button:hover    (0,3,0) ties .slider … :hover (0,3,0)
   Every pair is EQUAL and every one of them is won on source order alone. Do not
   "simplify" the `.slider` out of any of these four selectors.

   WHAT IS DELIBERATELY NOT SET HERE, because it is behaviour, not chrome:
     - `.flickity-viewport` / `.flickity-slider` carry the inline transforms
       Flickity writes from JS every frame of a drag. Untouched, deliberately.
     - the arrows' `opacity` and `transform`. flatsome.css fades them in on
       `.slider:hover` and slides them with translateX under `!important`. Both
       are the reveal interaction, not our paint.
     - the selected dot's `opacity`. flatsome.css sets `opacity:1 !important` on
       `.dot.is-selected`; we want 1 there anyway, so there is nothing to fight
       and no reason to try.
   The glow is color-mix on the token, NOT rgba(255,122,47,.5) — that literal is
   --nps-accent spelled out by hand, and tests/contrast.py only reads :root, so
   it could never see a glow left orange after the token moved. Same rule as the
   button's. --nps-accent is a FILL and is correct here; the -600 inks are not. */
.slider .flickity-page-dots .dot{
  width:7px; height:7px; opacity:.35;
  background:var(--nps-ink-3); border:0;
  transition:width .35s var(--nps-ease), background .35s var(--nps-ease),
             opacity .35s var(--nps-ease);
}
.slider .flickity-page-dots .dot.is-selected{
  opacity:1; width:24px; border-radius:var(--nps-r-pill);
  background:var(--nps-accent);
  box-shadow:0 2px 10px color-mix(in srgb, var(--nps-accent) 50%, transparent);
}
/* Arrows. Flatsome paints these transparent with no border
   (.flickity-prev-next-button{background-color:transparent;border:none}), so
   this is a surface where there was none. border-radius is ours and is not
   garnish: the button box is 36px wide and spans top:40%/bottom:40% of the
   slider, so an unrounded surface reads as a hard slab whose height changes with
   the slider. The pill keeps it reading as chrome at any slider height. */
.slider .flickity-prev-next-button{
  background:var(--nps-surface); box-shadow:var(--nps-shadow-sm);
  border:1px solid var(--nps-line); border-radius:var(--nps-r-pill);
}
.slider .flickity-prev-next-button:hover{ box-shadow:var(--nps-shadow); }

/* Reduced motion. The dot transition is the only mover this pattern adds — the
   arrows' fade/slide are flatsome.css's and are not ours to cancel. Named at the
   SAME (0,3,0) the base rule scores, and printed after it, because a media query
   contributes no specificity of its own. */
@media (prefers-reduced-motion: reduce){
  .slider .flickity-page-dots .dot{ transition:none; }
}


/* --- LINE-WORK — [divider] --------------------------------------------------
   4,717 uses — the third most-used element on the site. And 4,624 of them
   cannot be recoloured from this file at all. Read this before touching the
   selector; it is the whole story of this rule.

   THE SHORTCODE EMITS A DIV WITH TWO CLASSES, NOT AN <hr class="divider">.
   titles_dividers.php:96 returns, verbatim:
     <div class="is-divider divider clearfix" [inline css]></div>
   Both classes, every time. `.is-divider` is the one flatsome.css styles
   (height:3px; background-color:rgba(0,0,0,.1); max-width:30px).

   THE SELECTOR IS `.is-divider.divider` AND NOT A BARE `.divider`, FOR TWO
   INDEPENDENT REASONS — either one alone would justify it.
   1. A bare `.divider` HITS THINGS THAT ARE NOT DIVIDERS. `.divider` is not
      unique to this shortcode: woocommerce/global/breadcrumb.php:37,43 emits
      `<span class="divider">/</span>` for the breadcrumb separator, and
      woocommerce/checkout/header-small.php:24,29 (and header.php:24,29) emit
      `<span class="divider hide-for-small">` wrapping a chevron icon.
      flatsome.css styles them as text (`.breadcrumbs .divider{opacity:.35;
      margin:0 .3em;font-weight:300}`). A bare `.divider` rule therefore smears
      the gradient behind every breadcrumb separator and the checkout header's
      chevrons. `.is-divider` is the discriminator because it is exactly what the
      divider shortcode emits and never what a breadcrumb carries.
      NOTE, because the first draft of this comment got it wrong: the separators
      do NOT collapse to 1px. `height` does not apply to non-replaced INLINE
      elements, and these spans stay `display:inline` — measured rect height is
      identical with the rule on or off. Only the background paints. The bug is
      real, the mechanism isn't the one it looks like. The error came from
      reading a COMPUTED value (`height:1px`) as a USED value; `display:inline`
      was sitting in the same output. Measure geometry, not computed style.
   2. SPECIFICITY. A bare `.divider` is (0,1,0) — it would tie flatsome.css's
      `.is-divider` (0,1,0) and win on order, but it would LOSE to
      `.dark .is-divider{background-color:rgba(255,255,255,.3)}` and
      `.box-blog-post .is-divider{height:2px}`, both (0,2,0). `.is-divider.divider`
      is (0,2,0): it ties those and prints later.

   98% OF DIVIDERS CARRY AN INLINE background-color AND ARE UNREACHABLE.
   The `color` att maps to inline `background-color` (titles_dividers.php:85) and
   get_shortcode_inline_css (helpers-shortcode.php:229-236) emits it as a literal
   `style="…"` ATTRIBUTE. Inline styles outrank every selector in this file;
   only !important could beat them, and !important is banned here. A census of
   database.sql:
     4,717 [divider] total
     4,624 (98.0%) carry color="…"      -> inline background-color, unreachable
     4,305 carry height="…"             -> inline height, unreachable
     4,623 carry width="…"              -> inline max-width, so they ARE full-width
        93 bare [divider]               -> reachable
   The dominant shapes are `width="100%" height="2px" color="rgb(2, 127, 198)"`
   (1,891x) and the same in `rgb(243, 119, 71)` (1,408x) — the client's blue and
   orange, chosen per-divider in UX Builder.

   HENCE `:not([style*="background-color"])`, WHICH IS THE POINT OF THIS RULE.
   Without it this rule still MATCHES those 4,624 — and half-paints them. Our
   `background` shorthand's colour component loses to the inline background-color,
   but background-IMAGE has no inline competitor, so the gradient paints ON TOP of
   the author's solid blue. The gradient's two ends are `transparent`, so the blue
   shows through at the tips: the result is a blue bar with a rainbow smudge
   across its middle, on 4,624 elements. That is worse than either the author's
   colour or ours. The :not() confines this rule to the 93 dividers where the
   spectrum can actually render end-to-end, and leaves an author's deliberate
   colour choice alone everywhere else — which is the correct default anyway.
   Specificity becomes (0,3,0); it beats everything named above.

   max-width IS DELIBERATELY NOT SET. flatsome.css caps `.is-divider` at 30px and
   we do not lift it: that cap is Flatsome's "short accent rule under a heading"
   and overriding it would re-lay-out dividers site-wide, which is a redesign and
   not this task's remit. The 93 reachable dividers therefore render as a 30px
   spectrum TICK unless they set width= themselves. The full-width rainbow
   hairline the brief pictured does not exist on this site and cannot be made to
   from this file — see the report for what a human needs to decide.
   The stops go through --hue-* / --nps-accent, never a literal. --hue-2 and
   --hue-4 resolve to the -600 inks by Zone 1's own design (spec D9 anchors the
   ramp to the brand that way); that is Zone 1's decision to make, and a divider
   carries no text, so no contrast question arises here. */
.is-divider.divider:not([style*="background-color"]){
  height:1px; opacity:.8;
  background:linear-gradient(90deg, transparent, var(--hue-1),
    var(--nps-accent), var(--hue-4), var(--hue-6), transparent);
}


/* --- SECTION HEADING — [title] ----------------------------------------------
   The only element on these pages with zero rules in this file OR in
   design-system.css, so it is the one that renders unstyled today.

   THE MARKUP IS titles_dividers.php:56, verbatim:
     <div class="container section-title-container">
       <h{n} class="section-title section-title-{style}">
         <b></b>
         <span class="section-title-main">TEXT<small class="sub-title">SUB</small></span>
         <b></b>
       </h{n}>
     </div>

   THE <b> PAIR IS LOAD-BEARING, NOT DECORATION. `.section-title` is
   display:flex with justify-content:space-between and each <b> is flex:1, so
   the two of them are what push the text to the centre. `display:none` on them
   collapses every centred heading to the left. They are REPAINTED, never hidden.

   FIVE INHERITED RULES ARE ACCOUNTED FOR — four answered, one deliberately
   left alone — each with its specificity:
   1. `.section-title span{text-transform:uppercase}` (0,1,1) forces caps on
      every heading. Uppercased Vietnamese loses diacritic legibility and the
      design system sets headings in sentence case. Overridden at
      `.section-title .section-title-main` (0,2,0) — strictly heavier, so this
      does not depend on source order. The three `hN.section-title
      .section-title-main` size rules below are (0,2,1), heavier again, and they
      carry font-size only, so they never re-open the case question.
   2. `.section-title b{display:block;flex:1;height:2px;opacity:.1;
      background-color:currentColor}` (0,1,1) paints the flanking rules as 2px
      of near-invisible ink. Answered at an EQUAL (0,1,1): flatsome.css prints at
      wp_head 8 and this panel at 100, so later-wins applies, exactly as the file
      header describes. Only height/opacity/background are restated —
      `display:block` and `flex:1` are LEFT STANDING on purpose, because they are
      what makes the centring described above work.
   3. `.section-title small{text-transform:none;font-weight:normal;
      padding-left:15px;font-size:1em;opacity:.7}` (0,1,1) and
      `.section-title-center small{padding:0 15px;display:block}` (0,1,1) style
      the sub_text slot. Answered at `.section-title .sub-title` (0,2,0).
      NOTE THE FIRST TWO DECLARATIONS OF THAT FIRST RULE: Flatsome actively sets
      the <small> to sentence case at normal weight. That is precisely why
      `.sub-title` below sets `font-weight:700` and `text-transform:uppercase` —
      those two are not decoration and not duplication of an inherited default,
      they are the answer to `font-weight:normal` and `text-transform:none`.
      Delete either one and the eyebrow drops back to lowercase body-weight text.
   4. `.section-title-container{margin-bottom:1.5em}` (0,1,0) is answered at an
      EQUAL (0,1,0) by the clamp below — the same wp_head 8-versus-100 source
      order argument as item 2, not a specificity win.
   5. `.section-title-container{margin-top:30px}` (0,1,0) — a SEPARATE rule,
      later in flatsome.css than the margin-bottom one — is DELIBERATELY NOT
      ANSWERED. 30px falls inside the clamp's own 1.5rem-2.5rem range, so the
      heading already reads as balanced between its gaps; and this container is
      emitted by EVERY [title] on the site, so re-spacing its top would re-lay-out
      pages outside this task's remit. Same reasoning as the divider max-width
      note above. If a future page needs a tighter top, that is a section-padding
      decision, not a heading one. Left unanswered knowingly, not by omission.

   SUB_TEXT IS AN EYEBROW, NOT A LEAD, AND THAT IS A SEMANTIC CONSTRAINT rather
   than a taste call: titles_dividers.php:56 emits <small> INSIDE the heading
   tag, so prose there would nest a paragraph inside an <h1>. Page leads are a
   separate [ux_text]. See the spec's "Two constraints [title] imposes".

   STYLE="CENTER" IS THE ONLY VARIANT USED. The bold variants add
   `border:2px solid rgba(0,0,0,.1); padding:.3em .8em` around the heading text
   (flatsome.css `.section-title-bold span`; `.section-title-bold-center span`
   only adds text-align:center on top of it), a boxed-heading look the design
   system does not use.

   EXTERNAL: --nps-h1/-h2/-h3 and --nps-eyebrow live in design-system.css, not
   Zone 1 above. Do not copy their values here. Weight and line-height arrive
   from that file's `h1..h6` base rule (lines 71-78), so only size, case, ink
   and the rules need saying here.

   COLOUR DOES NOT ARRIVE FROM design-system.css — IT HAS TO BE STATED. That
   file's base rule sets `h1,h2,h3,h4,h5,h6,.nps-h1,.nps-h2,.nps-h3{
   color:var(--nps-navy)}` at wp_head 8. But Flatsome's Theme Options block —
   the same <style id="custom-css"> this panel is printed inside, at wp_head
   100 — re-declares `h1,h2,h3,h4,h5,h6,.heading-font{color:#000000}`. Compare
   the two on the selector that actually matches a [title] heading, the bare
   `h1`: one type selector, no class, no id, so (0,0,1) on BOTH sides. Equal
   specificity is decided by source order, and the theme-mod prints second, so
   BLACK won and every [title] on these pages rendered #000000, not navy.
   The homepage never showed this because its nps_* shortcodes emit
   `<h2 class="nps-h2">`, and design-system.css's `.nps-h2` is (0,1,0), which
   outranks a (0,0,1) type selector outright. titles_dividers.php:56 emits
   `<hN class="section-title section-title-{style}">` with NO .nps-h* class, so
   it had nothing above (0,0,1) to stand on and lost on source order.
   ANSWERED BELOW at `.section-title` — one class, no id, no type, so (0,1,0),
   which is strictly heavier than the (0,0,1) of BOTH declarations. That is a
   pure specificity win: it does not care that the theme-mod block prints last,
   and it needs no !important (forbidden in this file, see the header).

   FONT-FAMILY IS DELIBERATELY NOT ANSWERED HERE, AND MUST NOT BE ADDED. The
   same theme-mod block also re-declares `body{font-family:"Montserrat",
   sans-serif}` and an identical `h1..h6,.heading-font` font-family, so the
   ENTIRE SITE renders in Montserrat while design-system.css's Be Vietnam Pro
   reaches only the .nps-h* headings. A `font-family` on `.section-title` would
   win by the same (0,1,0) arithmetic the colour does — and the result would be
   a page whose section headings are Be Vietnam Pro while every other heading
   and all body copy around them stays Montserrat. Mixed is worse than
   consistent-but-wrong. The font is a Customizer decision for the site owner
   (Flatsome -> Theme Options -> Style -> Typography), recorded under "Known
   outstanding" in docs/CUSTOM-CSS-PANEL.md. Do not "complete" this block by
   adding font-family. Colour is fixable here because the design system has a
   navy token of its own; the font is not, because the fix has to be global. */
.section-title-container{ margin-bottom:clamp(1.5rem, 1.1rem + 1.6vw, 2.5rem); }

.section-title{ color:var(--nps-ink); }

.section-title .section-title-main{
  text-transform:none;
}

h1.section-title .section-title-main{ font-size:var(--nps-h1); }
h2.section-title .section-title-main{ font-size:var(--nps-h2); }
h3.section-title .section-title-main{ font-size:var(--nps-h3); }

.section-title b{ height:1px; opacity:1; background:var(--nps-line); }

.section-title .sub-title{
  display:block; margin-top:.7em;
  font-size:var(--nps-eyebrow); font-weight:700; letter-spacing:.14em;
  text-transform:uppercase; opacity:1; color:var(--nps-accent-600);
}


/* --- TESTIMONIAL — [testimonial] --------------------------------------------
   themes/flatsome/inc/shortcodes/testimonials.php:45 emits the card root as
     <div class="icon-box testimonial-box icon-box-{pos} text-{align}">
   `.icon-box` is in that class list, so the CARD block above — a bare
   `.box, .icon-box` at (0,1,0) — already gives this element the surface, the
   hairline, the radius, the lift and the 24px padding (skin.css:240). Nothing
   here repeats any of that.

   `.testimonial-text` (testimonials.php:53), `.testimonial-name` (:57) and
   `.testimonial-company` (:59) have NO rule anywhere in Flatsome's CSS —
   verified across every file in themes/flatsome/assets/css/ plus style.css and
   rtl.css, with an UNANCHORED pattern so a leading combinator or ancestor could
   not hide a match. Zero hits. The same holds for `.person-*`. So these three
   selectors face no competition on the elements themselves, and are scoped
   through `.testimonial-box` purely to keep them off anything else.

   THE INHERITED RULES ARE ON THE *SIBLING* CLASSES, not on `.testimonial-*`.
   testimonials.php:53 also stamps six more classes onto the quote —
   `line-height-small italic test_text first-reset last-reset is-italic`.
   FOUR of those six are live in flatsome.css, not two:
     .line-height-small{line-height:1.4em}
     .is-italic{font-style:italic !important}
     .first-reset *:first-child{margin-top:0}
     .last-reset *:last-child{margin-bottom:0}
   Only `.italic` and `.test_text` are dead. `.testimonial-meta` (:56) also
   carries `.pt-half{padding-top:15px}`, live, on an element this block does
   not target at all.

   Note the SHAPE of the last two: the class is followed by a descendant
   selector, so a pattern anchored as `\.first-reset\{` matches nothing and
   reports a confident false negative — which is how the first draft of this
   comment came to claim they were unstyled. Grep them unanchored:
     grep -ohE "\.(first-reset|last-reset)[^{]*\{[^}]*\}" …/flatsome.css

   Of the four, only ONE is contested here — and each of the other three is
   uncontested for its OWN reason, not a shared one:
     .first-reset / .last-reset  set margin only, on descendants; this block
       declares no margin anywhere, so they are simply left to do their job.
     .is-italic                  sets font-style, and IS a genuine contender —
       it is conceded, not dodged. See the paragraph below.
     .line-height-small          sets line-height, which this block also sets.
       This is the one real fight, and the arithmetic (id, class, type) is:
         .line-height-small{line-height:1.4em}      (0,1,0)
         .testimonial-box .testimonial-text         (0,2,0)  -> ours wins
       Specificity, not source order, settles it; the 1.6 below is real.

   `.is-italic` is UNWINNABLE without !important, which is banned here. It is
   also not worth winning: italic reads correctly for a quotation and is the
   element's own convention, so the quote stays italic by design, not by
   defeat.

   THE ONE REAL COLLISION IS WITH THIS FILE. Multi-paragraph shortcode content
   is wpautop'd into a `<p>` INSIDE `.testimonial-text` (verified through
   the_content), and skin.css:248 already declares
   `.icon-box-text p{ color:var(--nps-ink-2) }`. That rule targets the
   paragraph DIRECTLY, and a direct declaration always beats an inherited value
   regardless of specificity — so without the last rule below, a two-paragraph
   quote would silently drop to the secondary ink mid-quote. `color:inherit`
   hands the paragraph back to the quote colour:
     .icon-box-text p                           (0,1,1)
     .testimonial-box .testimonial-text p       (0,2,1)  two classes + one type
   Ours wins on specificity alone, so it would hold even if source order ran
   the other way.

   INK: the quote takes --nps-ink (15.77:1 on white) because it is the card's
   primary content; the attribution line takes --nps-ink-3 (4.99:1 on white,
   4.61:1 on --nps-surface-2 — both clear AA) because it is secondary. */
.testimonial-box .testimonial-text{
  color:var(--nps-ink); font-size:1.0625rem; line-height:1.6;
}
.testimonial-box .testimonial-text p{ color:inherit; }
.testimonial-box .testimonial-name{ color:var(--nps-ink); font-weight:700; }
.testimonial-box .testimonial-company{ color:var(--nps-ink-3); }


/* --- WASH BACKGROUND LAYERS — [section class="nps-wash"] --------------------
   Zone 3's `.nps-wash > *{ position:relative; z-index:1 }` exists to lift the
   section's CONTENT above the ::before gradient. But `>` also matches the
   section's BACKGROUND layer, which must stay out of flow.

   sections.php:120 opens `<section class="section nps-wash">` and :121 opens,
   as its FIRST DIRECT CHILD, `<div class="<?php echo $classes_bg; ?>">` — the
   class list built at :60, `array('bg','section-bg','fill','bg-fill')`.
   flatsome.css declares:
     .fill{position:absolute;top:0;left:0;height:100%;right:0;bottom:0;
           padding:0 !important;margin:0 !important}

     .nps-wash > *   one class + the universal selector (0 weight)   (0,1,0)
     .fill           one class                                       (0,1,0)

   A tie, so source order decides. flatsome.css is registered at
   `wp_enqueue_scripts` 100 (function-setup.php:195) and PRINTED into the head by
   core's `add_action('wp_head','wp_print_styles',8)`; this panel is printed by
   `add_action('wp_head','flatsome_custom_css',100)`
   (function-custom-css.php:666). 100 runs after 8, so the wash rule won and
   dragged the background div into normal flow. There it claimed layout space
   beside
   .section-content (sections.php:144), which is why page 1077's tutor section
   rendered squeezed into the right half with its text clipping at 390px.

     .nps-wash > .fill   two classes                                 (0,2,0)

   which beats (0,1,0) on specificity alone, so it holds regardless of order.
   Only `position` is restored. The Zone 3 rule still imposes `z-index:1` on the
   fill layer, which is deliberate and harmless: ::before sits at z-index 0, the
   fill layer and .section-content both sit at 1, and between those two the DOM
   order in sections.php decides — background first, content second. So the paint
   order stays gradient / background / content, exactly as intended. .fill's own
   declarations (top/left/right/bottom/height and its two !important resets) were
   never contested by anything in this file.

   Scope note: `.fill` is named rather than `.section-bg` because `.fill` is the
   class that actually carries position:absolute — the override answers the rule
   it is correcting, not a sibling class that happens to ride the same element.
   It does NOT extend the fix to the other fill layers: sections.php:127's
   `.section-bg-overlay absolute fill` and :136's `.bg-effect fill no-click` are
   nested INSIDE the :121 div, so the child combinator never reached them in
   either direction and they were never broken. */
.nps-wash > .fill{ position:absolute; }

/* --- CARD TITLE CASE — [featured_box] ---------------------------------------
   featured_box.php:86 hardcodes `<h5 class="uppercase">` around the title, and
   flatsome.css applies the transform from two separate rules:
     .uppercase,h6,th,span.widget-title{line-height:1.05;letter-spacing:.05em;
                                        text-transform:uppercase}
     .is-uppercase,.uppercase{line-height:1.2;text-transform:uppercase}
   Specificity is computed per selector in a list, so `.uppercase` is (0,1,0) in
   both. Vietnamese set in caps loses its diacritics' legibility, and the design
   system sets headings in sentence case — the same reason Task 1's block
   overrides `.section-title .section-title-main`.

     .uppercase                              one class               (0,1,0)
     .icon-box .icon-box-text h5.uppercase   three classes + one type (0,3,1)

   Ours wins outright, so no !important and no reliance on source order.

   Scoped through .icon-box (featured_box.php:72) and .icon-box-text (:85) so the
   .uppercase utility keeps working everywhere it is applied deliberately — the
   builder's default [text] block (builder/shortcodes/text.php:34), widget
   titles, table headers. [testimonial] also emits .icon-box (testimonials.php:45)
   and .icon-box-text (:51) but no h5 at all, so this cannot reach it;
   test_skin_blocks.php asserts that. Only text-transform is answered —
   .uppercase's letter-spacing is left alone, as it reads fine at sentence case
   and reverting it would be a type change this block does not own. */
.icon-box .icon-box-text h5.uppercase{ text-transform:none; }


/* --- OPT-IN: SPECTRUM — [row class="nps-spectrum"] --------------------------
   Six pastel numbered cards whose hue cycles by POSITION and whose numbers come
   from CSS counters, so the owner configures nothing: add a seventh card and it
   continues the count and wraps back to rose on its own (6n+7 == 6n+1).

   THE NAME IS nps-spectrum, NOT nps-roadmap. .nps-roadmap and .nps-step are
   already owned by the nps_process widget (home.css:105-115, process.php:64) —
   the live homepage's <ol> roadmap with a dashed connector. Reusing either would
   silently restyle it; tests/test_classes.sh guards the collision.

   WHAT [row]/[col] ACTUALLY EMIT — read before trusting the combinators.
   row.php:73 returns <div class="row nps-spectrum" id=...>{cols}{maybe <style>}</div>:
   the class is on the ROW and the columns are its DIRECT children. `ux_col` in
   row.php:205-211 (NOT a col.php — the inc/shortcodes/col.php grep will fail;
   both row and col render from row.php) emits `.col > .col-inner > {content}`,
   so a card is a GRANDCHILD of the row —
   `.col > .col-inner > .icon-box`, never a direct `.box`/`.icon-box`. That drives
   two decisions:
     1. The hue cycle targets `> *:nth-child(6n+k)`, which lands on the `.col` and
        sets --card-hue/--card-tint THERE. Custom properties inherit, so the pair
        flows down to the nested card and its ::after with no further selectors.
        (It is also why the flat preview markup and the real nested markup resolve
        identically — the var lands on whatever the direct child is and flows down.)
     2. The card itself is matched with a DESCENDANT combinator, never `>`: a child
        combinator would find nothing across `.col > .col-inner` (the Task 4 trap,
        where `.box` sat three levels below the column it was written against).
   nth-child COUNTS EVERY ELEMENT SIBLING, but nothing joins the columns before
   them: row.php concatenates the content first, and ux_builder_element_style_tag's
   <style> (row.php:73, emitted only when the row sets padding/col_bg — row.php:60-69)
   is appended AFTER the cards, so it is sibling N+1 and can never shift the phase.
   Counters walk the document tree in order regardless of nesting, so the count runs
   1,2,3... straight down the columns.

   THE CARD IS .box AND .icon-box together, exactly as the CARD block above.
   The reference is a numbered TEXT card, and the natural Flatsome element for that
   is [featured_box] -> `.icon-box` (featured_box.php:72): with no img the icon
   block is skipped (featured_box.php:74), leaving a clean image-free
   `.icon-box > .icon-box-text > h5 + p`. [ux_image_box] -> `.box` is covered too,
   but note it ALWAYS emits a `.box-image`, and flatsome_get_image('') returns the
   missing.jpg placeholder (helpers-shortcode.php:259) — so a `.box` card is an
   IMAGE card with a number badge, not a clean text card. The brief's demo markup
   (`<div class="box"><h4>...` with the heading a DIRECT child of `.box`) matches no
   shortcode: every Flatsome card nests its copy in a text wrapper (.box-text /
   .icon-box-text). The selectors below are written for that real nesting.

   Trade-off (accepted, spec section 9.5): colour is positional, so reordering
   cards changes their colours. Hue carries no meaning here, so this is harmless. */
/* LAYOUT — the pattern OWNS its grid; it does not lean on Flatsome's columns.
   A real [row] is display:flex;flex-wrap:wrap and Flatsome sizes [col span="2"]
   at max-width:16.6667%. Six of those sum to exactly 100%, and flex sub-pixel
   rounding then wraps the 6th card to a second line — and the columns are
   uneven-height, so nothing lines up. (The Task-8 preview hid this: its harness
   row carried an inline `display:grid`, so the cards were laid out by a grid the
   real page never has — the same harness-vs-reality gap that bit the banners.)
   Grid here fixes both at once: equal tracks that don't wrap-by-rounding, and
   align-items:stretch equal heights. auto-fit keeps it responsive (fewer columns
   on narrow screens) with zero media queries. The nth-child hue cycle below is
   unaffected — it counts DOM order, which grid preserves. */
.nps-spectrum{
  counter-reset:nps-step-n;
  display:grid;
  grid-template-columns:repeat(auto-fit, minmax(155px, 1fr));
  gap:1rem;
  align-items:start;   /* each card is its own natural height — see note */
}
/* Neutralise Flatsome's column sizing so the grid controls width. max-width is
   the load-bearing one: .large-2's max-width:16.6667% would otherwise shrink each
   card to a sixth of its GRID CELL.
   align-items:start is deliberate. Stretching the cards to equal height means
   forcing the grid TRACK to the tallest card, but the card's real height sits
   behind a .col > .col-inner > .icon-box chain, and every attempt to push a
   stretched height back down that chain (height:100%, flex:1, grid-auto-rows:1fr)
   made the grid size the track to ~one line (~105px) and overflow:hidden clipped
   the rest. Natural height renders every card in full with zero clipping. Cards
   whose copy differs will differ slightly in height — acceptable, and correct,
   versus a clipped stub. */
/* max-height:none defends against a stray site rule: a logo-column rule on this
   site (`.col.logo1…, .large-2 { max-height:105px }`) has a bare `.large-2` in its
   selector list, so it clamps EVERY span="2" column to 105px — which then let the
   card overflow its own column and the section, colliding with the next section.
   Our (0,2,0) selector outranks the stray `.large-2` (0,1,0). */
.nps-spectrum > .col{ max-width:none; width:auto; max-height:none; flex:none; }

.nps-spectrum > *:nth-child(6n+1){ --card-hue:var(--hue-1); --card-tint:var(--tint-1); }
.nps-spectrum > *:nth-child(6n+2){ --card-hue:var(--hue-2); --card-tint:var(--tint-2); }
.nps-spectrum > *:nth-child(6n+3){ --card-hue:var(--hue-3); --card-tint:var(--tint-3); }
.nps-spectrum > *:nth-child(6n+4){ --card-hue:var(--hue-4); --card-tint:var(--tint-4); }
.nps-spectrum > *:nth-child(6n+5){ --card-hue:var(--hue-5); --card-tint:var(--tint-5); }
.nps-spectrum > *:nth-child(6n+6){ --card-hue:var(--hue-6); --card-tint:var(--tint-6); }

/* The card takes the tint fill and drops the hairline. (0,2,0) beats the base
   `.box,.icon-box`(0,1,0) from the CARD block above and prints later, so the fill,
   the flex column and the counter all win. No inline background competes: the
   author's fill lands on .box-text / .icon-inner, never on the card root
   (ux_image_box.php:75,101 / featured_box.php:79). */
.nps-spectrum .box,
.nps-spectrum .icon-box{
  display:flex; flex-direction:column;
  background:var(--card-tint); border-color:transparent;
  box-shadow:none; padding:24px 22px;
  counter-increment:nps-step-n;
}
/* Cancel the inner text wrapper's own padding so the number and heading share one
   left edge — the card root now owns the padding. .box-text carries 20px from the
   CARD block; the flatsome.css .icon-box-text pads exist only for the -left/-right
   icon positions, so pos=top cards need nothing, but the reset is harmless and
   keeps both card types aligned. */
.nps-spectrum .box-text,
.nps-spectrum .icon-box-text{ padding:0; }

/* The step number. ::after + order:-1 floats it to the top of the flex column,
   above the heading, so NO number is ever written in the markup. order:-1 is safe:
   flatsome.css sets `.icon-box-text{order:-1}` ONLY under `.icon-box-right`
   (verified), and these are pos=top cards, so the wrapper keeps order:0 and the
   number stays above it. --card-hue inherits from the .col; contrast.py verifies
   every hue-N on tint-N at 4.5:1, so the number and heading always clear AA. */
.nps-spectrum .box::after,
.nps-spectrum .icon-box::after{
  content:counter(nps-step-n);
  order:-1; display:block;
  font-size:2rem; font-weight:800; line-height:1; margin-bottom:10px;
  color:var(--card-hue);
  font-variant-numeric:tabular-nums;
}
/* Headings: card copy is an h-tag inside the text wrapper, so a descendant match
   is required. featured_box emits h5/h6 (title/title_small) and IS the numbered
   text-card vehicle — fully covered. ux_image_box forces NO heading tag (it emits
   only author $content in .box-text-inner), so `.box h4` recolours an image-card
   heading ONLY if the author happened to write h4/h5; h1-h3 would stay navy. That
   is acceptable: the spectrum pattern is for text cards, where the vehicle is
   featured_box. (0,2,1) clears design-system.css h1-h6 navy (0,0,1) directly. */
.nps-spectrum .box h4, .nps-spectrum .box h5,
.nps-spectrum .icon-box h4, .nps-spectrum .icon-box h5, .nps-spectrum .icon-box h6{
  color:var(--card-hue); margin:0 0 10px;
}
.nps-spectrum .box p,
.nps-spectrum .icon-box p{ color:var(--nps-ink-2); font-size:.9rem; }

.nps-spectrum .box:hover,
.nps-spectrum .icon-box:hover{ transform:translateY(-5px); box-shadow:var(--nps-shadow); }

/* Reduced motion — the ONLY mover this pattern adds is the -5px hover lift, new at
   (0,3,0). The CARD block's cancellation reaches only `.box:hover`/`.icon-box:hover`
   (0,2,0) and cannot touch this heavier rule; a media query carries no specificity
   of its own. Cancel at the SAME (0,3,0) and print later so source order wins. The
   transition is already killed by the CARD block's `.box,.icon-box{transition:none}`
   (this pattern adds none of its own), so only the transform remains to cancel. */
@media (prefers-reduced-motion: reduce){
  .nps-spectrum .box:hover,
  .nps-spectrum .icon-box:hover{ transform:none; }
}


/* =============================================================================
   5. RESCUE
   On a light canvas, white-background photos and dark-on-transparent logos
   both render correctly. Nothing to rescue.
   NEVER add a global logo filter: filter:brightness(0) invert(1) would strip
   the colour out of every coloured logo to rescue the dark ones.
   ============================================================================= */
