/**
 * Esteri theme — visual layer.
 *
 * theme.json owns global tokens (palette, font families, sizes, layout).
 * This file owns:
 *   1. Lovable-source CSS-variable polyfill (--wire-edge, hero gradients, --radius).
 *   2. Reusable pattern primitives ported from `source/src/index.css`
 *      (.wire-label, .tile-engineered, .wire-section, .wire-container).
 *   3. Theme-side overrides for core blocks (button, link, group) where
 *      theme.json alone isn't enough.
 *
 * Block-specific CSS (mega-menu rendering, sticky CTA, carousel scroll-snap,
 * etc.) lives in the companion plugin sl-esteri-core, scoped to each block's
 * class prefix.
 */

/* ─── Global border-box reset (port of Tailwind Preflight) ──────────
 * The Lovable source is Tailwind, whose Preflight applies
 *   *, ::before, ::after { box-sizing: border-box }
 * to EVERY element. This port originally dropped that global invariant
 * and sprinkled `box-sizing: border-box` on individual components
 * instead — so any element the port forgot defaulted to the browser's
 * `content-box`. That silently broke every width-capping rail emitted
 * as a BARE <div> from a dynamic block's render.php (`.wire-container`,
 * `.esteri-breadcrumbs`, the product-grid filter/listing): `max-width`
 * capped only their content box, the +2×--wire-edge padding pushed them
 * past the cap to full-bleed, and `margin-inline:auto` could no longer
 * centre them — so those rails sat ~--wire-edge left of every
 * core-block rail (which DID inherit border-box from WP's `.wp-block-*`
 * reset). Restoring the global reset is the load-bearing fix: it matches
 * the source, fixes the whole class at once, and makes the scattered
 * per-component `box-sizing` patches (e.g. the carousel-nav rule below)
 * redundant. Do not remove. (Caught on /tuotteet/:category rails,
 * 2026-06-07 — see skill-additions/dynamic-block-full-bleed-bands.md.) */
*,
*::before,
*::after {
	box-sizing: border-box;
}

/* ─── Form-control font inheritance (port of Tailwind Preflight) ─────
 * Sibling to the box-sizing reset above, and dropped from the original
 * port for the same reason. Tailwind Preflight applies
 *   button, input, optgroup, select, textarea { font-family: inherit }
 * so native controls render in the body font (Inter). Without it, every
 * <button>/<input> falls back to the UA default (Arial on this box),
 * breaking font parity on the language switcher ("FI"), the product
 * "Lue lisää" toggle, download buttons, and article pagination
 * ("Edellinen / Seuraava"). Restoring the global invariant fixes the
 * whole class at once and matches the source. (Caught on a full
 * computed-style sweep, 2026-06-29.) */
button,
input,
optgroup,
select,
textarea {
	font-family: inherit;
}

:root {
	--wire-edge: 1.25rem;
	--radius: 4px;
	--hero-min:           560px;
	--hero-min-lg:        640px;
	--hero-min-compact:   360px;
	--hero-min-compact-lg:420px;
	--hero-min-page:      420px;
	--hero-min-page-lg:   500px;
	--hero-visual-min:    320px;   /* source: stacked hero image min-height (mobile) */
	--gradient-hero:        linear-gradient(160deg, hsl(222, 50%, 8%) 0%, hsl(222, 68%, 20%) 45%, hsl(222, 68%, 33%) 100%);
	--gradient-hero-split:  linear-gradient(140deg, hsl(222, 50%, 8%) 0%, hsl(222, 68%, 20%) 55%, hsl(222, 68%, 33%) 100%);
	--gradient-hero-scrim:  linear-gradient(180deg, hsla(222, 68%, 20%, 0.18) 0%, transparent 28%, transparent 65%, hsla(222, 68%, 20%, 0.35) 100%);
	--gradient-cta:         linear-gradient(135deg, hsl(222, 68%, 20%) 0%, hsl(222, 68%, 33%) 70%, hsl(222, 68%, 45%) 100%);
	--shadow-card:       0 1px 2px hsl(222 30% 12% / 0.04), 0 8px 24px -8px hsl(222 68% 33% / 0.08);
	--shadow-card-hover: 0 4px 8px hsl(222 30% 12% / 0.06), 0 16px 40px -10px hsl(222 68% 33% / 0.18);
	/* lucide ArrowUpRight (↗) — used as a CSS mask for wire-link + social CTAs. */
	--esteri-arrow-up-right: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='7' y1='17' x2='17' y2='7'/%3E%3Cpolyline points='7 7 17 7 17 17'/%3E%3C/svg%3E");
	/* lucide Bot — final-CTA "Kysy Esterinalta" primary button icon. */
	--esteri-bot: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 8V4H8'/%3E%3Crect width='16' height='12' x='4' y='8' rx='2'/%3E%3Cpath d='M2 14h2'/%3E%3Cpath d='M20 14h2'/%3E%3Cpath d='M15 13v2'/%3E%3Cpath d='M9 13v2'/%3E%3C/svg%3E");
	/* lucide Pause / Play (filled) — hero video toggle icons. */
	--esteri-pause: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='black'%3E%3Crect x='6' y='4' width='4' height='16' rx='1'/%3E%3Crect x='14' y='4' width='4' height='16' rx='1'/%3E%3C/svg%3E");
	--esteri-play: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='black'%3E%3Cpolygon points='6 3 20 12 6 21 6 3'/%3E%3C/svg%3E");
	/* lucide ArrowRight — filled-button trailing arrow on CTAs. */
	--esteri-arrow-right: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'/%3E%3Cpolyline points='12 5 19 12 12 19'/%3E%3C/svg%3E");
	/* lucide Sparkles — 404 page "Kysy Esterinalta" primary button icon
	 * (NotFound.tsx uses Sparkles here, not the Bot used by CtaBlock). */
	--esteri-sparkles: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z'/%3E%3Cpath d='M20 3v4'/%3E%3Cpath d='M22 5h-4'/%3E%3Cpath d='M4 17v2'/%3E%3Cpath d='M5 18H3'/%3E%3C/svg%3E");
	/* lucide Wrench / Phone / Shield — service-links card icons. */
	--esteri-wrench: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z'/%3E%3C/svg%3E");
	--esteri-phone: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z'/%3E%3C/svg%3E");
	--esteri-shield: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z'/%3E%3C/svg%3E");
	/* lucide Search / PhoneCall / ClipboardList — service-action-step row icons.
	 * Service version: Search → PhoneCall → Wrench. Warranty version: Search →
	 * ClipboardList → Wrench (`warranty-action-steps` swaps the middle icon). */
	--esteri-search: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'/%3E%3C/svg%3E");
	/* lucide PawPrint (v0.462) — Toimistolemmikit dept heading icon (source
	 * ContactPage uses <PawPrint className="text-brand-green"/>). */
	--esteri-paw: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='4' r='2'/%3E%3Ccircle cx='18' cy='8' r='2'/%3E%3Ccircle cx='20' cy='16' r='2'/%3E%3Cpath d='M9 10a5 5 0 0 1 5 5v3.5a3.5 3.5 0 0 1-6.84 1.045Q6.52 17.48 4.46 16.84A3.5 3.5 0 0 1 5.5 10Z'/%3E%3C/svg%3E");
	--esteri-phone-call: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z'/%3E%3Cpath d='M15.05 5A5 5 0 0 1 19 8.95M15.05 1A9 9 0 0 1 23 8.94'/%3E%3C/svg%3E");
	--esteri-clipboard-list: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect width='8' height='4' x='8' y='2' rx='1' ry='1'/%3E%3Cpath d='M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2'/%3E%3Cpath d='M12 11h4'/%3E%3Cpath d='M12 16h4'/%3E%3Cpath d='M8 11h.01'/%3E%3Cpath d='M8 16h.01'/%3E%3C/svg%3E");
	/* lucide ShieldCheck / Check — warranty-highlight stat tile + bullet icons. */
	--esteri-shield-check: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z'/%3E%3Cpath d='m9 12 2 2 4-4'/%3E%3C/svg%3E");
	--esteri-check: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E");
}

@media (min-width: 640px)  { :root { --wire-edge: 2rem; } }
@media (min-width: 768px)  { :root { --wire-edge: 2.5rem; } }
@media (min-width: 1280px) { :root { --wire-edge: 3rem; } }
@media (min-width: 1536px) { :root { --wire-edge: 3.5rem; } }

/* ─── Base typography (matches source body) ─────────────────────── */

body {
	font-family: 'Inter', system-ui, -apple-system, sans-serif;
	/* Matches source (index.css): 'ss01' + 'cv11' (Inter's single-story a).
	   cv11 is intentionally ON for Lovable parity — the single-story 'a' is the
	   designed glyph; without it WP renders a double-story 'a' that reads as a
	   different font (Saku, 2026-06-12). The self-hosted inter.woff2 must be
	   Google's unmodified v20 so these GSUB features survive (see HANDOFF cont.21
	   — a re-subset file silently strips ssXX/cvXX, making this a no-op). */
	font-feature-settings: 'ss01', 'cv11';
	font-size: 1rem;
	line-height: 1.55;
	color: hsl(222, 30%, 12%);
	background: hsl(0, 0%, 100%);
	-webkit-font-smoothing: antialiased;
	-moz-osx-font-smoothing: grayscale;
	overflow-x: clip;
}

html { scroll-behavior: smooth; }
:target, [id] { scroll-margin-top: 96px; }

h1, h2, h3, h4 {
	font-family: 'Inter', system-ui, sans-serif;
	letter-spacing: -0.02em;
	font-weight: 700;
}
h1 { letter-spacing: -0.035em; }
h2 { letter-spacing: -0.025em; }
/* The design's dominant section h2 is `text-3xl md:text-4xl` (30/36 →
 * 36/40); theme.json's element style is the fixed md+ value, so mobile
 * headings ran 6px large site-wide. `body h2` (0,0,2) outranks the
 * global-styles `h2` element rule (0,0,1) that prints after theme.css;
 * every per-surface class rule still wins. */
body h2 { font-size: 1.875rem; line-height: 1.2; }
@media (min-width: 768px) {
	body h2 { font-size: 2.25rem; line-height: 1.1112; }
}

/* ─── Layout containers (port of .wire-* primitives) ────────────── */

.wire-container,
.has-wire-container {
	margin-inline: auto;
	max-width: 1508px;
	padding-inline: var(--wire-edge);
}

/* Author-typed content that lands DIRECTLY inside an alignfull band — Enter at
 * the band edge or the outer group's appender drops the block OUTSIDE the
 * band's .wire-container — spanned the full viewport, text at the screen edge
 * (Saku, 2026-08-12: "if i put plain text the text wont stay in container its
 * too wide"; measured 1920px wide, left 0, on a probe product's hero band).
 * Give bare typed blocks the .wire-container box so they line up with the
 * band's own text column. Structural band children are always classed groups /
 * figures (verified across all 28 wire-section patterns), so only naked typed
 * blocks match. Reaches the editor canvas via theme.css being an editor style. */
.wp-block-group.wire-section > :where(p, h1, h2, h3, h4, h5, h6, ul, ol, blockquote, .wp-block-buttons) {
	margin-inline: auto;
	max-width: 1508px;
	padding-inline: var(--wire-edge);
}

/* Source-order safety net: WP block library ships a layout rule
 *   .is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)) { max-width: var(--wp--style--global--content-size); }
 * with specificity (0,1,0) — same as `.wire-container` above. On the
 * front end the layout style happens to load BEFORE theme.css, so
 * theme.css wins the tie and wire-container caps at 1508 px. In the
 * editor canvas the order is reversed (theme.css inlines first, the
 * layout style comes later) and the layout rule wins, clamping wire-
 * container to the theme.json default `contentSize: 680 px` — visible
 * as the ContactPage's editor view being roughly half as wide as
 * other pages'. Chaining to `wp-block-group` lifts the wire-container
 * cap to (0,2,0) and breaks the order-dependency in both contexts.
 * Only matters where wire-container is the className on a wp:group
 * nested inside an `is-layout-constrained` wrapper — i.e. the
 * ContactPage populator's wire-section pattern. Other wire-container
 * call-sites (site-header / site-footer / contact-tabs bar) are raw
 * divs in dynamic-block render.php and never become children of an
 * is-layout-constrained wp:group, so they keep working under the
 * lower-specificity rule. */
.wp-block-group.wire-container,
.wp-block-group.has-wire-container {
	max-width: 1508px;
}

/* Lovable contract: .wire-section is a full-bleed band — vertical rhythm +
 * border only (source index.css `.wire-section { @apply py-20 border-b }`).
 * Horizontal edge padding + the 1508px cap belong to the nested .wire-container,
 * NOT here. Putting padding-inline here made bands relying on it span full-bleed
 * while wire-container bands capped at 1508 — the wide-viewport mismatch. */
.wire-section {
	padding-block: 5rem;
	border-bottom: 1px solid hsl(220, 14%, 88%);
}

/* Background utilities — applied via className so block-validation doesn't trip on var(...) in JSON style attrs. */
.has-gradient-cta-bg        { background: var(--gradient-cta); }
.has-gradient-hero-split-bg { background: var(--gradient-hero-split); }
.has-surface-deep-bg        { background: var(--wp--preset--color--surface-deep); }
/* Display-1 heading scale (replaces clamp(...) in core/heading style attrs).
 * Source: homepage hero h1 text-4xl md:text-5xl xl:text-6xl (lg == md). Stepped to
 * match Tailwind exactly — the old clamp() undershot text-5xl across 768–936px. */
.has-display-1-size { font-size: 2.25rem; line-height: 1.05; letter-spacing: -0.035em; }   /* text-4xl */
/* md+/xl+ steps: Tailwind's text-5xl/text-6xl set their OWN line-height:1,
 * and in the source build that wins over leading-[1.05] (probed: 48/48 and
 * 60/60; only the 390 base shows 37.8 = 1.05). Match the computed values. */
@media (min-width: 768px)  { .has-display-1-size { font-size: 3rem; line-height: 1; } }     /* md:text-5xl */
@media (min-width: 1280px) { .has-display-1-size { font-size: 3.75rem; line-height: 1; } }  /* xl:text-6xl */
/* Why-Esteri stat number — source text-5xl md:text-6xl (48px → 60px). Stepped; the
 * old clamp() held 48px until ~1428px, undershooting text-6xl across the tablet range. */
.esteri-stat-num { font-size: 3rem; line-height: 1; letter-spacing: -0.04em; }   /* text-5xl */
@media (min-width: 768px) { .esteri-stat-num { font-size: 3.75rem; } }   /* md:text-6xl */
/* Final-CTA "Aloitetaan kartoituksesta" heading — source Index.tsx text-3xl md:text-4xl
 * (30px → 36px). The inline font-size was dropped from the cta-final pattern + ajankohtaista
 * populator so this stepped rule applies; the old fixed 2.25rem overshot text-3xl at mobile. */
/* Two distinct source CTAs share `.esteri-final-cta`: the HOME final-CTA is
 * bespoke (Index.tsx) — lede `max-w-lg` (512px) + size-default heading
 * line-height; every OTHER page renders the shared CtaBlock — lede `max-w-xl`
 * (576px) + heading `leading-[1.1]`. Both use `mb-4` under the heading, but the
 * populated CtaBlock content carried no h2 margin, so that 16px gap went
 * missing (home content kept its inline margin and was already correct). */
/* `.esteri-cta.is-style-esteri-cta-gradient` is aliased in beside `.esteri-final-cta`
   so pages MIGRATED off the old cta-final/final-cta wrapper onto the unified
   Loppu-CTA wrapper keep the SAME parity-tuned heading/lede geometry byte-for-byte
   (their inner blocks + inline styles are preserved; only the outer class swapped). */
.esteri-final-cta h2,
.esteri-cta.is-style-esteri-cta-gradient h2 { font-size: 1.875rem; margin-bottom: 1rem; }   /* text-3xl, mb-4 */
@media (min-width: 768px) { .esteri-final-cta h2, .esteri-cta.is-style-esteri-cta-gradient h2 { font-size: 2.25rem; } }   /* md:text-4xl */
.esteri-final-cta p,
.esteri-cta.is-style-esteri-cta-gradient p { max-width: 32rem; }                       /* home: max-w-lg (512px) */
body:not(.home) .esteri-final-cta p,
body:not(.home) .esteri-cta.is-style-esteri-cta-gradient p { max-width: 36rem; }       /* CtaBlock: max-w-xl (576px) */
/* CtaBlock lede is `leading-relaxed` = 1.625 (26px @16px). Migrated instances
 * carry no `.esteri-cta__lede` class, so theme.json's `p` element style (1.6 →
 * 25.6px) was winning — systemic root-cause #1 (the `:where(p)` 1.6 tie). */
.esteri-final-cta p,
.esteri-cta.is-style-esteri-cta-gradient p { line-height: 1.625; }
body:not(.home) .esteri-final-cta h2,
body:not(.home) .esteri-cta.is-style-esteri-cta-gradient h2 { line-height: 1.1; }      /* CtaBlock: leading-[1.1] — but only <768, see below */
/* `leading-[1.1]` is DEAD at md+ in the source: Tailwind emits responsive
 * variants after base utilities, so `md:text-4xl`'s PAIRED line-height (40px)
 * overrides the base `leading-[1.1]` from 768 up. Probed on the design:
 * 390 → 30px/33px (leading wins), 768/1024/1440 → 36px/40px (the pair wins).
 * WP kept 1.1 everywhere = 39.6px, so every CtaBlock heading was 0.4px short
 * per line and the band boundary drifted ~4px on huoltoverkosto/ajankohtaista.
 * refs: pixel-parity-audit.md § "Stepped `@media` heading sizes"; memory
 *       `tailwind-arbitrary-size-has-no-pair` (the mirror case). */
@media (min-width: 768px) {
	body:not(.home) .esteri-final-cta h2,
	body:not(.home) .esteri-cta.is-style-esteri-cta-gradient h2 { line-height: 2.5rem; }   /* md:text-4xl pair = 40px */
}
/* 7 populators (warranty, products, product-categories, category, subcategory,
   contact, service-network) hard-code an inline `font-size:2.25rem` on this h2,
   which (as an inline style) beat the line above and pinned the heading at 36px
   at EVERY width — so at <768 the source's text-3xl (30px) step was lost and
   long headings ("Kysymyksiä takuusta?") wrapped to 2 lines vs Lovable's 1.
   `!important` is the only thing that beats a non-important inline style; scoped
   to <768 so the inline 2.25rem still rules at md+. (The home final-CTA omits the
   inline size and was already correct — construction follow-up: drop the inline
   `fontSize` from those populators so this override becomes redundant.) */
@media (max-width: 767.98px) { .esteri-final-cta h2, .esteri-cta.is-style-esteri-cta-gradient h2 { font-size: 1.875rem !important; } }

/* ─── Loppu-CTA pattern (cta-final) — ONE pattern, two background block styles ──
   Saku: one CTA pattern with a sidebar switch between the brand gradient and the
   light surface. The wrapping core/group carries .esteri-cta + the chosen
   is-style-esteri-cta-{gradient,light}; text + the secondary button recolour per
   style, the accent ("with-bot") button works on both. Self-contained class so
   the parity-tuned .esteri-final-cta (home + ratkaisu) stays untouched. */
/* Clean classes for NEWLY-inserted Loppu-CTAs (heading/lede/secondary button).
   Values mirror the source CTA so a fresh insert matches migrated pages; the
   -0.025em heading tracking is also the global h2 default, so migrated pages
   (whose h2 lacks this class) get the same tracking from the global rule. */
.esteri-cta__title { font-size: 1.875rem; font-weight: 700; line-height: 1.1; letter-spacing: -0.025em; margin: 0 0 1rem; }
@media (min-width: 768px) { .esteri-cta__title { font-size: 2.25rem; } }
.esteri-cta__lede { font-size: 1rem; line-height: 1.625; max-width: 36rem; margin: 0 auto 2.5rem; }   /* source leading-relaxed */
/* Gradient (default) */
.esteri-cta.is-style-esteri-cta-gradient { background: var(--gradient-cta); }
.esteri-cta.is-style-esteri-cta-gradient .esteri-cta__title { color: hsl(0, 0%, 100%); }
.esteri-cta.is-style-esteri-cta-gradient .esteri-cta__lede { color: hsla(0, 0%, 100%, 0.7); }
/* secondary = source btn-outline-light: solid 2px white border, fills white on hover */
.esteri-cta.is-style-esteri-cta-gradient .esteri-cta__btn2 .wp-block-button__link { color: hsl(0, 0%, 100%); background: transparent; border: 2px solid hsl(0, 0%, 100%); }
.esteri-cta.is-style-esteri-cta-gradient .esteri-cta__btn2 .wp-block-button__link:hover { background: hsl(0, 0%, 100%); color: hsl(222, 68%, 33%); }
/* Light (secondary surface) */
.esteri-cta.is-style-esteri-cta-light { background: var(--wp--preset--color--secondary, hsl(220, 20%, 96%)); border-top: 1px solid var(--wp--preset--color--border, hsl(220, 13%, 91%)); border-bottom: 1px solid var(--wp--preset--color--border, hsl(220, 13%, 91%)); }
.esteri-cta.is-style-esteri-cta-light .esteri-cta__title { color: var(--wp--preset--color--foreground, hsl(222, 30%, 12%)); }
.esteri-cta.is-style-esteri-cta-light .esteri-cta__lede { color: var(--wp--preset--color--muted-foreground, hsl(222, 10%, 42%)); }
/* secondary = source CtaBlock btn-secondary: SOLID brand-blue fill, hovers accent green */
.esteri-cta.is-style-esteri-cta-light .esteri-cta__btn2 .wp-block-button__link { color: var(--wp--preset--color--primary-foreground, hsl(0, 0%, 100%)); background: var(--wp--preset--color--primary, hsl(222, 68%, 33%)); border: 2px solid var(--wp--preset--color--primary, hsl(222, 68%, 33%)); }
.esteri-cta.is-style-esteri-cta-light .esteri-cta__btn2 .wp-block-button__link:hover { background: hsl(74, 87%, 40%); color: hsl(222, 68%, 20%); }

/* The two rules above are deliberately COMPOUND (`.esteri-cta.is-style-…`) and stay
 * that way: these two looks exist only as the Loppu-CTA's two designed backgrounds,
 * and no other page has them (Saku, cont.82m, of the front page's Suunnittelu &
 * kartoitus box: "i dont think other pages has those styles so you can remove
 * those"). The chips are therefore not OFFERED anywhere else either — the plugin's
 * assets/band-colour-lock.js registers them only while a group carrying
 * `esteri-cta` is selected — so there is no dead control to make truthful with a
 * standalone rule, and relaxing the selector to a bare `.is-style-…` would drop it
 * to (0,1,0) and hand the band's background to any later single-class rule.
 * block-style-variations-from-uikit.md § "The toggle is registered GLOBALLY". */

.wire-hero {
	position: relative;
	padding-block: 8rem;
	border-bottom: 1px solid hsl(220, 14%, 88%);
}

/* ─── Brand label (eyebrow with green accent bar) ───────────────── */

/* `p.` selectors alongside the bare classes — the same tie already documented on
 * p.esteri-footer__tagline and p.esteri-utility__tagline. A bare
 * `.is-style-wire-label` is (0,1,0) and TIES with WP global styles'
 * `:root :where(p){line-height:1.6}`; the front end loads theme.css last so the
 * eyebrow won there, but the EDITOR injects global styles after the theme's, so
 * every wire-label eyebrow rendered at 24px in the canvas and 15px live (Saku,
 * 2026-08-11: "the line height is not same in live page and editor page in that
 * lue lisää pattern" — measured on /tes: eyebrow lh 15px live vs 24px editor, and
 * its margin-bottom 16px vs 0). Each selector in a list carries its own
 * specificity, so adding the `p.` form makes the paragraph case (0,1,1) and the
 * rule stops depending on load order. */
.wire-label,
.is-style-wire-label,
p.wire-label,
p.is-style-wire-label {
	display: inline-flex;
	align-items: center;
	gap: 0.625rem;
	font-family: 'Space Mono', monospace;
	font-size: 0.9375rem;
	font-weight: 500;
	line-height: 1;
	letter-spacing: 0.18em;
	text-transform: uppercase;
	color: hsl(222, 68%, 33%);
}
.wire-label::before,
.is-style-wire-label::before {
	content: '';
	flex-shrink: 0;
	width: 22px;
	height: 2px;
	background: hsl(74, 87%, 40%);
}
/* Same (0,1,1) treatment as the dark-bg variant above — same tie, same reason. */
.wire-label-light,
.is-style-wire-label-light,
p.wire-label-light,
p.is-style-wire-label-light {
	display: inline-flex;
	align-items: center;
	gap: 0.625rem;
	font-family: 'Space Mono', monospace;
	font-size: 0.9375rem;
	font-weight: 500;
	line-height: 1;
	letter-spacing: 0.18em;
	text-transform: uppercase;
	/* hsla() comma form — NOT hsl(h, s%, l% / a): mixing legacy commas with
	   slash-alpha is invalid CSS and the browser drops the whole declaration,
	   leaving the eyebrow to inherit the dark foreground (was rendering
	   rgb(21,27,40) on the dark benefits band). */
	color: hsla(0, 0%, 100%, 0.7);
}
.wire-label-light::before,
.is-style-wire-label-light::before {
	content: '';
	flex-shrink: 0;
	width: 22px;
	height: 2px;
	background: hsl(74, 87%, 40%);
}
/* Eyebrow labels: a real eyebrow word (e.g. AMMATTIPESULATEKNIIKKAA, ≤24 chars ≈
 * 200px mono) always fits its line and stays WHOLE — `overflow-wrap: anywhere`
 * only breaks a token that WOULD otherwise overflow. So every designed eyebrow
 * renders exactly like the source (wraps at spaces only, never mid-character),
 * while a customer-entered no-space run (a URL, a mashed string) wraps INSIDE the
 * band instead of forcing the whole page — or the editor canvas — to scroll
 * sideways. `anywhere` (not break-word) also lowers the element's min-content so
 * the eyebrow can shrink inside a flex/grid track rather than clipping. Applies
 * at EVERY width: real content is byte-for-byte identical to the design; hostile
 * input stays contained at both mobile and desktop (was desktop-`normal` before,
 * which let a long token overflow — the sections without `overflow:hidden`, e.g.
 * `.esteri-process`, then forced a page-wide horizontal scroll). The hero H1
 * keeps its own `overflow-wrap:normal` on purpose (Saku directive + hero
 * `overflow:hidden` clips it) — that decision is unchanged.
 * refs: responsive-testing.md § "Customer-entered content" (min-content trap);
 *       review-gate.md § L8 (no-space token must wrap, not clip). */
.wire-label,
.is-style-wire-label,
.wire-label-light,
.is-style-wire-label-light {
	overflow-wrap: anywhere;
}
/* Robustness net: any customer-entered long token (a URL, a no-space string)
 * must wrap instead of overflowing / being cut off on mobile — AND must not
 * push the page (or a grid/flex card track) wider than the viewport. Uses
 * `anywhere`, NOT `break-word`: both break a would-overflow word, but only
 * `anywhere` lowers the element's MIN-CONTENT size, so a long word inside a
 * grid/flex card (e.g. `.esteri-tile__title`) lets the track shrink instead of
 * forcing the whole page to scroll sideways (content-stress-sweep found 6
 * routes scrolling @390 with only break-word). Normal prose still wraps at
 * spaces (anywhere only breaks a word that WOULD overflow). Wrapped in :where()
 * (0 specificity) so the .wire-label `overflow-wrap:normal` exception above —
 * and any future explicit override (e.g. the hero title) — always wins.
 * refs: responsive-testing.md § "Customer-entered content" (min-content trap). */
:where(p, li, dd, dt, h1, h2, h3, h4, h5, h6,
       blockquote, figcaption, cite, td, th, .esteri-article-prose,
       .esteri-tile__title, .esteri-tile__desc) {
	overflow-wrap: anywhere;
}
/* WP core ships `.wp-block-column { word-break: break-word }` (block-library
 * inline CSS) and word-break INHERITS — so every element inside every
 * wp:columns could mid-word-break real design content the source (Tailwind,
 * no word-break anywhere) never splits (hero eyebrow AMMATTIPESULATEKNIIKKAA
 * @≤370px was the reported case; any column text 1–10px over its box is the
 * general one). Restore the design's `normal`; hostile-token safety stays
 * with the overflow-wrap:anywhere net above, which does NOT need word-break.
 * refs: responsive-testing.md § "Customer-entered content" (net rationale). */
.wp-block-column { word-break: normal; }
/* Tile titles/descs stay in the overflow-wrap:anywhere net at EVERY width
 * (they're in the :where() list above): multi-word titles wrap at spaces,
 * and a single word wider than the tile wraps mid-word instead of clipping.
 * DELIBERATE DIVERGENCE from the source (Saku directive 2026-07-30): the
 * design CLIPS an over-wide word at the tile edge (h3 has no wrap class —
 * verified clip:true on the Lovable dev server at 360, real name
 * "Maksujärjestelmät"), losing letters. Saku tested mobile view and wants
 * long tile titles to WRAP — readable beats design-exact here. */
/* Every SECTION / inner-page-hero eyebrow in the source is `wire-label block`
 * — the Tailwind `block` makes the parent display:block, so the ::before
 * becomes a normal INLINE box, which ignores width/height → the 22px green
 * dash collapses to nothing. WP renders all of those as `is-style-wire-label`
 * (inline-flex), where ::before is a blockified flex item that DOES paint the
 * dash. Suppress it so every is-style-wire-label eyebrow matches the source's
 * dash-less rendering (KÄYTTÖKOHTEET, TUOTEKATEGORIAT, MIKSI ESTERI,
 * REFERENSSIT, AJANKOHTAISTA, NÄIN EDETÄÄN, page-hero eyebrows, …).
 * NOT the dark-bg `is-style-wire-label-light` (homepage hero) — source renders
 * THAT with an explicit dash, so it keeps its ::before above. */
.is-style-wire-label::before { display: none; }

/* ─── Engineered image tile (categories / solutions / news cards) ── */

.tile-engineered {
	position: relative;
	display: block;
	overflow: hidden;
	background: hsl(222, 30%, 12%);
	border-radius: var(--radius);
}
.tile-engineered__img {
	position: absolute;
	inset: 0;
	width: 100%;
	height: 100%;
	object-fit: cover;
	transition: transform 0.5s ease-out;
}
.tile-engineered:hover .tile-engineered__img { transform: scale(1.06); }
.tile-engineered__scrim {
	position: absolute;
	inset: 0;
	pointer-events: none;
	background: linear-gradient(180deg, hsla(222, 30%, 12%, 0) 35%, hsla(222, 30%, 12%, 0.85) 100%);
}
.tile-engineered__scrim-blue {
	position: absolute;
	inset: 0;
	pointer-events: none;
	opacity: 0;
	transition: opacity 0.3s ease-out;
	background: linear-gradient(180deg, hsla(222, 68%, 20%, 0) 30%, hsla(222, 68%, 20%, 0.6) 100%);
}
.tile-engineered:hover .tile-engineered__scrim-blue { opacity: 1; }

.tile-chip {
	position: absolute;
	z-index: 10;
	display: inline-flex;
	align-items: center;
	gap: 0.375rem;
	padding: 0.25rem 0.625rem;
	background: hsl(0 0% 100% / 0.9);
	backdrop-filter: blur(4px);
	font-family: 'Space Mono', monospace;
	font-size: 0.9375rem;
	text-transform: uppercase;
	letter-spacing: 0.15em;
	color: hsl(222, 30%, 12%);
	border-radius: var(--radius);
}
.tile-chip__dot {
	width: 4px;
	height: 4px;
	border-radius: 50%;
	background: hsl(74, 87%, 40%);
}

.tile-arrow {
	position: absolute;
	z-index: 10;
	display: inline-flex;
	width: 2rem;
	height: 2rem;
	align-items: center;
	justify-content: center;
	color: hsl(222, 30%, 12%);
	background: hsl(74, 87%, 40%);
	border-radius: var(--radius);
	transform: translateX(0.5rem);
	opacity: 0;
	transition: transform 0.2s ease-out, opacity 0.2s ease-out;
}
.tile-engineered:hover .tile-arrow,
.group:hover .tile-arrow { transform: translateX(0); opacity: 1; }

.tile-engineered__bottom {
	position: absolute;
	inset-inline: 0;
	bottom: 0;
	z-index: 10;
}
.tile-engineered__sweep {
	display: block;
	width: 2rem;
	height: 2px;
	margin-bottom: 0.75rem;
	background: hsl(74, 87%, 40%);
	transform-origin: left;
	transform: scaleX(1);
	transition: transform 0.3s ease-out;
}
.tile-engineered:hover .tile-engineered__sweep { transform: scaleX(3); }

/* ─── Buttons — THE DESIGN'S FOUR VARIANTS, as pickable block styles ───
 *
 * `source/src/index.css` @layer components defines exactly four buttons, and the
 * four `register_block_style` slugs in functions.php map onto them 1:1. Everything
 * below this block is the older ATTRIBUTE-driven path (`.has-accent-background-color`
 * &c.) that shipped content still uses — the two coexist on purpose, and the style
 * rules carry `!important` so a picked style always wins over a lingering colour
 * class (core prints its colour utilities `!important`, so nothing weaker can).
 *
 *   is-style-btn-primary   ← .btn-primary       (index.css 315)
 *   is-style-btn-secondary ← .btn-secondary     (index.css 341)
 *   is-style-outline-light ← .btn-outline-light (index.css 353)  [slug unchanged]
 *   is-style-outline-dark  ← .btn-outline-dark  (index.css 381)
 *
 * Each carries the base class's OWN hover, not just its resting look
 * (block-style-variations-from-uikit.md § "Port the base class's OWN hover").
 * refs: block-style-variations-from-uikit.md § 3. */

/* .btn-primary — green fill, dark-blue label; hover inverts to a white surface
 * with a hairline ring. The `--on-light` context variant is further down, next to
 * the attribute-driven copy of the same rule, so the two stay in step. */
.wp-block-button.is-style-btn-primary > .wp-block-button__link {
	background-color: var(--wp--preset--color--accent) !important;
	color: var(--wp--preset--color--accent-foreground) !important;
	border: 0;
	transition: background-color 0.15s ease-out, color 0.15s ease-out, box-shadow 0.15s ease-out;
}
.wp-block-button.is-style-btn-primary > .wp-block-button__link:hover {
	background-color: var(--wp--preset--color--background) !important;
	color: var(--wp--preset--color--foreground) !important;
	box-shadow: inset 0 0 0 1px hsl(222 30% 12% / 0.15); /* source: hsl(var(--foreground) / 0.15) */
}
/* .btn-secondary — blue fill, white label; hover → accent green. */
.wp-block-button.is-style-btn-secondary > .wp-block-button__link {
	background-color: var(--wp--preset--color--primary) !important;
	color: var(--wp--preset--color--primary-foreground) !important;
	border: 0;
	transition: background-color 0.15s ease-out, color 0.15s ease-out;
}
.wp-block-button.is-style-btn-secondary > .wp-block-button__link:hover {
	background-color: var(--wp--preset--color--accent) !important;
	color: var(--wp--preset--color--accent-foreground) !important;
}
/* .btn-outline-dark — transparent, 2px brand-blue border + blue label. `is-style-outline`
 * is core's own slug and carried these exact declarations before the design's name
 * existed; both selectors stay so the 0 published `is-style-outline` buttons (and any
 * that predate this) keep rendering while the pickable chip is the design's name. */
.wp-block-button.is-style-outline > .wp-block-button__link,
.wp-block-button.is-style-outline-dark > .wp-block-button__link {
	background: transparent !important;
	color: hsl(222, 68%, 33%) !important;
	border: 2px solid hsl(222, 68%, 33%);
	/* source .btn-outline-dark: `transition-all duration-150` — without this the fill snaps */
	transition: background-color 0.15s cubic-bezier(0.4, 0, 0.2, 1), color 0.15s cubic-bezier(0.4, 0, 0.2, 1), border-color 0.15s cubic-bezier(0.4, 0, 0.2, 1);
}
.wp-block-button.is-style-outline > .wp-block-button__link:hover,
.wp-block-button.is-style-outline-dark > .wp-block-button__link:hover {
	background: hsl(222, 68%, 33%) !important;
	color: hsl(0, 0%, 100%) !important;
}
/* Outline-light — secondary button on dark/hero panels: solid 2px white border.
 *
 * `!important` on the colour pair for the same reason btn-primary/-secondary carry
 * it (see the § header): core prints `.has-*-background-color{…!important}`, so
 * without it a button that ALSO carries a colour class — every hero first button
 * shipped before the styles existed — keeps its old fill and the picked style only
 * draws the border. Saku, cont.82m: "if i want to change the hero's first button
 * style it wont work with all the styles" — it worked for the three variants that
 * already had `!important` and failed for exactly the three that did not. The
 * context variants below must carry it too: an important declaration beats a
 * non-important one whatever the specificity, so a plain `--default` override
 * would now lose to this base rule. */
.wp-block-button.is-style-outline-light > .wp-block-button__link {
	background: transparent !important;
	color: hsl(0, 0%, 100%) !important;
	border: 2px solid hsl(0, 0%, 100%);
	/* source .btn-outline-light: `transition-all duration-150` */
	transition: background-color 0.15s cubic-bezier(0.4, 0, 0.2, 1), color 0.15s cubic-bezier(0.4, 0, 0.2, 1), border-color 0.15s cubic-bezier(0.4, 0, 0.2, 1);
}
.wp-block-button.is-style-outline-light > .wp-block-button__link:hover {
	background: hsl(0, 0%, 100%) !important;
	color: hsl(222, 68%, 33%) !important;
}
/* On the LIGHT (default) page-hero variant an outline-light button is white on
 * white — a ghost (found cont.71: the Hero pattern ships the front-hero button
 * pair, and the variant is a customer toggle). Auto-adapt to the source's
 * .btn-outline-dark (index.css ~381: brand-blue border/text, hover inverts) so
 * one pattern stays legible on BOTH variants. Scoped to the default variant —
 * bold keeps the white outline. Mirrored to the editor via theme.css being an
 * editor style + the mirror emitting the same variant class. */
.esteri-page-hero--default .wp-block-button.is-style-outline-light > .wp-block-button__link {
	color: hsl(222, 68%, 33%) !important;
	border-color: hsl(222, 68%, 33%);
}
.esteri-page-hero--default .wp-block-button.is-style-outline-light > .wp-block-button__link:hover {
	background: hsl(222, 68%, 33%) !important;
	color: hsl(0, 0%, 100%) !important;
}
/* A WHITE FILL keeps a readable label. "Valkoinen" is in the button palette for TEXT
 * (a white label on the blue or green fill — Saku, cont.82j: "but now the text cant be
 * white"), but the same swatch can be applied to the BACKGROUND, and the button
 * element's default text colour is already white (theme.json elements.button →
 * primary-foreground), so that combination rendered an invisible label: measured
 * `background rgb(255,255,255)` on `color rgb(255,255,255)`. Give a white-filled button
 * the brand-blue label the design's own outline-light HOVER state uses, so white works
 * in both roles.
 *
 * Two rules, on purpose. The first only fires when the author set NO text colour, so an
 * explicit readable pick (e.g. Tumma sininen on white) is respected — verified, that one
 * still renders hsl(222,68%,20%) — and it is the case that matters, since a white fill
 * with no text pick is what the palette hands you by default.
 *
 * The second is the belt for white text explicitly chosen ON a white fill, which is never
 * what anyone means. `has-background-color` is the TEXT class for the `background` slug
 * (core appends `-color` for text and `-background-color` for background), and core prints
 * its colour utilities `!important`, so out-ranking one needs `!important` too.
 * **Front end only, measured:** live renders it brand blue, while the editor CANVAS still
 * shows the literal white-on-white, because there core applies the colour as an INLINE
 * style on the RichText element and neither the transformed `add_editor_style` copy nor a
 * `block_editor_settings_all` rule out-ranked it (both tried, both measured, the settings
 * one removed again rather than left in as dead code). The divergence is deliberate and
 * in the safe direction: the published page is never invisible, and in the editor the
 * author sees exactly the combination they picked and can undo it. Don't "fix" the front
 * end to match the canvas. */
.wp-block-button__link.has-background-background-color:not(.has-text-color) { color: hsl(222, 68%, 33%); }
.wp-block-button__link.has-background-background-color.has-background-color { color: hsl(222, 68%, 33%) !important; }
/* Same adaptation for a button row the CUSTOMER adds to the page flow. Since cont.82j
 * "Vaalea reunus (valkoinen)" is a pickable block style (functions.php), so this button
 * can now land on a plain light page — where a white outline is the same ghost as
 * above. Scoped to a buttons row that is a DIRECT child of the content: every shipped
 * white button lives inside a dark band (hero panel, gradient CTA, benefits) and keeps
 * its white outline. Provably inert on existing content — 0 loose button rows on every
 * published route (probed cont.82j, 5 routes). Mirrored into the canvas in
 * functions.php, where `.entry-content` does not exist. */
.entry-content > .wp-block-buttons .wp-block-button.is-style-outline-light > .wp-block-button__link {
	color: hsl(222, 68%, 33%) !important;
	border-color: hsl(222, 68%, 33%);
}
.entry-content > .wp-block-buttons .wp-block-button.is-style-outline-light > .wp-block-button__link:hover {
	background: hsl(222, 68%, 33%) !important;
	color: hsl(0, 0%, 100%) !important;
}
/* Light CTA secondary "Yhteystiedot" — mirrors source CtaBlock variant="light"
 * `.btn-secondary`: a SOLID brand-blue fill (set via the button's primary
 * background/foreground colour attrs in the pattern) that hovers to accent
 * green. The resting .has-primary-background-color fill carries !important,
 * so the hover must too. (Source dark variant uses .btn-outline-light = the
 * is-style-outline-light above, which is already correct — only LIGHT changes.) */
.esteri-cta-secondary > .wp-block-button__link:hover {
	background-color: hsl(74, 87%, 40%) !important;
	color: hsl(222, 68%, 20%) !important;
}

/* Filled accent (primary) buttons — hero "Tutustu tuotteisiin" + final-CTA
 * "Kysy Esterinalta". Source `.btn-primary:hover` inverts the green fill to a
 * white surface with dark text and a hairline ring; the WP core
 * `.wp-element-button:hover` only darkened it to primary-deep, so the hover
 * was effectively wrong/missing. */
.wp-block-button > .wp-block-button__link.has-accent-background-color {
	transition: background-color 0.15s ease-out, color 0.15s ease-out, box-shadow 0.15s ease-out;
}
.wp-block-button > .wp-block-button__link.has-accent-background-color:hover {
	/* WP emits `.has-accent-background-color { background-color: … !important }`,
	 * so the hover override must also be !important to take effect (otherwise the
	 * fill never changes and the hover reads as missing). */
	background-color: var(--wp--preset--color--background) !important;
	color: var(--wp--preset--color--foreground) !important;
	box-shadow: inset 0 0 0 1px hsl(222 30% 12% / 0.15); /* source: hsl(var(--foreground) / 0.15) */
}
/* Source `.btn-primary--on-light:hover` — on LIGHT sections the white hover
 * would vanish into the background, so the source hovers to brand navy
 * instead (index.css L335-339). Scope: every light section that carries an
 * accent-fill CTA — the light CTA band AND the service/warranty action-steps
 * band (`.esteri-service-action`, `has-secondary-background-color`, source
 * ServiceHighlight/WarrantyActionHighlight `bg-secondary` + `btn-primary--on-light`).
 * Without this the "Etsi huoltoliike" button inverts green→white (the dark-
 * section default at L398) instead of the source's green→navy. */
.esteri-cta-block--light .wp-block-button > .wp-block-button__link.has-accent-background-color:hover,
.esteri-cta.is-style-esteri-cta-light .wp-block-button > .wp-block-button__link.has-accent-background-color:hover,
.esteri-service-action .wp-block-button > .wp-block-button__link.has-accent-background-color:hover,
/* Same context variant for the PICKABLE style (is-style-btn-primary). Listed on the
 * same rule so the two paths can never drift — a variant scoped to only one of them
 * regresses the other to the dark-section default on every light band
 * (block-style-variations-from-uikit.md § "Scope a context variant to EVERY matching
 * section"). Same three wrappers, plus the light page-hero, where the design's
 * `--on-light` applies for exactly the same reason (white hover on a white band). */
.esteri-cta-block--light .wp-block-button.is-style-btn-primary > .wp-block-button__link:hover,
.esteri-cta.is-style-esteri-cta-light .wp-block-button.is-style-btn-primary > .wp-block-button__link:hover,
.esteri-service-action .wp-block-button.is-style-btn-primary > .wp-block-button__link:hover,
.esteri-page-hero--default .wp-block-button.is-style-btn-primary > .wp-block-button__link:hover,
.entry-content > .wp-block-buttons .wp-block-button.is-style-btn-primary > .wp-block-button__link:hover {
	background-color: var(--wp--preset--color--primary) !important;
	color: var(--wp--preset--color--primary-foreground) !important;
	box-shadow: none;
}

/* Suunnittelu & kartoitus panel — "Pyydä kartoitus" is a RAW <a class="wp-block-
 * button__link …"> (service-highlight.php is a custom-HTML pattern), so it has no
 * .wp-block-button parent and the accent-button hover above never matches it →
 * hover read as missing. Source CtaBannerSection uses .btn-primary on a DARK
 * panel, whose :hover inverts green→white + hairline ring. Mirror that here, and
 * nudge the arrow like every other .btn-primary svg. */
.esteri-service-cta__text-inner a.wp-block-button__link.has-accent-background-color {
	transition: background-color 0.15s ease-out, color 0.15s ease-out, box-shadow 0.15s ease-out;
}
.esteri-service-cta__text-inner a.wp-block-button__link.has-accent-background-color:hover {
	background-color: var(--wp--preset--color--background) !important;
	color: var(--wp--preset--color--foreground) !important;
	box-shadow: inset 0 0 0 1px hsl(222 30% 12% / 0.15); /* source: hsl(var(--foreground) / 0.15) */
}
.esteri-service-cta__text-inner a.wp-block-button__link svg { transition: transform 0.2s ease-out; }
.esteri-service-cta__text-inner a.wp-block-button__link:hover svg { transform: translateX(3px); }
/* The CTA is now a NATIVE core/button (was a raw-HTML <a> with inline-flex +
   an inline <svg> arrow). Restore the inline-flex layout and draw the lucide
   arrow via ::after (masked SVG, currentColor) so the button LABEL stays
   editable. service-highlight.php. */
.esteri-service-cta__text-inner .wp-block-button__link {
	display: inline-flex;
	align-items: center;
	gap: 0.5rem;
}
/* `:not(:has(svg))` so legacy raw-HTML instances (which still carry an inline
   <svg> arrow) don't get a SECOND arrow — only the native button gets ::after. */
.esteri-service-cta__text-inner .wp-block-button__link:not(:has(svg))::after {
	content: "";
	width: 16px;
	height: 16px;
	flex: 0 0 auto;
	background-color: currentColor;
	-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12h14'/%3E%3Cpath d='m12 5 7 7-7 7'/%3E%3C/svg%3E") center / contain no-repeat;
	mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12h14'/%3E%3Cpath d='m12 5 7 7-7 7'/%3E%3C/svg%3E") center / contain no-repeat;
	transition: transform 0.2s ease-out;
}
.esteri-service-cta__text-inner .wp-block-button__link:not(:has(svg)):hover::after { transform: translateX(3px); }

/* ─── Wire-link — section "Kaikki X" CTAs: mono-uppercase text-link with a
   green underline + a diagonal arrow. NOT a filled/outlined button.
   Two variants: default (dark text on light sections) and -light (white
   text on the dark product-categories section). ─────────────────────── */
.wp-block-button.is-style-wire-link > .wp-block-button__link,
.wp-block-button.is-style-wire-link-light > .wp-block-button__link {
	position: relative;
	display: inline-flex;
	align-items: center;
	gap: 0.5rem;
	padding: 0 0 0.25rem;
	/* !important on the colour pair — same reason as the four filled variants (see
	 * the "THE DESIGN'S FOUR VARIANTS" header): a button that still carries a
	 * `has-*-background-color` utility from before the styles existed would keep its
	 * fill and render as a filled button with an underline. */
	background: transparent !important;
	border: 0;
	border-bottom: 2px solid var(--wp--preset--color--accent);
	border-radius: 0;
	font-family: 'Space Mono', monospace;
	font-size: 0.9375rem;
	font-weight: 400;
	letter-spacing: 0.18em;
	text-transform: uppercase;
	line-height: 1.6;   /* source wire-links inherit 24px @15px (was 1.2 → 18px) */
	/* WP buttons default to text-align:center; the source wire-links are
	 * left-aligned (`self-start`). On one line it's invisible, but in narrow
	 * columns (e.g. the 3-up service links ~880px) "Pepa varaosakauppa" wraps —
	 * and centered, the wrapped lines looked misaligned vs Lovable's left edge. */
	text-align: left;
	transition: color 0.3s ease-out;
}
.wp-block-button.is-style-wire-link > .wp-block-button__link {
	color: var(--wp--preset--color--foreground) !important;
}
.wp-block-button.is-style-wire-link > .wp-block-button__link:hover {
	color: var(--wp--preset--color--primary-deep) !important;
	background: transparent !important;
}
/* Hover animation (source: SolutionsCarouselSection / ReferencesSection links).
 * A deep-blue underline sweeps left→right over the static green underline. The
 * green is the element's border-bottom; this ::before overlays it (bottom:-2px
 * sits it exactly on the 2px border) and scales from 0→full width on hover.
 * Default variant only — the -light variant is on a dark band where a blue
 * sweep would be invisible (and Lovable's light links don't carry it). */
.wp-block-button.is-style-wire-link > .wp-block-button__link::before {
	content: '';
	position: absolute;
	left: 0;
	bottom: -2px;
	width: 100%;
	height: 2px;
	background: var(--wp--preset--color--primary-deep);
	transform: scaleX(0);
	transform-origin: left;
	transition: transform 0.5s ease-out;
}
.wp-block-button.is-style-wire-link > .wp-block-button__link:hover::before {
	transform: scaleX(1);
}
.wp-block-button.is-style-wire-link-light > .wp-block-button__link {
	color: var(--wp--preset--color--primary-foreground) !important;
}
.wp-block-button.is-style-wire-link-light > .wp-block-button__link:hover {
	color: var(--wp--preset--color--accent) !important;
	background: transparent !important;
}
/* The -light variant's only source instance (home ProductCategoriesSection "Kaikki tuotteet"):
 * `hover:text-brand-green transition-colors` (150ms) and a HALF-step arrow nudge
 * (`group-hover:translate-x-0.5 group-hover:-translate-y-0.5` = 2px) — unlike SectionHeader's 4px. */
.wp-block-button.is-style-wire-link-light > .wp-block-button__link { transition: color 0.15s cubic-bezier(0.4, 0, 0.2, 1); }
/* Diagonal ↗ arrow via CSS mask so the label text stays clean + editable. */
.wp-block-button.is-style-wire-link > .wp-block-button__link::after,
.wp-block-button.is-style-wire-link-light > .wp-block-button__link::after {
	content: '';
	width: 16px;
	height: 16px;
	flex-shrink: 0;
	background-color: currentColor;
	-webkit-mask: var(--esteri-arrow-up-right) center / contain no-repeat;
	        mask: var(--esteri-arrow-up-right) center / contain no-repeat;
	transition: transform 0.15s cubic-bezier(0.4, 0, 0.2, 1);   /* source: `transition-transform` (150ms) */
}
.wp-block-button.is-style-wire-link > .wp-block-button__link:hover::after,
.wp-block-button.is-style-wire-link-light > .wp-block-button__link:hover::after {
	/* source SectionHeader.tsx:35 `group-hover:translate-x-1 group-hover:-translate-y-1` = 4px (was 2px) */
	transform: translate(4px, -4px);
}
/* -light: half-step nudge (see the note at its base rule) — equal specificity, so it must follow the 4px rule. */
.wp-block-button.is-style-wire-link-light > .wp-block-button__link:hover::after { transform: translate(2px, -2px); }
/* SiteFooter.tsx:111-114 — the footer's btn-primary carries its OWN arrow rule
 * (`transition-transform duration-200 group-hover:translate-x-1` = 4px), not the 3px generic. */
.esteri-footer .wp-block-button.has-arrow > .wp-block-button__link:hover::after,
.esteri-footer .wp-block-button.has-arrow > .wp-block-button__link:hover svg { transform: translateX(4px); }
/* NewsSection.tsx:143 is the one view-all link the design builds differently: a STATIC
 * green border-b-2 (no blue sweep), `hover:text-brand-blue-deep transition-colors`
 * (150ms) and a half-step nudge (`translate-x-0.5 -translate-y-0.5` = 2px). */
.esteri-news .wp-block-button.is-style-wire-link > .wp-block-button__link { transition: color 0.15s cubic-bezier(0.4, 0, 0.2, 1); }
.esteri-news .wp-block-button.is-style-wire-link > .wp-block-button__link::before { display: none; }
.esteri-news .wp-block-button.is-style-wire-link > .wp-block-button__link:hover::after { transform: translate(2px, -2px); }

/* ─── CC7 — carousel "view-all" link: desktop header copy + mobile below-nav copy ───
   Source SolutionsCarouselSection / ArticleReferencesCarousel / NewsSection render
   the view-all link TWICE: a `hidden md:inline-flex` copy in the section head, and a
   `md:hidden` copy placed AFTER the nav arrows on mobile. WP previously emitted only
   the head copy at all widths, so on mobile it sat above the carousel. These utilities
   reproduce the dual-copy behaviour. The news rail switches at sm=640; the rest at
   md=768 — hence the `--md` / `--sm` variants. */
@media (max-width: 767.98px) { .esteri-viewall--md-up { display: none !important; } }
@media (max-width: 639.98px) { .esteri-viewall--sm-up { display: none !important; } }

/* Section-head view-all row — match source's `flex items-end justify-between
 * flex-wrap gap-4`. The WP build emits a 66/34 `wp:columns` for these heads
 * (Ratkaisut / Tuotteet / Referenssit / Ajankohtaista), which keeps the link
 * pinned bottom-right through the ~768–900 band where the source instead wraps
 * the link BELOW the heading, left-aligned. Convert the two columns to
 * natural-width flex items (heading grows, link is intrinsic width) so the link
 * wraps below-left exactly like source — at every width, not just <782.
 * Scoped via :has() to ONLY the heads that carry a desktop view-all link, so
 * no other wp:columns is touched. !important + the high :has() specificity beat
 * WP-core's inline flex-basis:66/34 and its ≤781 stack rule (flex-basis:100%). */
.wp-block-columns:has(> .wp-block-column > .wp-block-buttons .esteri-viewall--md-up),
.wp-block-columns:has(> .wp-block-column > .wp-block-buttons .esteri-viewall--sm-up) {
	flex-wrap: wrap;
	align-items: flex-end;
	justify-content: space-between;
	column-gap: 1rem;
	row-gap: 1rem;
}
.wp-block-columns:has(> .wp-block-column > .wp-block-buttons .esteri-viewall--md-up) > .wp-block-column,
.wp-block-columns:has(> .wp-block-column > .wp-block-buttons .esteri-viewall--sm-up) > .wp-block-column {
	/* Natural (max-content) width — NOT flex-grow. The heading block sizes to
	 * its widest line so the link only fits beside it while there's room; once
	 * heading + link exceed the row the link wraps below (left-aligned), exactly
	 * like source. flex-grow here would force the heading to fill the row and
	 * keep the link pinned right — the bug we're fixing. */
	flex: 0 1 auto !important;
	flex-basis: auto !important;
}
/* The CTA column must shrink to the link's width (not fill the row). Otherwise,
 * when it wraps to its own line it spans full width and its inner
 * `is-content-justification-right` buttons wrapper pushes the link to the RIGHT;
 * source shows it LEFT after a wrap. fit-content + no grow keeps the column =
 * link width, so on a wrapped line `justify-content:space-between` (single item)
 * lands it at flex-start (left); when it fits beside the heading it sits right. */
.wp-block-columns:has(> .wp-block-column > .wp-block-buttons .esteri-viewall--md-up) > .wp-block-column:last-child,
.wp-block-columns:has(> .wp-block-column > .wp-block-buttons .esteri-viewall--sm-up) > .wp-block-column:last-child {
	flex: 0 0 auto !important;
	width: -moz-fit-content !important;
	width: fit-content !important;
}
/* At the breakpoint where the desktop view-all link exists, the source head is
 * `md:flex-row md:justify-between` with NO wrap: the text column SHRINKS (its
 * subtext wraps internally) and the link stays pinned right. The wrap-based
 * emulation above only matches below the breakpoint, where this link is
 * hidden anyway — without nowrap the text column's max-content (one long
 * subtext line) forces the link onto its own row through the 768–900 band. */
/* Source news head lede carries max-w-md (28rem, NewsSection.tsx) — capping
 * the head column's max-content is what lets the "KAIKKI ARTIKKELIT" link fit
 * beside it from 768 up (the head row wraps by max-content, rule below). */
.esteri-news .wp-block-columns > .wp-block-column:first-child > p.has-text-color {
	max-width: 28rem;
}
/* From the breakpoint where the desktop view-all link EXISTS, the head must
 * keep it beside the heading like the source does at these widths (references
 * head is source `md:flex-row` nowrap; news fits inside its wrap because its
 * lede is max-w-md-capped) — WP's wrap-based emulation broke the link onto
 * its own row through the 768–781 band where WP-core's stack rules interfere.
 * nowrap + a shrinkable first column reproduces the source: the text column
 * gives way (lede wraps internally), the link stays pinned right. Below the
 * breakpoint the link is display:none and the mobile viewall-m takes over,
 * so nowrap changes nothing there. !important beats core's
 * `flex-wrap:wrap !important` ≤781 stack rule via :has() specificity. */
@media (min-width: 768px) {
	.wp-block-columns:has(> .wp-block-column > .wp-block-buttons .esteri-viewall--md-up) {
		flex-wrap: nowrap !important;
	}
	.wp-block-columns:has(> .wp-block-column > .wp-block-buttons .esteri-viewall--md-up) > .wp-block-column:first-child {
		min-width: 0;
	}
}
@media (min-width: 640px) {
	.wp-block-columns:has(> .wp-block-column > .wp-block-buttons .esteri-viewall--sm-up) {
		flex-wrap: nowrap !important;
	}
	.wp-block-columns:has(> .wp-block-column > .wp-block-buttons .esteri-viewall--sm-up) > .wp-block-column:first-child {
		min-width: 0;
	}
}

.esteri-viewall-m {
	display: inline-flex;
	align-items: center;
	gap: 0.5rem;
	margin-top: 2rem;
	padding-bottom: 0.25rem;
	font-family: var(--wp--preset--font-family--mono);
	font-size: 0.9375rem; /* source `text-sm` = 15px (project sm override) */
	font-weight: 400;
	text-transform: uppercase;
	letter-spacing: 0.18em;
	color: var(--wp--preset--color--foreground);
	text-decoration: none;
	border-bottom: 2px solid var(--wp--preset--color--accent);
	transition: color 0.3s;
}
.esteri-viewall-m:hover { color: var(--wp--preset--color--primary-deep); }
/* Light variant — for the dark product-categories band (source: white text,
 * `hover:text-brand-green`). */
.esteri-viewall-m--light { color: var(--wp--preset--color--primary-foreground); }
.esteri-viewall-m--light:hover { color: var(--wp--preset--color--accent); }
.esteri-viewall-m__arrow {
	flex: 0 0 16px;
	width: 16px;
	height: 16px;
	background-color: currentColor;
	-webkit-mask: var(--esteri-arrow-up-right) center / contain no-repeat;
	        mask: var(--esteri-arrow-up-right) center / contain no-repeat;
}
@media (min-width: 768px) { .esteri-viewall-m--md { display: none !important; } }
@media (min-width: 640px) { .esteri-viewall-m--sm { display: none !important; } }

/* ─── Block style: wire-label (eyebrow) ─────────────────────────── */

/* `:where()` so this is a TRUE default (specificity 0) — the design gives each
 * eyebrow its own bottom margin (mb-1/2/3/4/5 = 4/8/12/16/20px), and every
 * per-section rule that encodes one is a single class, (0,1,0). As a plain
 * `.wp-block-paragraph.is-style-wire-label` (0,2,0) this generic 1rem OUTRANKED
 * all of them, so sections that had correctly declared the source's mb-3 still
 * rendered 16px — `.esteri-service-action__eyebrow` and the ratkaisut process
 * eyebrow both showed up in the sweep as "src 12 vs wp 16" while their own CSS
 * said 0.75rem. (The one place that had diagnosed it patched around the
 * cascade with a (0,2,1) compound selector — see
 * `p.is-style-wire-label.esteri-warranty-terms__eyebrow`.) With zero
 * specificity here, a section rule wins just by existing, and mb-4 consumers
 * (ProductsPage) keep 1rem by falling through.
 * refs: pixel-parity-audit.md § Tolerances; gotchas.md § specificity. */
:where(.wp-block-paragraph.is-style-wire-label) {
	margin: 0 0 1rem;
}

/* Section eyebrows are BLOCK-level, not inline-flex — `block` in the source
 * removes the LINE BOX, not just the dash.
 *
 * Every rendered eyebrow in the design carries Tailwind `block`
 * (`wire-label block mb-N` — 29 of 29 instances; the two non-`block` ones are
 * a <p> (block anyway) and a CtaBlock branch no page passes an eyebrow to).
 * The known consequence was the collapsed ::before dash (see the global
 * suppression above). The UNKNOWN one cost 5 routes their spacing parity: an
 * `inline-flex` eyebrow is an INLINE-LEVEL box, so it sits in a line box
 * struck by the PARENT's line-height (24.8px here) and gains half-leading
 * ABOVE (~6px) and descender slack BELOW (~4px) that the design's block-level
 * eyebrow does not have. Nothing in the eyebrow's own computed style shows it:
 * box height, font, margins and the text's x all match the design exactly —
 * only the box's Y position moves. Paired-shot and strip diffs never saw it
 * either, because the 6px above and 4px below cancel out over the band.
 * Caught by tools/spacing-parity.mjs (whitespace-above, text-paired) on
 * index/tuotteet/huolto-takuu/tuote-esteri-7860 and confirmed by hand-probing
 * both origins: design display:block vs WP inline-flex, everything else equal.
 *
 * `.esteri-contact-tabs` had already been scope-stripped to block for the same
 * reason (see that rule) — which is exactly why /yhteystiedot was the one
 * eyebrow-carrying route the sweep passed clean. This generalises it.
 *
 * Two bespoke hero eyebrows are genuinely inline-flex in the source (a real
 * `<span class="w-10 h-[2px] bg-brand-green">` dash, not the pseudo) and
 * re-declare `display: inline-flex` at their own rules: `.esteri-hero-front`
 * (index) and `.esteri-page-hero--bold` (every page hero).
 *
 * Keyed on the STYLE class alone, not `.wp-block-paragraph.…`: several eyebrows
 * live in custom-HTML patterns and carry only `is-style-wire-label` (e.g.
 * `.esteri-products-categories`'s TUOTEKATEGORIAT), so a `.wp-block-paragraph`-
 * qualified rule silently skipped them.
 * refs: tailwind-cheatsheet.md § "`block` overrides `display` — read pseudos
 *       through that lens"; pixel-parity-audit.md § "What to measure". */
.is-style-wire-label,
.is-style-wire-label-light {
	display: block;
}

/* Section-header eyebrows above an h2: source is `wire-label block mb-3`
 * = 12px (SolutionsCarouselSection / references / NewsSection / SolutionsPage's
 * "NÄIN EDETÄÄN" process band). The generic 1rem above stays for the design's
 * mb-4 eyebrow consumers.
 * Found by spacing-parity (ws-above src 12 vs wp 16 on all four). */
.esteri-solutions .wp-block-paragraph.is-style-wire-label,
.esteri-references .wp-block-paragraph.is-style-wire-label,
.esteri-process .wp-block-paragraph.is-style-wire-label,
.esteri-news .wp-block-paragraph.is-style-wire-label {
	margin-bottom: 0.75rem;
}

/* ─── Why-Esteri stat rule (2px accent bar; restored after dropping border.top JSON) ── */

.esteri-why__rule {
	border: 0;
	height: 2px;
	width: 2.5rem;
	margin-left: 0;
	background: var(--wp--preset--color--accent);
}

/* ─── Hero-front split panel (left column padding restored after dropping JSON style) ── */

.esteri-hero-front {
	border-bottom: 1px solid var(--wp--preset--color--border);
	/* Source: `relative border-b overflow-hidden` (Index.tsx hero section).
	 * The clip is load-bearing for the eyebrow's overflow-wrap:normal below —
	 * an overshooting token clips at the section edge instead of scrolling
	 * the page, exactly like the design. */
	overflow: hidden;
}
/* The source hero (Index.tsx) builds its eyebrow INLINE with bespoke values, not
 * the generic .wire-label-light class: `gap-3` (0.75rem), `w-10` dash (40px),
 * `tracking-[0.2em]`, and `font-mono` with no weight class (= 400). Scope the
 * override to the hero so the SHARED .is-style-wire-label-light (sol-benefits
 * "EDUT JA HYÖDYT" eyebrow) keeps its generic 22px / 0.18em / 500 values. */
.esteri-hero-front .is-style-wire-label-light {
	/* Explicit: the paragraph eyebrows are block-level by default (see the
	 * strut note there). This one is a bespoke inline-flex row in the source
	 * with a REAL 40px dash element, so it must stay inline-flex — a block
	 * parent would collapse the pseudo dash to zero width. */
	display: inline-flex;
	gap: 0.75rem;
	font-weight: 400;
	letter-spacing: 0.2em;
	/* The source's inline eyebrow span has NO leading class — it inherits the
	 * body line-height (probed 24px @15px); the generic wire-label lh:1 made the
	 * 2-line wrap at 390 render cramped. */
	line-height: 1.6;
}
/* The generic wire-label `overflow-wrap:anywhere` net broke the REAL eyebrow
 * word AMMATTIPESULATEKNIIKKAA (273px @15px mono — wider than the ~200px the
 * net's comment assumed) mid-word on narrow phones (probed: 21+2 chars @360,
 * 268/183 split @375; Saku report 2026-07-30). The word RENDERS 280px wide
 * (probed on the source) and fits its box from 378px up (box = vw − 44 panel
 * padding − 52 dash+gap ≥ 282; at exactly 376–377 the 280.0px box ties the
 * word's own 280.x and `anywhere` still split it — probed), so `anywhere` is invisible on real content
 * there and STAYS for hostile-token robustness (L8 injects at 390). Below
 * 378 parity wins: `normal` renders the word whole like the source; an
 * overshooting token clips at the hero's overflow:hidden edge — the design's
 * own containment. */
@media (max-width: 377.98px) {
	.esteri-hero-front .is-style-wire-label-light { overflow-wrap: normal; }
}
/* Source hero grid is `grid lg:grid-cols-2` — below lg its single IMPLICIT
 * track is auto-sized: max(viewport, min-content). On very narrow phones
 * (<~376px) the design panel therefore KEEPS its content's min-width (the
 * eyebrow dash+gap+word run + padding ≈ 376) and the section's
 * overflow:hidden clips the overhang — the text box never shrinks below
 * ~332px. WP's stacked flex column instead shrank with the viewport
 * (316px text box @360), re-wrapping the hero lede lines 3–4 (probed
 * [262,284] vs source [323,222] — Saku report 2026-07-30 "hero texts differ
 * in mobile"). min-content reproduces the source's content-driven floor;
 * above ~376px it is a no-op. */
@media (max-width: 1023.98px) {
	.esteri-hero-front__panel { min-width: min-content; }
}
/* Below 352px that floor runs off the phone. The floor puts the panel's CONTENT
 * at 20 + 332 = 352px, so from 352 up everything is still on screen (only the
 * right padding overhangs) and the parity rule above stands. Under 352 the source's
 * flaw cuts the eyebrow, H1, lede and buttons at the screen edge (32px @320,
 * phone-offscreen-sweep). Divergence, Saku 2026-09-25 "fix … as it goes off the
 * screen": the panel fits the viewport, and the eyebrow shrinks just enough for
 * its 40px dash + 12px gap + AMMATTIPESULATEKNIIKKAA (18.67em incl. the 0.2em
 * tracking; 18.8 leaves ~2px) to share the panel's content box (vw − 44). The
 * word stays WHOLE (overflow-wrap:normal above). ~12px @320, 13.7px @351.
 * refs: parity-acceptance-standard.md § divergence ledger. */
@media (max-width: 351.98px) {
	.esteri-hero-front__panel { min-width: 0; }
	.esteri-hero-front .is-style-wire-label-light {
		font-size: min(0.9375rem, calc((100vw - 96px) / 18.8));
	}
}
.esteri-hero-front .is-style-wire-label-light::before {
	width: 40px;
}
/* Source splits the hero 50/50 only at lg (≥1024) — `grid lg:grid-cols-2`. Core
 * `isStackedOnMobile` columns instead stack at 782px, so at 782–1023px WP showed a
 * cramped 50/50 hero whose title wrapped differently than Lovable (the reported
 * "title text is different on different viewports"). Force a full-width stack below
 * 1024; the compound `.wp-block-columns.esteri-hero-front__grid` selector + !important
 * beat core's `min-width:782px { flex-wrap:nowrap !important }` and the inline 50%. */
@media (max-width: 1023.98px) {
	.wp-block-columns.esteri-hero-front__grid { flex-wrap: wrap !important; }
	.esteri-hero-front__grid > .wp-block-column {
		flex-basis: 100% !important;
		max-width: 100% !important;
		width: 100% !important;
	}
}
.esteri-hero-front__panel {
	/* Source panel scales `py-10 md:py-14 lg:py-20` (Index.tsx) — a fixed 5rem
	 * made the mobile hero ~73px taller than the design (probed 664 vs 591 @390).
	 * min-h-[var(--hero-min)] keeps the tablet hero at design height once the
	 * padding stops inflating it (probed LOV 560 @768). */
	padding-block: 2.5rem;
	padding-left: max(var(--wire-edge), calc((100vw - 1508px) / 2 + var(--wire-edge)));
	padding-right: clamp(1.5rem, 4vw, 4rem);
	min-height: var(--hero-min, 560px);
	/* Source: `flex items-center` — content centers when min-height wins. */
	display: flex;
	flex-direction: column;
	justify-content: center;
}
@media (min-width: 768px) {
	.esteri-hero-front__panel { padding-block: 3.5rem; }
}
@media (min-width: 1024px) {
	.esteri-hero-front__panel { padding-block: 5rem; min-height: var(--hero-min-lg, 640px); }
}
/* Source wraps ALL panel content in `max-w-xl` (Index.tsx `<div class="relative
 * z-10 max-w-xl">`): without the cap the h1 runs 634px wide at 1920 and the
 * headline wraps 3 lines where the source wraps 5. The lead keeps its tighter
 * 32rem (max-w-lg) cap below. */
.esteri-hero-front__panel > * {
	max-width: 36rem;
}
/* Hero headline: NO h1-specific max-width — it inherits the panel's 36rem
 * (max-w-xl) cap above, matching the Lovable source (Index.tsx `max-w-xl`)
 * EXACTLY. At the xl 60px size "Oikea ratkaisu edellä," (~614px) exceeds 36rem
 * (576px) and the `&nbsp;` binds "ratkaisu edellä," together, so the headline
 * wraps to 4 lines with "Oikea" on its own line — 1:1 with Lovable. (Saku:
 * "match lovable 1:1" — supersedes the earlier cont.58 keep-on-3-lines request,
 * which had widened this to 42rem.) The lead keeps its own 32rem cap below. */
/* PHONE band (<394px) — DELIBERATE DIVERGENCE, Saku directive 2026-07-30
 * (supersedes the 1:1 directive above for THIS band only): at text-4xl/36px
 * every viewport under 394px orphans "Oikea" on its own line — in the DESIGN
 * TOO (verified side-by-side, design-screenshots/diff/hero-4up-2026-07-30
 * .png) — and Saku rejected that rendering on sight ("if it looks different/
 * wrong it's a bug"). 2rem keeps "Oikea ratkaisu edellä," on one line in the
 * narrowest phone box (311px @32 vs ≥331px box), giving every phone the same
 * 3-line headline the design shows once its box fits (350.x px — a 394–397
 * viewport TIES the 350 box and still orphans, probed). From 398px the 1:1
 * rule above resumes. */
@media (max-width: 397.98px) {
	.esteri-hero-front h1.wp-block-heading {
		font-size: 2rem;
		line-height: 1.05;
	}
}
/* Lead paragraph constrained to match source max-w-lg (~512px).
 * Source `text-base lg:text-lg` — probed 16px/26px base, 18px/28px ≥1024. */
.esteri-hero-front__lead {
	max-width: 32rem;
	font-size: 1rem;
	line-height: 1.625;
}
@media (min-width: 1024px) {
	.esteri-hero-front__lead { font-size: 1.125rem; line-height: 1.5556; }
}
/* Right column = autoplaying video filling the panel, scrim + pause toggle. */
.esteri-hero-front__visual { padding: 0; }
.esteri-hero-video {
	position: relative;
	width: 100%;
	height: 100%;
	min-height: 320px;
	overflow: hidden;
	background: var(--wp--preset--color--muted);
}
@media (min-width: 1024px) { .esteri-hero-video { min-height: 100%; } }
.esteri-hero-video__el {
	position: absolute;
	inset: 0;
	width: 100%;
	height: 100%;
	object-fit: cover;
}
.esteri-hero-video__scrim {
	position: absolute;
	inset: 0;
	pointer-events: none;
	background: var(--gradient-hero-scrim);
}
.esteri-hero-video__toggle {
	position: absolute;
	bottom: 1rem;
	right: 1rem;
	z-index: 10;
	display: inline-flex;
	align-items: center;
	justify-content: center;
	width: 2.75rem;
	height: 2.75rem;
	background: hsl(222 30% 12% / 0.6);
	color: hsl(0 0% 100%);
	border: 1px solid hsl(0 0% 100% / 0.25);
	border-radius: var(--radius);
	-webkit-backdrop-filter: blur(8px);
	        backdrop-filter: blur(8px);
	cursor: pointer;
	transition: background 0.2s, color 0.2s, border-color 0.2s;
}
.esteri-hero-video__toggle:hover {
	background: var(--wp--preset--color--accent);
	color: var(--wp--preset--color--foreground);
	border-color: var(--wp--preset--color--accent);
}
.esteri-hero-video__icon {
	width: 18px;
	height: 18px;
	background-color: currentColor;
	-webkit-mask: var(--esteri-pause) center / contain no-repeat;
	        mask: var(--esteri-pause) center / contain no-repeat;
}
.esteri-hero-video__toggle[data-state="paused"] .esteri-hero-video__icon {
	-webkit-mask: var(--esteri-play) center / contain no-repeat;
	        mask: var(--esteri-play) center / contain no-repeat;
}

/* ─── Engineered tile — bottom-anchored body, 4:5 aspect, chip top-left, scrim, sweep, title ── */

/* Raw-HTML tile (product categories, solutions, references) — single anchor wrapping
   image + scrim + chip + body. Positioned children resolve to .esteri-tile, not to a
   WP-injected inner-container. */
a.esteri-tile,
.esteri-tile {
	position: relative;
	/* Grid, not block: the ratio floor (::before) and the body share ONE cell, so
	   the card is `max(4:5, its own text)` — see the ::before note below. */
	display: grid;
	grid-template-columns: minmax(0, 1fr);
	/* 125cqw == the 4:5 height, i.e. the card's own "un-grown" height as a length.
	   __scrim uses it to keep its wash identical on every normal card. */
	container-type: inline-size;
	/* A grown card must not stretch its row-mates (grid) / rail-mates (flex). Every
	   normal tile is 4:5, so at rest this is a no-op. */
	align-self: start;
	overflow: hidden;
	border-radius: var(--radius);
	background: var(--wp--preset--color--foreground);
	color: var(--wp--preset--color--primary-foreground);
	text-decoration: none;
}
/* 4:5 is a FLOOR, not a fixed height. `aspect-ratio` on the tile itself can never
   grow with its text: a box whose `overflow` is hidden has an automatic minimum
   size of 0, so a long Kortin kuvaus was guillotined at the card edge instead
   (Saku, 2026-08-07, /ratkaisut/taloyhtiot "Palokuntapyykki": 506 px of caption in
   a 421 px card — the text also grew UP over its own title, since __body was
   bottom-anchored `absolute`). So the ratio moves to an in-flow, zero-content
   spacer sharing cell 1/1 with __body: the cell is max(spacer, body), which leaves
   every normal card at its exact 4:5 box and grows only the cards that need it.
   `::after` is the blue hover scrim (grids-listings-and-carousels.md § "engineered
   image tiles have TWO scrims") — do not reuse it for layout. */
.esteri-tile::before {
	content: '';
	grid-area: 1 / 1;
	width: 100%;
	aspect-ratio: 4 / 5;
	pointer-events: none;
}
.esteri-tile__img {
	position: absolute;
	inset: 0;
	width: 100%;
	height: 100%;
	object-fit: cover;
	transition: transform 0.5s ease-out;
}
.esteri-tile:hover .esteri-tile__img { transform: scale(1.06); }
.esteri-tile__scrim {
	position: absolute;
	inset: 0;
	pointer-events: none;
	/* Bottom-anchored and length-based instead of the source's `from 35%`, so the
	   wash follows a grown card. --tile-grow is 0 px on every card that still fits
	   its 4:5 box; there the first two stops collapse to 0 and 81.25cqw == 65 % of
	   4:5, so this resolves to EXACTLY the source gradient (verified: hovered normal
	   card is pixel-identical before/after). Once a long caption grows the card the
	   text covers the whole photo, so the wash has to become a plate rather than a
	   bottom fade — it climbs at 4× the growth, capped 4rem short of the top edge so
	   the image still reads through above the title. */
	/* --card-floor is the 4:5 height as a BORDER-box length. 125cqw is the content
	   box, which equals the border box on the image tiles but not on the CTA
	   placeholder, which pads itself — hence the --ph-* correction (0 unless the
	   card declares them), or a --has-image CTA card would read as "grown" by its
	   own padding and darken past parity. */
	--card-inset: calc(var(--ph-pad, 0px) + var(--ph-bord, 0px));
	--card-floor: calc(125cqw + 2.5 * var(--card-inset));
	--tile-grow: max(0px, 100% + 2 * var(--ph-bord, 0px) - var(--card-floor));
	background: linear-gradient(0deg,
		hsl(222 30% 12% / 0.92) 0,
		hsl(222 30% 12% / 0.92) min(calc(var(--tile-grow) * 4), 100% - 4rem),
		hsl(222 30% 12% / 0) min(calc(var(--tile-grow) * 4 + 0.65 * var(--card-floor)), 100%));
}
/* Blue hover scrim — source .tile-engineered__scrim-blue + the inline blue overlay
   in ProductCategoriesSection/SolutionsCarouselSection (`opacity-0 group-hover:
   opacity-100`, primary-deep gradient). Delivered as ::after so no markup change is
   needed across the 5 render locations: z-auto + last in tree order paints it ABOVE
   __img/__scrim but BELOW the z-indexed body/chip/arrow, matching the source layer. */
.esteri-tile::after {
	content: '';
	position: absolute;
	inset: 0;
	pointer-events: none;
	opacity: 0;
	transition: opacity 0.3s ease-out;
	background: linear-gradient(180deg, hsl(222 68% 20% / 0) 30%, hsl(222 68% 20% / 0.6) 100%);
}
.esteri-tile:hover::after { opacity: 1; }
.esteri-tile__body {
	/* In flow (cell 1/1, bottom-aligned) rather than `absolute`, so its height is
	   what grows the card. z-index stays 2 — the blue hover ::after must paint above
	   the image but under the text. */
	grid-area: 1 / 1;
	align-self: end;
	position: relative;
	z-index: 2;
	padding: 1.25rem;
	/* Rows are [sweep, title, desc] — all five renderers that emit this markup
	   (category-tiles, solutions-carousel, solution-products, solution-crosssell,
	   solution-tiles) emit exactly those three, desc last and optional. The desc row
	   animates 0fr → 1fr on hover: unlike a max-height clamp, `1fr` in an auto-height
	   grid resolves to the desc's OWN height, so the reveal keeps its 0.3s ease for a
	   two-line caption AND never truncates a long one. */
	display: grid;
	grid-template-rows: auto auto 0fr;
	transition: grid-template-rows 0.3s ease-out;
}
.esteri-tile:hover .esteri-tile__body { grid-template-rows: auto auto 1fr; }

/* Chip — pill on white-translucent surface, with the brand-green dot. */
.esteri-chip {
	position: absolute;
	top: 0.75rem;
	left: 0.75rem;
	z-index: 10;
	display: inline-flex;
	align-items: center;
	gap: 0.375rem;
	padding: 0.25rem 0.625rem;
	background: hsl(0 0% 100% / 0.9);
	backdrop-filter: blur(4px);
	font-family: 'Space Mono', monospace;
	/* Source chips are `text-sm` mono with no weight class (15px / 400 / lh 24).
	 * Base was 13px/500 — too small + too heavy; the home cat-grid + news-card
	 * chips already override to the source value, but the article-listing /
	 * product-grid / article-related chips inherited the stale base. */
	font-size: 0.9375rem;
	font-weight: 400;
	line-height: 1.6;
	letter-spacing: 0.15em;
	text-transform: uppercase;
	color: var(--wp--preset--color--foreground);
	border-radius: var(--radius);
}
.esteri-chip::before {
	content: '';
	width: 4px;
	height: 4px;
	border-radius: 50%;
	background: var(--wp--preset--color--accent);
}
/* Enlarged text (WCAG 1.4.4, text-only 200 %): "MALLIA" alone outgrew the 2-col tile
 * at 390 and the tile's overflow cut it. The absolute chip already shrinks to the
 * room left of its inset and wraps at the space; `anywhere` additionally breaks a
 * single word that cannot fit that room. No width cap: a cap re-wrapped the default
 * chip at 360 (measured), while `anywhere` acts only on a word wider than the tile. */
.esteri-tile > .esteri-chip {
	overflow-wrap: anywhere;
}
/* Chip variant — dark surface, e.g. date on news/article tiles, top-right. */
.esteri-chip-dark {
	left: auto;
	right: 0.75rem;
	background: hsl(222 30% 12% / 0.85);
	color: var(--wp--preset--color--primary-foreground);
}
.esteri-chip-dark::before {
	display: none;
}

/* Sweep — small green bar above the tile title. */
.esteri-sweep {
	display: block;
	width: 2rem;
	height: 2px;
	margin-bottom: 0.75rem;
	background: var(--wp--preset--color--accent);
	transform-origin: left;
	transform: scaleX(1);
	transition: transform 0.3s ease-out;
}
.esteri-tile:hover .esteri-sweep,
a.esteri-tile-link:hover .esteri-sweep,
.esteri-tile-placeholder:hover .esteri-sweep { transform: scaleX(3); }

/* Tile title — bold white. Source cat tiles step `text-base sm:text-lg
 * lg:text-xl` (probed 16/20 → 18/28 → 20/28); a fixed 1.25rem oversized the
 * mobile cards. */
.esteri-tile__title {
	margin: 0;
	font-size: 1rem;
	font-weight: 700;
	line-height: 1.25;
	color: var(--wp--preset--color--primary-foreground);
}
@media (min-width: 640px) {
	.esteri-tile__title { font-size: 1.125rem; line-height: 1.5556; }
}
@media (min-width: 1024px) {
	.esteri-tile__title { font-size: 1.25rem; line-height: 1.4; }
}
.esteri-tile__title a {
	color: inherit;
	text-decoration: none;
}
.esteri-tile__desc {
	margin: 0.25rem 0 0;
	font-size: 1rem;
	line-height: 1.375; /* source `leading-snug` (probed 22px) */
	color: hsl(0 0% 100% / 0.75);
}
/* Card descriptions are hidden at rest and revealed on hover (matches the
   source `max-h-0 group-hover:max-h-24` behaviour). Scoped to .esteri-tile__body
   so the dashed "+ Koko valikoima" placeholder (its desc shows at rest) is
   unaffected. On touch devices with no hover the desc stays hidden — also
   matching the source, which hides card descriptions on mobile. */
.esteri-tile__body .esteri-tile__desc {
	/* No max-height clamp at all any more — the 0fr row above hides it, and
	 * `overflow: hidden` + `min-height: 0` are what let a grid item collapse to a
	 * 0fr track (a grid item's automatic minimum size would otherwise be its
	 * content). The clamp used to be the ceiling on this caption: the source's 6rem
	 * (`max-h-24`) guillotined 3 of 8 live categories mid-sentence, cont.82n raised
	 * it to 20rem = the tile's own physical capacity, and Saku, 2026-08-07 asked for
	 * the last step — "test to grow the card when theres alot of text in desc" — so
	 * the CARD now grows and the caption has no cap at all. */
	min-height: 0;
	max-height: none;
	margin-top: 0;
	opacity: 0;
	overflow: hidden;
	transition: opacity 0.3s ease-out, margin-top 0.3s ease-out;
}
.esteri-tile:hover .esteri-tile__body .esteri-tile__desc {
	margin-top: 0.25rem;
	opacity: 1;
}

/* Hover arrow — accent-green chip slides in from the right on hover (top-right). */
.esteri-tile__arrow {
	position: absolute;
	top: 0.75rem;
	right: 0.75rem;
	z-index: 10;
	display: flex;
	align-items: center;
	justify-content: center;
	width: 2rem;
	height: 2rem;
	background: var(--wp--preset--color--accent);
	color: var(--wp--preset--color--foreground);
	border-radius: var(--radius);
	transform: translateX(8px);
	opacity: 0;
	transition: transform 0.2s ease-out, opacity 0.2s ease-out;
}
.esteri-tile__arrow::before {
	content: '';
	width: 16px;
	height: 16px;
	background-color: currentColor;
	-webkit-mask: var(--esteri-arrow-up-right) center / contain no-repeat;
	        mask: var(--esteri-arrow-up-right) center / contain no-repeat;
}
.esteri-tile:hover .esteri-tile__arrow {
	transform: translateX(0);
	opacity: 1;
}

/* "Koko valikoima" placeholder tile — dashed border, no image. */
.esteri-tile-placeholder {
	/* Same growth floor as the image tiles: a ::before spacer spanning all three
	   rows carries the 4:5, the label pins to row 1 and the text block to row 3,
	   and the 1fr row between them reproduces the old `space-between`. Height is
	   max(4:5, label + text), so a long CTA text grows the card. The 3-line /
	   4-line clamps this used to need are gone with it — the card can now show the
	   whole thing instead of truncating author text to fit a fixed box. */
	display: grid;
	grid-template-columns: minmax(0, 1fr);
	grid-template-rows: auto 1fr auto;
	align-self: start;                      /* don't stretch when a row-mate grows */
	container-type: inline-size;            /* the --has-image variant's scrim uses cqw */
	--ph-pad: 1.25rem;                      /* the spacer cancels both — see ::before */
	--ph-bord: 2px;
	padding: var(--ph-pad);
	border: var(--ph-bord) dashed hsl(0 0% 100% / 0.2);
	border-radius: var(--radius);
	color: var(--wp--preset--color--primary-foreground);
	text-decoration: none;
	overflow: hidden;                       /* radius + the --has-image variant's img */
	transition: border-color 0.2s ease-out, background-color 0.2s ease-out;
}
.esteri-tile-placeholder::before {
	content: '';
	grid-row: 1 / -1;
	grid-column: 1;
	/* Unlike the image tiles, THIS card carries the padding itself — so a
	   `width: 100%` spacer would measure the CONTENT box and land 4:5 of the wrong
	   width (11 px short at 337 px, caught by the at-rest geometry sweep). Stretched
	   width + a negative margin equal to the padding AND the dashed border makes the
	   spacer's box the card's BORDER box — which is what `aspect-ratio` sized under
	   border-box — so the ratio is exact and the margin-box contribution cancels
	   them again. (Padding alone leaves it 1 px short; the border counts too.) */
	width: auto;
	margin: calc(-1 * (var(--ph-pad) + var(--ph-bord)));
	aspect-ratio: 4 / 5;
	pointer-events: none;
}
/* The two in-flow children every renderer of this markup emits: the eyebrow label
   and a bare <div> holding sweep + title + desc. */
.esteri-tile-placeholder > .esteri-tile-placeholder__label { grid-area: 1 / 1; }
.esteri-tile-placeholder > div { grid-area: 3 / 1; min-height: 0; }
/* Optional CTA-card image (ctaImageId): the dashed placeholder becomes a real
   image tile — drop the dashed border, show the image + scrim (base
   .esteri-tile__img/.esteri-tile__scrim position them), lift the label + text
   block above, and force light text since it now sits over a dark image (over-
   riding the --dark dark-on-light modifiers). (Saku: add an image to the
   "Kaikki käyttökohteet" / "Koko valikoima" / "Tutustu kaikkiin" cards.) */
.esteri-tile-placeholder--has-image {
	position: relative;
	--ph-bord: 0px;
	border: none;
	background: var(--wp--preset--color--foreground);
}
.esteri-tile-placeholder--has-image > .esteri-tile-placeholder__label,
.esteri-tile-placeholder--has-image > div {
	position: relative;
	z-index: 1;
}
.esteri-tile-placeholder--has-image .esteri-tile-placeholder__label,
.esteri-tile-placeholder--has-image .esteri-tile__title,
.esteri-tile-placeholder--has-image .esteri-tile__title--dark,
.esteri-tile-placeholder--has-image .esteri-tile__desc,
.esteri-tile-placeholder--has-image .esteri-tile__desc--dark {
	color: #fff;
}
.esteri-tile-placeholder:hover {
	/* Border-only hover — NO background change. Source JSX has
	   `hover:bg-primary-deep/40`, but the Tailwind colour token is `blue-deep`
	   (tailwind.config.ts), not `primary-deep`, so that utility is undefined and
	   Lovable applies no hover background. The `--light` variant below DOES tint
	   (`hover:bg-muted` — a real token). */
	border-color: var(--wp--preset--color--accent);
}
.esteri-tile-placeholder__label {
	font-family: 'Space Mono', monospace;
	/* Source: `text-sm` mono, no weight class (15px / 400 / lh 24). */
	font-size: 0.9375rem;
	font-weight: 400;
	line-height: 1.6;
	letter-spacing: 0.2em;
	text-transform: uppercase;
	color: hsl(0 0% 100% / 0.6);
	transition: color 0.2s ease-out;
	/* Enlarged text (WCAG 1.4.4): "KÄYTTÖKOHTEET" at 200 % is wider than the card and
	 * was cut by it. `anywhere` breaks only a word that cannot fit its line, so the
	 * default-size label is unchanged. */
	overflow-wrap: anywhere;
}
/* Source: `group-hover:text-brand-green` on the placeholder label (both variants). */
.esteri-tile-placeholder:hover .esteri-tile-placeholder__label {
	color: var(--wp--preset--color--accent);
}
/* Light-background variant — for placeholder on the solutions carousel (white section). */
.esteri-tile-placeholder--light {
	border-color: var(--wp--preset--color--border);
	color: var(--wp--preset--color--foreground);
}
.esteri-tile-placeholder--light:hover {
	border-color: var(--wp--preset--color--accent);
	background: var(--wp--preset--color--muted); /* source `hover:bg-muted` */
}
.esteri-tile-placeholder--light .esteri-tile-placeholder__label {
	color: var(--wp--preset--color--muted-foreground);
}
/* Solution carousel cards + their dashed placeholder: source `text-base
 * lg:text-lg` (16 → 18 at lg only — no sm step, unlike the cat tiles). */
.esteri-solution-tile .esteri-tile__title,
.esteri-tile__title--dark { font-size: 1rem; line-height: 1.25; }
@media (min-width: 1024px) {
	.esteri-solution-tile .esteri-tile__title,
	.esteri-tile__title--dark { font-size: 1.125rem; line-height: 1.5556; }
}
.esteri-tile__title--dark { color: var(--wp--preset--color--foreground); }
.esteri-tile__desc--dark { color: var(--wp--preset--color--muted-foreground); }

/* ─── Product-categories grid — 4 cols desktop, 2 cols mobile. ───────────── */

.esteri-cat-grid {
	display: grid;
	grid-template-columns: repeat(2, minmax(0, 1fr));
	gap: 0.625rem; /* source gap-2.5 sm:gap-3 lg:gap-4 */
}
@media (min-width: 640px) {
	.esteri-cat-grid { gap: 0.75rem; }
}
@media (min-width: 1024px) {
	.esteri-cat-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1rem; }
}
/* Source cat tiles below lg: `p-3 sm:p-4` body, desc `hidden lg:block`, hover
 * arrow `hidden lg:flex`, sweep `w-6 mb-2`, chip `top/left-2.5 px-2 py-0.5`.
 * The shared tile defaults are the lg values — step them down here. */
/* The placeholder steps its padding through --ph-pad, NOT `padding`: its ratio
   spacer cancels exactly that variable, so setting `padding` here directly would
   desync the two and mis-size the card (it did: 4 px tall at 390). */
.esteri-cat-grid .esteri-tile__body { padding: 0.75rem; }
.esteri-cat-grid .esteri-tile-placeholder { --ph-pad: 0.75rem; }
@media (min-width: 640px) {
	.esteri-cat-grid .esteri-tile__body { padding: 1rem; }
	.esteri-cat-grid .esteri-tile-placeholder { --ph-pad: 1rem; }
}
@media (min-width: 1024px) {
	.esteri-cat-grid .esteri-tile__body { padding: 1.25rem; }
	.esteri-cat-grid .esteri-tile-placeholder { --ph-pad: 1.25rem; }
}
@media (max-width: 1023.98px) {
	.esteri-cat-grid .esteri-tile__arrow { display: none; }
	.esteri-cat-grid .esteri-sweep { width: 1.5rem; margin-bottom: 0.5rem; }
	.esteri-cat-grid .esteri-chip  { top: 0.625rem; left: 0.625rem; padding: 0.125rem 0.5rem; }
}
/* The source hides the caption below lg (`hidden lg:block`), which reads as "no
   captions on mobile" — but the real condition is "no captions where nobody can
   hover". On a narrowed DESKTOP window that rule silently killed the hover
   reveal, while the carousel tiles (never covered by it) still revealed at the
   same width. (Saku, 2026-08-07: "the hover desc wont show if the window is
   different size".) Gate it on the pointer instead of the width: touch keeps the
   source behaviour exactly, and a mouse gets the caption at every width. */
@media (max-width: 1023.98px) and (hover: none) {
	.esteri-cat-grid .esteri-tile__desc { display: none; }
}
/* Source chip text is `text-sm` mono with no weight class (15px / 400 / lh 24). */
.esteri-cat-grid .esteri-chip {
	font-size: 0.9375rem;
	font-weight: 400;
	line-height: 1.6;
}

/* ─── News listing — mobile carousel, tablet+/desktop grid. ──────────────
   Source NewsSection renders TWO blocks: a `sm:hidden` horizontal carousel
   (260px cards, scroll-snap, prev/next + progress bar) below 640px, and a
   `hidden sm:block` grid (`sm:grid-cols-2 lg:grid-cols-4 gap-4`) at 640+.
   In WP it's one Query Loop, CSS-switched: the query wrapper is the scroll
   root, the post-template <ul> the flex track, each <li> a snap card.
   Class-only selectors (no .wp-block-columns qualifier) per
   grids-listings-and-carousels.md §D. Earlier the cards were a 2-col grid at
   ALL widths < 1024, which cramped the cards at 390 and collided the
   top-left category chip with the top-right date chip (#10). */
.esteri-news__loop-wrap {
	overflow-x: auto;
	scrollbar-width: none;
	scroll-snap-type: x proximity;   /* see .esteri-carousel — avoids snap-back into empty space when paging */
	-webkit-overflow-scrolling: touch;
	scroll-padding-inline: var(--wire-edge);
}
.esteri-news__loop-wrap::-webkit-scrollbar { display: none; }
.esteri-news__loop {
	display: flex;
	gap: 0.75rem;            /* source carousel gap-3 */
	min-width: max-content;
	/* The news-grid block emits a BARE <ul> — the old core/query markup's
	 * .wp-block-post-template class carried core's list reset; without it
	 * the UA disc markers + 40px padding-left leak through. */
	list-style: none;
	margin: 0;
	padding: 0;
}
.esteri-news__loop > li {
	flex: 0 0 260px;         /* source w-[260px] */
	scroll-snap-align: start;
}
@media (min-width: 640px) {
	.esteri-news__loop-wrap {
		overflow-x: visible;
		scroll-snap-type: none;
	}
	.esteri-news__loop {
		display: grid;
		grid-template-columns: repeat(2, minmax(0, 1fr));
		gap: 1rem;           /* source grid gap-4 */
		min-width: 0;
	}
	.esteri-news__loop > li { flex: none; }
}
@media (min-width: 1024px) {
	.esteri-news__loop { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}

/* Mobile-only carousel controls (reuse the .esteri-carousel-nav__* child
   styling). Hidden once the layout becomes a grid at 640px. */
.esteri-news__carousel-nav {
	display: flex;
	align-items: center;
	gap: 0.75rem;
	padding-top: 1.5rem;
	box-sizing: border-box;
}
.esteri-news__carousel-nav .esteri-carousel-nav__progress {
	flex: 1;             /* source mobile bar is flex-1, not the 200px cap */
	max-width: none;
}
@media (min-width: 640px) {
	.esteri-news__carousel-nav { display: none; }
}
.esteri-news-card {
	position: relative;
	/* Ratio floor + in-flow body, as .esteri-tile — here the author text that can
	   overrun is the post TITLE (news-grid/render.php puts sweep + post-title in
	   __body and no excerpt), which used to climb over the image and then be cut
	   off at the card edge. ::after is the blue hover overlay; ::before is free. */
	display: grid;
	grid-template-columns: minmax(0, 1fr);
	container-type: inline-size;
	overflow: hidden;
	border-radius: var(--radius);
	background: var(--wp--preset--color--foreground);
}
.esteri-news-card::before {
	content: '';
	grid-area: 1 / 1;
	width: 100%;
	aspect-ratio: 4 / 5;
	pointer-events: none;
}
.esteri-news-card .wp-block-post-featured-image,
.esteri-news-card .wp-block-post-featured-image a,
.esteri-news-card .wp-block-post-featured-image img {
	position: absolute;
	inset: 0;
	width: 100%;
	height: 100%;
	margin: 0;
}
.esteri-news-card .wp-block-post-featured-image img {
	object-fit: cover;
	transition: transform 0.5s ease-out;
}
.esteri-news-card:hover .wp-block-post-featured-image img { transform: scale(1.06); }
/* Source: the green sweep stretches on card hover (group-hover:scale-x-[3]) —
 * the shared rule only covered .esteri-tile. */
.esteri-news-card:hover .esteri-sweep { transform: scaleX(3); }
.esteri-news-card__scrim {
	position: absolute;
	inset: 0;
	pointer-events: none;
	/* Length-based rewrite of `0 25%, .55 55%, .98 100%` read from the bottom:
	   93.75cqw / 56.25cqw are those two stops on a 4:5 card, so with --card-grow at
	   0px this is the same gradient it replaced; past the floor it climbs with the
	   growth so a long title never sits on unwashed image. */
	--card-floor: 125cqw;                   /* this card pads its __body, not itself */
	--card-grow: max(0px, 100% - var(--card-floor));
	background: linear-gradient(0deg,
		hsl(222 30% 12% / 0.98) 0,
		hsl(222 30% 12% / 0.98) min(calc(var(--card-grow) * 4), 100% - 4rem),
		hsl(222 30% 12% / 0.55) min(calc(var(--card-grow) * 4 + 0.45 * var(--card-floor)), 100% - 2rem),
		hsl(222 30% 12% / 0)    min(calc(var(--card-grow) * 4 + 0.75 * var(--card-floor)), 100%));
	z-index: 1;
}
/* Source hover overlay: deep-blue bottom gradient fades in over the scrim. */
.esteri-news-card::after {
	content: '';
	position: absolute;
	inset: 0;
	z-index: 1;
	pointer-events: none;
	background: linear-gradient(180deg, hsl(222 68% 20% / 0) 30%, hsl(222 68% 20% / 0.6) 100%);
	opacity: 0;
	transition: opacity 0.3s ease-out;
}
.esteri-news-card:hover::after { opacity: 1; }
/* Source hover arrow: green ↗ chip bottom-right, lg-only, slides in on hover. */
.esteri-news-card__arrow { display: none; }
@media (min-width: 1024px) {
	.esteri-news-card__arrow {
		position: absolute;
		bottom: 0.75rem;
		right: 0.75rem;
		z-index: 10;
		display: flex;
		align-items: center;
		justify-content: center;
		width: 2rem;
		height: 2rem;
		background: var(--wp--preset--color--accent);
		color: var(--wp--preset--color--foreground);
		border-radius: var(--radius);
		transform: translateX(8px);
		opacity: 0;
		transition: transform 0.2s ease-out, opacity 0.2s ease-out;
	}
	.esteri-news-card__arrow::before {
		content: '';
		width: 16px;
		height: 16px;
		background-color: currentColor;
		-webkit-mask: var(--esteri-arrow-up-right) center / contain no-repeat;
		        mask: var(--esteri-arrow-up-right) center / contain no-repeat;
	}
	.esteri-news-card:hover .esteri-news-card__arrow {
		transform: translateX(0);
		opacity: 1;
	}
}
.esteri-news-card__body {
	grid-area: 1 / 1;
	align-self: end;
	position: relative;
	z-index: 2;
	/* Source `p-4 lg:p-5 lg:pr-14` — right padding clears the hover arrow. */
	padding: 1rem;
}
@media (min-width: 1024px) {
	.esteri-news-card__body { padding: 1.25rem 3.5rem 1.25rem 1.25rem; }
}
.esteri-news-card .wp-block-post-title {
	margin: 0;
	/* Source `text-base sm:text-base lg:text-[15px] xl:text-lg` —
	 * probed 16/22 → 15/24 (lg) → 18/28 (xl). */
	font-size: 1rem;
	font-weight: 700;
	line-height: 1.375;
	color: var(--wp--preset--color--primary-foreground);
}
@media (min-width: 1024px) {
	.esteri-news-card .wp-block-post-title { font-size: 0.9375rem; line-height: 1.6; }
}
@media (min-width: 1280px) {
	.esteri-news-card .wp-block-post-title { font-size: 1.125rem; line-height: 1.5556; }
}
/* Source chips are `text-sm` mono with no weight class (15px/400); the
 * category chip tracks 0.15em, the date chip 0.12em; both step their
 * padding/offset `px-2 py-0.5 top/left-2.5 → lg:px-2.5 lg:py-1 top/left-3`. */
.esteri-news-card .esteri-chip {
	font-size: 0.9375rem;
	font-weight: 400;
	line-height: 1.6;
}
.esteri-news-card .esteri-news-date-chip { letter-spacing: 0.12em; }
@media (max-width: 1023.98px) {
	.esteri-news-card .esteri-chip {
		top: 0.625rem;
		padding: 0.125rem 0.5rem;
	}
	.esteri-news-card .esteri-news-chip { left: 0.625rem; }
	.esteri-news-card .esteri-chip-dark { right: 0.625rem; left: auto; }
}
.esteri-news-card .wp-block-post-title a {
	color: inherit;
	text-decoration: none;
}
/* News-card chip overrides — the post-terms / post-date blocks render their own wrappers; pin them. */
.esteri-news-card .wp-block-post-terms.esteri-chip,
.esteri-news-card .wp-block-post-date.esteri-chip {
	margin: 0;
}
.esteri-news-card .wp-block-post-terms.esteri-chip a {
	color: inherit;
	text-decoration: none;
}
.esteri-news-card .wp-block-post-terms.esteri-chip a + a::before {
	content: ', ';
	color: inherit;
}
/* Pin badge — top-left, below the category chip, on the pinned card only
   (source NewsSection: a lucide Pin on bg-brand-green, radius var(--radius)). */
.esteri-news-card__pin {
	position: absolute;
	top: 2.85rem;            /* clears the .esteri-chip (top 0.75rem, ~26px tall) */
	left: 0.75rem;
	z-index: 10;
	display: inline-flex;
	align-items: center;
	justify-content: center;
	width: 1.5rem;           /* source w-6 */
	height: 1.5rem;
	color: var(--wp--preset--color--foreground);
	background: var(--wp--preset--color--accent);
	border-radius: var(--radius);
}
@media (min-width: 1024px) {
	.esteri-news-card__pin { width: 1.75rem; height: 1.75rem; }   /* source lg:w-7 */
}

/* ─── News section — social media block. ───────────────────────────────── */

.esteri-news__social {
	display: flex;
	flex-direction: column;
	gap: 1rem;
	margin-top: 2.5rem;
	padding-top: 1.5rem;
	border-top: 1px solid var(--wp--preset--color--border);
}
@media (min-width: 640px) {
	/* source: sm:flex-row (640) — not md */
	.esteri-news__social {
		flex-direction: row;
		align-items: center;
		justify-content: space-between;
	}
}
.esteri-news__social-label {
	margin: 0;
	font-size: 1rem;
	color: var(--wp--preset--color--muted-foreground);
	/* source label is a plain flex item (shrink 1): under pressure at ~768 it
	 * wraps to two lines and the social pills keep a 3+2 wrap. */
}
.esteri-news__social-links {
	display: flex;
	flex-wrap: wrap;
	gap: 0.5rem;
	margin: 0;
	padding: 0;
	list-style: none;
}
/* Mobile-first (Lovable NewsSection: <640px = 40×40 icon button, ≥640px = text pill).
   Below sm the source shows only a lucide glyph; the label + ↗ arrow appear from sm up. */
.esteri-news__social-btn {
	display: inline-flex;
	align-items: center;
	justify-content: center;
	width: 2.5rem;
	height: 2.5rem;
	padding: 0;
	border: 1px solid var(--wp--preset--color--border);
	border-radius: 4px;
	color: var(--wp--preset--color--foreground);
	text-decoration: none;
	transition: border-color 0.15s cubic-bezier(0.4, 0, 0.2, 1), background-color 0.15s cubic-bezier(0.4, 0, 0.2, 1), color 0.15s cubic-bezier(0.4, 0, 0.2, 1);   /* source: transition-colors */
}
.esteri-news__social-icon {
	width: 18px;
	height: 18px;
	flex-shrink: 0;
	display: block;
}
.esteri-news__social-text { display: none; }
.esteri-news__social-btn::after {
	content: '';
	display: none;
	width: 12px;
	height: 12px;
	flex-shrink: 0;
	background-color: currentColor;
	-webkit-mask: var(--esteri-arrow-up-right) center / contain no-repeat;
	        mask: var(--esteri-arrow-up-right) center / contain no-repeat;
	transition: transform 0.2s ease-out;
}
@media (min-width: 640px) {
	.esteri-news__social-btn {
		width: auto;
		height: auto;
		gap: 0.5rem;
		padding: 0.5rem 1rem;
		border-width: 2px;
		border-color: var(--wp--preset--color--foreground);
		font-size: 0.9375rem;
		line-height: 24px;   /* source NewsSection.tsx:236 `text-sm font-mono` pair — the chip
		                      * label inherits 24px from the anchor, not the body's 1.55 */
		font-family: 'Space Mono', monospace;
		letter-spacing: 0.14em;
		text-transform: uppercase;
	}
	.esteri-news__social-icon { display: none; }
	.esteri-news__social-text { display: inline; }
	.esteri-news__social-btn::after { display: block; }
}
.esteri-news__social-btn:hover {
	border-color: var(--wp--preset--color--accent);
	background: var(--wp--preset--color--accent);
	color: var(--wp--preset--color--foreground);
}
.esteri-news__social-btn:hover::after {
	transform: translate(2px, -2px);
}

/* Product-categories header CTA placement is handled generically by the
 * `:has(... .esteri-viewall-* )` section-head flex rule above (matches source's
 * `flex items-end justify-between flex-wrap`: link wraps below-left in the
 * ~768–900 band, inline-right when it fits). The old product-specific 768+
 * nowrap/66-34 override was removed — it pinned the CTA inline-right through
 * that band, which the source does NOT do. */

/* ─── Solutions-carousel — horizontal scroll, 320-px cards. ─────────────── */

.esteri-carousel {
	overflow-x: auto;
	scrollbar-width: none;
	/* proximity, not mandatory: mandatory forces the resting position onto a
	   card's start, so near the end — where the last card's start sits past
	   maxScroll — it snaps BACK to the last reachable card-start, leaving empty
	   space to the right when paging with the next button (the smooth-scroll vs
	   mandatory-snap pitfall in grids-listings-and-carousels.md). proximity lets
	   it rest at true maxScroll = last card flush. */
	scroll-snap-type: x proximity;
	-webkit-overflow-scrolling: touch;
	scroll-padding-left: max(var(--wire-edge), calc((100vw - 1508px) / 2 + var(--wire-edge)));
	scroll-padding-right: max(var(--wire-edge), calc((100vw - 1508px) / 2 + var(--wire-edge)));
}
.esteri-carousel::-webkit-scrollbar { display: none; }
.esteri-carousel__track {
	display: flex;
	gap: 1rem;
	min-width: max-content;
	padding-left: max(var(--wire-edge), calc((100vw - 1508px) / 2 + var(--wire-edge)));
	/* NOT padding-right — WebKit (Safari) excludes a scroll container's trailing
	   padding from its scrollable overflow, so a padding-right gutter lets the
	   last card over-scroll to the viewport edge instead of resting at the
	   content rail (Chrome honours it; Safari does not). The trailing rail-gutter
	   is delivered by the ::after flex spacer below, which IS counted in the
	   overflow by every engine. (margin-right on the last child is ALSO dropped
	   by WebKit here — only an in-flow flex item works.) */
	padding-right: 0;
}
/* Trailing rail-gutter for the full-bleed carousels: an in-flow flex spacer =
   rail-inset minus the 1rem track gap that precedes it, so the last card rests
   flush at the content rail (matches Chrome + the Lovable source) in WebKit too.
   Constrained carousels (references / pd-rail) already clip at the content edge
   and must not get the spacer — their ::after is removed. */
.esteri-carousel__track::after {
	content: "";
	flex: 0 0 calc(max(var(--wire-edge), calc((100vw - 1508px) / 2 + var(--wire-edge))) - 1rem);
}
.esteri-references-carousel__track::after,
.esteri-pd-rail__track::after { content: none; }
.esteri-carousel__track > * {
	flex: 0 0 280px;
	scroll-snap-align: start;
}
@media (min-width: 768px) {
	.esteri-carousel__track > * { flex-basis: 320px; }
}

/* ─── Carousel navigation controls (prev/next + progress bar). ───────────── */

.esteri-carousel-nav {
	display: flex;
	align-items: center;
	gap: 0.75rem;
	padding-top: 1.5rem;
	/* wire-container alignment needs border-box so max-width:1508 includes the
	 * edge padding; without it the raw-HTML nav stays content-box, fills the
	 * band full-bleed, and margin:auto can't centre it — pushing the arrows
	 * ~13px left of the card rail. */
	box-sizing: border-box;
}
.esteri-carousel-nav__btn {
	display: flex;
	align-items: center;
	justify-content: center;
	width: 2.75rem;
	height: 2.75rem;
	border-radius: var(--radius);
	border: 2px solid var(--wp--preset--color--primary);
	background: transparent;
	color: var(--wp--preset--color--primary);
	cursor: pointer;
	flex-shrink: 0;
	transition: border-color 0.15s cubic-bezier(0.4, 0, 0.2, 1), background-color 0.15s cubic-bezier(0.4, 0, 0.2, 1), color 0.15s cubic-bezier(0.4, 0, 0.2, 1);   /* source: transition-colors */
}
.esteri-carousel-nav__btn:hover {
	border-color: var(--wp--preset--color--primary);
	background: var(--wp--preset--color--primary);
	color: hsl(0,0%,100%);
}
.esteri-carousel-nav__btn:disabled {
	opacity: 0.2;
	cursor: not-allowed;
}
/* Source: disabled:hover:bg-transparent disabled:hover:text-primary — a disabled
   arrow must not pick up the blue hover fill. */
.esteri-carousel-nav__btn:disabled:hover {
	background: transparent;
	border-color: var(--wp--preset--color--primary);
	color: var(--wp--preset--color--primary);
}
.esteri-carousel-nav__progress {
	flex: 0 1 200px;
	max-width: 200px;
	height: 4px;
	background: var(--wp--preset--color--border);
	border-radius: 9999px;
	overflow: hidden;
}
.esteri-carousel-nav__bar {
	height: 100%;
	width: 25%;
	background: var(--wp--preset--color--accent);
	border-radius: 1px;
	transform-origin: left;
	/* NO transition: the JS rewrites translateX on every scroll event, so the bar
	 * already tracks the live scroll position frame-by-frame. A transform
	 * transition here double-animates each update — the bar visibly lags and
	 * keeps sliding for 0.3s after the content has stopped. `will-change`
	 * promotes it to its own layer for smooth scroll-linked updates instead. */
	will-change: transform;
}

/* ─── Service-highlight split CTA banner (between why-esteri and references). ── */

.esteri-service-cta {
	display: grid;
	grid-template-columns: 1fr;
	min-height: 360px;
	border-bottom: 1px solid var(--wp--preset--color--border);
}
/* The CTA wrapper is now a native core/group (grid). Zero the is-layout-flow
 * child margins WP injects on the 2nd grid child (__visual) so the band's
 * 50/50 split is unaffected. */
.esteri-service-cta > * { margin-block: 0; }
@media (min-width: 1024px) {
	.esteri-service-cta {
		grid-template-columns: 1fr 1fr;
		min-height: 420px;
	}
}
.esteri-service-cta__text {
	position: relative;
	display: flex;
	align-items: center;
	background: var(--gradient-cta);
	padding-block: 4rem;
	padding-left: max(var(--wire-edge), calc((100vw - 1508px) / 2 + var(--wire-edge)));
	padding-right: var(--wire-edge);
}
.esteri-service-cta__text::before {
	content: '';
	position: absolute;
	top: 0; left: 0; right: 0; height: 2px;
	background: var(--wp--preset--color--accent);
	opacity: 0.8;
}
.esteri-service-cta__text-inner { max-width: 32rem; }
/* Source lead carries its own max-w-md (28rem) INSIDE the max-w-lg wrapper
 * (CtaBannerSection.tsx) — without it the lead wraps a word later. */
.esteri-service-cta__text-inner h2 + p { max-width: 28rem; }
.esteri-service-cta__visual {
	position: relative;     /* anchor the absolute image */
	min-height: 260px;
	overflow: hidden;
}
@media (min-width: 640px) { .esteri-service-cta__visual { min-height: 340px; } }   /* source sm:h-[340px] */
/* Source img is `absolute inset-0 object-cover` so it FILLS the cell (sized by
 * the text panel + grid min-h:420) instead of dictating it. Without absolute,
 * the square 1024² image forced the band to ~968px at 1920 (≈2× too tall). */
.esteri-service-cta__visual .wp-block-image {
	position: absolute;
	inset: 0;
	margin: 0;
}
.esteri-service-cta__visual .wp-block-image,
.esteri-service-cta__visual .wp-block-image img {
	width: 100%;
	height: 100%;
}
.esteri-service-cta__visual .wp-block-image img {
	object-fit: cover;
	display: block;
}

/* ─── References carousel — one full-width image card at a time, prev/next. ── */

/* Each slide is exactly the viewport width so prev/next steps one card. */
.esteri-references-carousel__track > * {
	flex: 0 0 100%;
	scroll-snap-align: start;
	scroll-snap-stop: always;
}
@media (min-width: 768px) {
	.esteri-references-carousel__track > * { flex-basis: 100%; }
}
.esteri-references-carousel__track {
	/* Drop the shared track's min-width:max-content — with flex-basis:100% slides
	 * that rule oversizes each slide to ~1.5× the viewport, and the right overflow
	 * scrolls into view as a textless trailing "slide". Slides must equal the
	 * viewport so prev/next steps one full card. */
	min-width: 0;
	padding-left: 0;
	padding-right: 0;
}
/* No rail scroll-padding here: the carousel already sits inside .wire-container,
 * so each slide should snap flush to the viewport edge. */
.esteri-references-carousel__viewport {
	scroll-padding-left: 0;
	scroll-padding-right: 0;
}
.esteri-references-carousel .esteri-carousel-nav {
	padding-left: 0;
	padding-right: 0;
}

a.esteri-ref-slide {
	/* Flex column so the dark body stretches to the slide's bottom edge —
	 * the flex track equalises slide heights to the tallest, and a block
	 * body left a white strip under shorter slides. */
	display: flex;
	flex-direction: column;
	border: 1px solid var(--wp--preset--color--border);
	border-radius: var(--radius);
	overflow: hidden;
	background: var(--wp--preset--color--background);
	text-decoration: none;
}
.esteri-ref-slide .esteri-ref-slide__body { flex: 1; }
.esteri-ref-slide__media {
	position: relative;
	height: 280px;
	overflow: hidden;
	background: var(--wp--preset--color--foreground);
}
@media (min-width: 768px) { .esteri-ref-slide__media { height: 440px; } }
.esteri-ref-slide__media img {
	position: absolute;
	inset: 0;
	z-index: 0;
	width: 100%;
	height: 100%;
	object-fit: cover;
	transition: transform 0.5s ease-out;
}
.esteri-ref-slide:hover .esteri-ref-slide__media img { transform: scale(1.03); }
/* Source: the content-band green sweep stretches on slide hover (group-hover:scale-x-[3]). */
.esteri-ref-slide:hover .esteri-sweep { transform: scaleX(3); }
/* Source image scrims (ArticleReferencesCarousel.tsx): a rest-state edge
 * gradient + a deep-blue bottom gradient that fades in on hover. The WP
 * slide markup has no scrim elements, so both were missing — emit them as
 * pseudo-elements stacked above the absolutely-positioned img. */
.esteri-ref-slide__media::before,
.esteri-ref-slide__media::after {
	content: '';
	position: absolute;
	inset: 0;
	pointer-events: none;
}
.esteri-ref-slide__media::before {
	z-index: 1;
	background: linear-gradient(180deg,
		hsl(222 30% 12% / 0.2) 0%, hsl(222 30% 12% / 0) 25%,
		hsl(222 30% 12% / 0) 75%, hsl(222 30% 12% / 0.4) 100%);
}
.esteri-ref-slide__media::after {
	z-index: 2;
	background: linear-gradient(180deg, hsl(222 68% 20% / 0) 40%, hsl(222 68% 20% / 0.5) 100%);
	opacity: 0;
	transition: opacity 0.3s ease-out;
}
.esteri-ref-slide:hover .esteri-ref-slide__media::after { opacity: 1; }
.esteri-ref-slide__body {
	display: grid;
	/* Source is `grid-cols-1 md:grid-cols-[auto_1fr_auto]` — the counter row
	 * sits ABOVE the content on mobile. A 2-col mobile grid squeezed the
	 * title beside the big numeral (the reported broken mobile layout). */
	grid-template-columns: 1fr;
	gap: 1.5rem;
	padding: 1.25rem 1.25rem 1.5rem;
	background: var(--wp--preset--color--surface-deep);
	color: var(--wp--preset--color--primary-foreground);
	align-items: start;
}
@media (min-width: 768px) {
	.esteri-ref-slide__body {
		grid-template-columns: auto 1fr auto;
		gap: 2rem;
		padding: 2.5rem;
		align-items: center;
	}
}
.esteri-ref-slide__count {
	display: flex;
	align-items: baseline;
	gap: 0.5rem;
}
/* Source content column caps at `md:max-w-4xl` (896px) — without it the
 * excerpt line ran the full 1fr width and wrapped differently. */
@media (min-width: 768px) {
	.esteri-ref-slide__content { max-width: 56rem; }
}
.esteri-ref-slide__index {
	/* Source `text-5xl md:text-6xl` (48 → 60). */
	font-size: 3rem;
	font-weight: 700;
	line-height: 1;
}
@media (min-width: 768px) {
	.esteri-ref-slide__index { font-size: 3.75rem; }
}
.esteri-ref-slide__total {
	font-family: 'Space Mono', monospace;
	font-size: 1rem; /* source `text-base` (16/24) */
	line-height: 1.5;
	color: hsl(0 0% 100% / 0.6);
}
.esteri-ref-slide__sector {
	display: block;
	margin-bottom: 0.75rem;
	font-family: 'Space Mono', monospace;
	font-size: 0.9375rem; /* source `text-sm` = 15px */
	letter-spacing: 0.2em;
	text-transform: uppercase;
	line-height: 1;
	color: hsl(0 0% 100% / 0.8);
	/* Enlarged text (WCAG 1.4.4): at 200 % "PELASTUSLAITOS" is wider than the 390 slide,
	 * and as the column's min-content it pushed the title out too; both were cut by
	 * the slide's overflow. `anywhere` acts only on a word that cannot fit. */
	overflow-wrap: anywhere;
}
.esteri-ref-slide__title {
	margin: 0 0 0.75rem;
	/* Source `text-lg md:text-xl leading-snug tracking-[-0.01em]`. */
	font-size: 1.125rem;
	font-weight: 700;
	line-height: 1.375;
	letter-spacing: -0.01em;
	color: var(--wp--preset--color--primary-foreground);
}
@media (min-width: 768px) {
	.esteri-ref-slide__title { font-size: 1.25rem; }
}
.esteri-ref-slide__excerpt {
	margin: 0;
	font-size: 1rem;
	color: hsl(0 0% 100% / 0.75);
	line-height: 1.625; /* source `leading-relaxed` */
}
/* Mobile shows the reference slide title-only — source ArticleReferencesCarousel.tsx
 * renders the excerpt `hidden sm:block` (visible only at the 640px sm breakpoint up). */
@media (max-width: 639px) {
	.esteri-ref-slide__excerpt { display: none; }
}
.esteri-ref-slide__arrow {
	display: none;
}
@media (min-width: 768px) {
	.esteri-ref-slide__arrow {
		display: flex;
		align-items: center;
		justify-content: center;
		width: 3rem;
		height: 3rem;
		background: var(--wp--preset--color--accent);
		color: var(--wp--preset--color--foreground);
		border-radius: var(--radius);
		transform: translateX(8px);
		opacity: 0;
		transition: transform 0.2s ease-out, opacity 0.2s ease-out;
	}
	.esteri-ref-slide__arrow::before {
		content: '';
		width: 20px;
		height: 20px;
		background-color: currentColor;
		-webkit-mask: var(--esteri-arrow-up-right) center / contain no-repeat;
		        mask: var(--esteri-arrow-up-right) center / contain no-repeat;
	}
	.esteri-ref-slide:hover .esteri-ref-slide__arrow {
		transform: translateX(0);
		opacity: 1;
	}
}

/* ─── Mono eyebrow WITHOUT the green dash — used on the dark-surface
   product-categories ("Tuotteet") + service-CTA ("Suunnittelu & kartoitus")
   sections, where the source renders a plain mono label and NO dash. ─── */
.is-style-mono-eyebrow-light,
.esteri-mono-eyebrow-light {
	display: inline-block;
	font-family: 'Space Mono', monospace;
	font-size: 0.9375rem;
	/* Source span is `font-mono text-sm` with NO weight class (400) and
	 * inherits the text-sm line-height (probed 24px). */
	font-weight: 400;
	letter-spacing: 0.2em;
	text-transform: uppercase;
	line-height: 1.6;
	color: hsl(0 0% 100% / 0.7);
	/* Source wraps this span in `inline-flex items-center mb-3 leading-none`
	 * — the wrapper's 12px bottom margin was never ported, so the h2 sat
	 * flush against the eyebrow (ink-gap oracle cont.72: wp -2 vs lv 11). */
	margin-bottom: 0.75rem;
}
/* SSR section eyebrows (solution-references / cross-sell render.php spans):
 * source is `wire-label block mb-3` — block display + the 12px bottom margin
 * (is-style-wire-label alone is inline-flex with NO margin, so these sat
 * flush over their titles: ink-gap oracle wp -1 vs lv 7). */
.esteri-sol-references__eyebrow,
.esteri-sol-crosssell__eyebrow {
	display: block;
	margin-bottom: 0.75rem;
}

/* ─── Why-Esteri: stats row — source is `grid grid-cols-2 lg:grid-cols-4`
   (2-up below the lg/1024 breakpoint, 4-up at lg+). The core wp:columns
   would instead stack 1-up at <=781px (its `flex-basis:100% !important`
   mobile-stack default) and stay 4-up across 782-1023px — so force the
   source's own grid: 2×2 at mobile + tablet, 4 across on desktop. The
   higher-specificity selector beats core's `.wp-block-columns.is-layout-flex`
   `display:flex` in both the front-end and the (narrow) editor canvas, where
   the layout stylesheet loads after theme.css. ─────────────────────── */
/* Source section is `wire-section relative overflow-hidden` (WhyEsteriSection
 * .tsx) — the clip is what lets the nowrap Avainlippu badge overshoot its
 * grid cell harmlessly on narrow phones instead of forcing page scroll. */
.esteri-why { overflow: hidden; }
.esteri-why .wp-block-columns.esteri-why__stats {
	display: grid;
	grid-template-columns: repeat(2, minmax(0, 1fr));
	column-gap: 2rem;    /* source gap-x-8 */
	row-gap: 2.5rem;     /* source gap-y-10 */
	align-items: start;  /* source items-start */
}
@media (min-width: 1024px) {
	.esteri-why .wp-block-columns.esteri-why__stats {
		grid-template-columns: repeat(4, minmax(0, 1fr));
	}
}

/* ─── Why-Esteri: vertical dividers + top border between the 3 value props
   (source: border-t + md:divide-x divide-border). ─────────────────── */
.esteri-why__valueprops {
	border-top: 1px solid var(--wp--preset--color--border);
	padding-top: 2.5rem;
	gap: 2rem;   /* source gap-8 — stacked value props need vertical spacing on mobile */
}
/* Source is `grid-cols-1 md:grid-cols-3` + `md:divide-x divide-border`, so the
   un-stack belongs at the Tailwind md breakpoint (768), NOT core's wp:columns
   782 stack default — otherwise the 768–781 band stays 1-up while Lovable is
   already 3-col (same root cause as the #11 shared un-stack below; verified at
   768 via computed top-offsets: WP stacked, Lovable side-by-side). */
@media (min-width: 768px) {
	.esteri-why__valueprops {
		gap: 0;   /* source md:gap-0 — the dividers + column padding handle separation */
		flex-wrap: nowrap !important;
	}
	.esteri-why__valueprops > .wp-block-column {
		flex-basis: 0 !important;   /* equal thirds, matching md:grid-cols-3 */
		flex-grow: 1;
	}
	.esteri-why__valueprops > .wp-block-column + .wp-block-column {
		border-left: 1px solid var(--wp--preset--color--border);
		padding-left: 2rem;
	}
	.esteri-why__valueprops > .wp-block-column:not(:last-child) {
		padding-right: 2rem;
	}
}

/* ─── Why-Esteri: Avainlippu badge (bold text + official mark image). ── */
/* Source AvainlippuBadge scales with the breakpoint: gap-3 lg:gap-4,
   text-base lg:text-xl, leading-snug lg:leading-tight, h-[3.75rem]
   lg:h-[5.25rem]. The fixed 1.25rem text only fit while the stat was a
   full-width 1-col stack; in the new 2×2 grid (mobile + tablet) the badge
   shares a half-width column, so the text must drop to 1rem there or it
   over-wraps and breaks mid-word. Mobile-first base = source's <lg values,
   lg media query = source's lg: values. */
.esteri-avainlippu {
	display: inline-flex;
	align-items: center;
	gap: 0.75rem;            /* source gap-3 */
	max-width: 100%;
}
/* The source keeps the mark BESIDE the text at every width (inline-flex,
 * no wrap) and lets the badge overshoot its grid cell — the section's
 * overflow-hidden clips any excess. The old ≤400px wrap-below fallback made
 * WP visibly different from the design on real phones (390/393: mark dropped
 * under the text — Saku report 2026-07-30). Keep nowrap through the real
 * phone band; below 360px the design would clip HALF the mark at the viewport
 * edge (WCAG 1.4.10 reflow @320), so only there wrap below the text.
 * .esteri-why carries the section clip (source `wire-section overflow-hidden`). */
.esteri-avainlippu { flex-wrap: nowrap; }
@media (max-width: 359.98px) {
	.esteri-avainlippu { flex-wrap: wrap; }
}
/* 360–374: the mark still sits beside the text (the decision above), but the
 * 12px gap put its right 3px past the screen at 360 (phone-offscreen-sweep
 * 2026-09-25). 8px there keeps the whole mark on screen; ≥375 keeps the design's 12px. */
@media (min-width: 360px) and (max-width: 374.98px) {
	.esteri-avainlippu { gap: 0.5rem; }
}
.esteri-avainlippu__text {
	margin: 0;
	font-weight: 700;
	font-size: 1rem;         /* source text-base */
	line-height: 1.375;      /* source leading-snug */
	letter-spacing: -0.025em;/* source tracking-tight */
	text-transform: uppercase;
	color: var(--wp--preset--color--primary-deep);
	/* Source forces exactly 3 lines via <br>; nowrap stops a sub-pixel
	   half-width-column overflow from breaking a word mid-letter
	   ("MYÖNNETT/Y"). Any few-px overshoot lands in the right-hand
	   gutter, not on a neighbour. */
	white-space: nowrap;
}
.esteri-avainlippu__mark {
	height: 3.75rem;         /* source h-[3.75rem] */
	width: auto;
	flex-shrink: 0;
}
@media (min-width: 1024px) {
	.esteri-avainlippu { gap: 1rem; }              /* source lg:gap-4 */
	.esteri-avainlippu__text {
		font-size: 1.25rem;                        /* source lg:text-xl */
		line-height: 1.25;                         /* source lg:leading-tight */
	}
	.esteri-avainlippu__mark { height: 5.25rem; }  /* source lg:h-[5.25rem] */
}
/* The Avainlippu mark is now a swappable core/image: the __mark class lands
 * on the <figure> wrapper (was a bare <img>), so kill the core block margin
 * (also overrides the is-layout-flow child margin WP injects on the group's
 * 2nd child) and let the inner <img> fill the fixed figure height. */
.esteri-avainlippu .wp-block-image.esteri-avainlippu__mark { margin: 0; }
/* !important: in the editor core/image writes an inline `height: auto` on the
 * <img>, which beat this rule and painted the mark at its natural 686×780 —
 * the badge text then wrapped one letter per line beside it. */
.esteri-avainlippu__mark img { height: 100% !important; width: auto !important; display: block; }

/* ─── Final-CTA primary button — bot icon before the label. ───────────
 * `has-bot` is the COMPOSABLE alias of `is-style-with-bot`. A Gutenberg block style
 * is single-select — the `is-style-*` slot holds one value — so as soon as the
 * design's four FILLS became pickable styles (functions.php), an icon that also
 * lived in that slot could not coexist with a fill: picking "Vihreä" on the
 * "Kysy Esterinalta" button would have silently dropped its bot glyph. The icon is
 * an ADD-ON, not a variant, so it moves to a plain class the button-icon picker
 * writes (sl-esteri-core/assets/button-icon-picker.js), exactly as `has-arrow`
 * already worked alongside `is-style-with-arrow`. Both selectors stay: 33 published
 * buttons carry `is-style-with-bot` and render unchanged. */
.wp-block-button.is-style-with-bot > .wp-block-button__link::before,
.wp-block-button.has-bot > .wp-block-button__link::before {
	content: '';
	width: 16px;
	height: 16px;
	flex-shrink: 0;
	background-color: currentColor;
	-webkit-mask: var(--esteri-bot) center / contain no-repeat;
	        mask: var(--esteri-bot) center / contain no-repeat;
	/* source index.css L366-379: every .btn-* svg — the <Bot> icon included — nudges 3px on hover */
	transition: transform 200ms ease-out;
}
.wp-block-button.is-style-with-bot > .wp-block-button__link:hover::before,
.wp-block-button.has-bot > .wp-block-button__link:hover::before {
	transform: translateX(3px);
}
.wp-block-button.is-style-with-bot > .wp-block-button__link,
.wp-block-button.has-bot > .wp-block-button__link {
	display: inline-flex;
	align-items: center;
	gap: 0.5rem;
}
/* Filled CTA button paired with a bordered `is-style-outline-*` sibling.
 * The source button row is `flex flex-wrap` with default cross-axis STRETCH,
 * so the borderless filled button stretches to the outline's bordered height
 * while they share a row (56/56 at desktop) but reverts to its NATURAL 52
 * once the row wraps at mobile widths. An earlier transparent-2px-border
 * workaround matched the desktop 56 but stayed 56 when wrapped — 4px taller
 * than the source at 390 (caught by parity-diff STEP 4d). Reproduce the
 * mechanism instead: stretch the row, and make each `.wp-block-button`
 * wrapper a flex container so the stretch reaches the nested <a>.
 *
 * EVERY flex button row, not just the shipped CTA/hero wrappers (was scoped
 * to final-cta/article-cta/hero-front/cta-gradient): an author-inserted
 * wp:buttons row anywhere else got core's layout default `align-items:center`
 * and a mixed solid+outline pair rendered 52/56 (Saku, 2026-08-12: "two
 * buttons next to each other and other one has different design its different
 * size"; measured on a probe page). The double class beats core's generated
 * `.wp-block-buttons-is-layout-flex { align-items:center }` (0,1,0) whatever
 * the print order. Borderless-only rows are untouched — stretch on equal
 * heights is a no-op (52/52 stays, as in Lovable's light cta-block). */
.wp-block-buttons.is-layout-flex {
	align-items: stretch;
}
.wp-block-buttons.is-layout-flex > .wp-block-button {
	display: flex;
}
/* A stretched plain link is inline-block, so its label would ride 2px above
 * centre inside the taller box; the source buttons are `inline-flex
 * items-center` (index.css .btn-*). justify-content keeps the label centred
 * when the author sets a button width. has-arrow/with-bot/wire-link already
 * declare the same display, so this only lifts the plain ones. */
.wp-block-buttons.is-layout-flex > .wp-block-button > .wp-block-button__link {
	display: inline-flex;
	align-items: center;
	justify-content: center;
}

/* ─── 404 primary button — Sparkles icon before the label. Mirror of
 * is-style-with-bot; NotFound.tsx uses <Sparkles> for "Kysy Esterinalta"
 * where the rest of the site uses <Bot>. ─────────────────────────── */
.wp-block-button.is-style-with-sparkles > .wp-block-button__link::before,
.wp-block-button.has-sparkles > .wp-block-button__link::before {
	content: '';
	width: 16px;
	height: 16px;
	flex-shrink: 0;
	background-color: currentColor;
	-webkit-mask: var(--esteri-sparkles) center / contain no-repeat;
	        mask: var(--esteri-sparkles) center / contain no-repeat;
}
.wp-block-button.is-style-with-sparkles > .wp-block-button__link,
.wp-block-button.has-sparkles > .wp-block-button__link {
	display: inline-flex;
	align-items: center;
	gap: 0.5rem;
}

/* ─── Esterina AI-bot FAB (UI-only) ─────────────────────────────────
 * Port of source/src/components/AiBotWidget.tsx collapsed state: fixed
 * bottom-right brand-blue round launcher + a dismissible speech bubble
 * (sm+ only). The chat panel/assistant is a stub (out of scope) — this is
 * the always-visible launcher only. Rendered by includes/aibot-fab.php. */
.esteri-aibot {
	position: fixed;
	bottom: 1.5rem;          /* source bottom-6 */
	right: 1.5rem;           /* source right-6 */
	z-index: 50;
	display: flex;
	flex-direction: column;
	align-items: flex-end;
}
.esteri-aibot__bubble {
	position: relative;
	max-width: 260px;
	margin-bottom: 0.75rem;  /* source mb-3 */
	padding: 1rem;           /* source p-4 */
	background: var(--wp--preset--color--primary, hsl(222, 68%, 33%));
	color: var(--wp--preset--color--primary-foreground, #fff);
	font-size: 0.9375rem;    /* source text-sm (15px) */
	line-height: 1.625;      /* source leading-relaxed */
	border-radius: var(--radius, 4px);
	box-shadow: 0 10px 15px -3px hsl(0 0% 0% / 0.1), 0 4px 6px -4px hsl(0 0% 0% / 0.1);
}
.esteri-aibot__bubble[hidden] { display: none; }
.esteri-aibot__bubble-close {
	position: absolute;
	top: 0.5rem;
	right: 0.5rem;
	width: 16px;
	height: 16px;
	padding: 0;
	border: 0;
	background: transparent;
	cursor: pointer;
	color: hsl(0 0% 100% / 0.6);
	display: inline-flex;
	align-items: center;
	justify-content: center;
	transition: color 0.15s ease;
}
.esteri-aibot__bubble-close:hover { color: var(--wp--preset--color--primary-foreground, #fff); }
.esteri-aibot__bubble-close svg { width: 12px; height: 12px; }
.esteri-aibot__bubble-tail {
	position: absolute;
	bottom: -0.5rem;
	right: 1.5rem;           /* source right-6 */
	width: 1rem;
	height: 1rem;
	background: var(--wp--preset--color--primary, hsl(222, 68%, 33%));
	transform: rotate(45deg);
}
@media (max-width: 639.98px) {
	/* Source bubble is `hidden sm:block` — avoid overlapping mobile CTAs. */
	.esteri-aibot__bubble { display: none; }
}
.esteri-aibot__fab {
	width: 3.5rem;           /* source w-14 */
	height: 3.5rem;          /* source h-14 */
	border-radius: 9999px;
	border: 0;
	cursor: pointer;
	background: var(--wp--preset--color--primary, hsl(222, 68%, 33%));
	color: var(--wp--preset--color--primary-foreground, #fff);
	display: inline-flex;
	align-items: center;
	justify-content: center;
	box-shadow: 0 10px 15px -3px hsl(0 0% 0% / 0.1), 0 4px 6px -4px hsl(0 0% 0% / 0.1);
	transition: background-color 0.2s ease;
}
/* NO hover background change — the source's `hover:bg-primary-glow` never
 * ships. `primary-glow` exists only as the CSS var `--primary-glow` and as
 * `brand.blue-glow` in tailwind.config.ts; there is no `primary-glow` key in
 * the `primary` colour scale, so Tailwind generates no `bg-primary-glow`
 * utility and the design's FAB stays hsl(222 68% 33%) under the pointer
 * (live-hover probed: rest and hover both rgb(27,61,141)). WP was lightening
 * to rgb(37,84,193) — a hover the design doesn't have (Saku report,
 * 2026-08-03: "kysy esterinalta button hover is not same as in lovable").
 * The transition stays for the icon/shadow; only the fill is now inert.
 * refs: tailwind-cheatsheet.md § "Live-hover probing" (undefined token ⇒ the
 *       hover never ships — port what renders, not what the class implies). */
.esteri-aibot__fab svg { width: 24px; height: 24px; }
/* FAB glyph swaps Bot → X while the panel is open (source toggles the icon). */
.esteri-aibot__fab-icon { display: inline-flex; }
.esteri-aibot__fab-icon--x { display: none; }
.esteri-aibot__fab-icon--x svg { width: 22px; height: 22px; }
.esteri-aibot.is-open .esteri-aibot__fab-icon--bot { display: none; }
.esteri-aibot.is-open .esteri-aibot__fab-icon--x { display: inline-flex; }
/* Product page: lift the whole widget (FAB + bubble + panel) above the sticky
 * "Pyydä tarjous" bar while it is shown. Source index.css
 * `body.has-sticky-banner [data-aibot] { bottom: 6rem; }`; product-detail.js
 * initSticky toggles the body class from the same heroOut && !footerIn state
 * that shows the bar (ProductDetailPage.tsx), so the FAB drops back to 1.5rem
 * at the footer together with the bar. No transition — the source has none. */
body.has-sticky-banner .esteri-aibot { bottom: 6rem; }

/* ─── Santtu AI search page ([santtu_search]) ─────────────────────────────────
 * The shortcode prints plain content into the page template's post content,
 * whose constrained layout caps children at 1508px WITHOUT the site's 3.5rem
 * inner edge — so on wide screens the search ran 56px past the content edges
 * every .wire-container section keeps (Saku 2026-10-26: "hakutulossivu on sit
 * sivuston muun sisällön leveydellä - nyt se on leveämpi"). Same width as the
 * .wire-container content box; below 1508px the global padding already matches. */
.entry-content .santtu-search {
	max-width: calc(1508px - 2 * var(--wire-edge, 3.5rem));
	margin-left: auto !important;
	margin-right: auto !important;
}
/* Site colours instead of Santtu's neutral greys (Saku 2026-10-26: "samoin värit
 * voivat olla sivun mukaiset"). Santtu documents these variables as the theme's
 * override point; the search page and the popup read the same set. `body`
 * outranks the plugin's own single-class declarations whatever the load order. */
body .santtu-search,
body .santtu-popup {
	--santtu-ink: var(--wp--preset--color--foreground, hsl(222, 30%, 12%));
	--santtu-muted: var(--wp--preset--color--muted-foreground, hsl(222, 10%, 42%));
	--santtu-line: var(--wp--preset--color--border, hsl(220, 14%, 88%));
	--santtu-soft: var(--wp--preset--color--secondary, hsl(220, 20%, 96%));
	--santtu-accent: var(--wp--preset--color--primary, hsl(222, 68%, 33%));
	--santtu-theme-button: var(--wp--preset--color--primary, hsl(222, 68%, 33%));
	--santtu-theme-button-text: var(--wp--preset--color--primary-foreground, #fff);
	--santtu-button: var(--santtu-theme-button);
	--santtu-button-text: var(--santtu-theme-button-text);
}

/* ─── Esterina, LIVE mode (Site Logic AI Chatbot → Dialogflow Messenger) ────
 * When the plugin prints <df-messenger> (a Chatit row matches the URL), the
 * Google widget IS the chat and aibot-fab.php adds `.is-live` to our root: the
 * stub FAB + panel step aside, the design's teaser bubble stays, parked above
 * the Google bubble. Brand defaults sit on <body> so they INHERIT into the
 * widget's shadow DOM but lose to anything an editor sets in wp-admin → AI
 * Chatbot → Chatit (the plugin prints those on the element itself). Values are
 * the AiBotWidget.tsx tokens: w-14 launcher, w-[380px] window,
 * h-[min(560px,calc(100vh-7rem))], mb-4 gap, text-[15px], rounded-md.
 * refs: third-party-embeds.md § The architecture; sticky-bottom-cta.md (lift). */
body {
	--df-messenger-chat-bubble-background: var(--wp--preset--color--primary, hsl(222, 68%, 33%));
	--df-messenger-chat-bubble-icon-color: var(--wp--preset--color--primary-foreground, #fff);
	--df-messenger-chat-bubble-size: 3.5rem;
	--df-messenger-chat-bubble-border-radius: 9999px;
	--df-messenger-chat-bubble-icon-size: 24px;
	--df-messenger-chat-bubble-close-icon-size: 22px;
	--df-messenger-primary-color: var(--wp--preset--color--primary, hsl(222, 68%, 33%));
	--df-messenger-font-family: var(--wp--preset--font-family--sans, Inter, sans-serif);
	--df-messenger-font-size: 15px;
	--df-messenger-font-color: var(--wp--preset--color--foreground, hsl(222, 30%, 12%));
	--df-messenger-chat-window-width: min(380px, calc(100vw - 3rem));   /* w-[380px] max-w-[calc(100vw-3rem)] */
	--df-messenger-chat-window-height: min(560px, calc(100vh - 7rem));
	/* Google draws the 1px chat border on a content-box wrapper INSIDE this
	 * box, so the panel painted 382×562 and every row was 2px wider than the
	 * design — enough to re-wrap the greeting from 4 lines to 3. aibot-fab.php
	 * injects `.chat-wrapper{box-sizing:border-box}` so the border stays inside
	 * 380×560 like the source's border-box panel. */
	--df-messenger-chat-window-offset: 1rem;
	/* Panel frame — rounded-md border border-border shadow-2xl bg-background */
	--df-messenger-chat-border: 1px solid var(--wp--preset--color--border, hsl(220, 14%, 88%));
	--df-messenger-chat-border-radius: calc(var(--radius, 4px) - 2px);
	--df-messenger-chat-window-box-shadow: 0 25px 50px -12px hsl(0 0% 0% / 0.25);
	--df-messenger-chat-background: var(--wp--preset--color--background, #fff);
	--df-messenger-chat-padding: 40px 20px 16px;   /* px-5 pt-10 pb-4 (first entry's margin-top is zeroed in aibot-fab.php) */
	--df-messenger-message-spacing: 12px;          /* space-y-3 */
	--df-messenger-message-animation: none;        /* the source greeting is static markup; Google fades each message group in over .3s (opacity var below is that animation's start value — keep both) */
	--df-messenger-message-animation-opacity: 1;
	--df-messenger-focus-color: var(--wp--preset--color--primary, hsl(222, 68%, 33%));
	/* Header — white, px-5 py-4, border-b; logo (chat-title-icon, h-6) left,
	 * "Esterina" text-base font-semibold text-primary + ↘ collapse icon right. */
	--df-messenger-titlebar-background: var(--wp--preset--color--background, #fff);
	--df-messenger-titlebar-padding: 3px 20px;     /* wrapper min-height 50 + 6 + 1px border = 57px, like py-4 + h-6 + border */
	--df-messenger-titlebar-button-background-hover: transparent;  /* source: hover:opacity-80, no disc */
	--df-messenger-titlebar-border-bottom: 1px solid var(--wp--preset--color--border, hsl(220, 14%, 88%));
	--df-messenger-titlebar-font-color: var(--wp--preset--color--primary, hsl(222, 68%, 33%));
	--df-messenger-titlebar-title-align: end;
	--df-messenger-titlebar-title-font-family: var(--wp--preset--font-family--sans, Inter, sans-serif);
	--df-messenger-titlebar-title-font-size: 1rem;
	--df-messenger-titlebar-title-font-weight: 600;
	--df-messenger-titlebar-title-line-height: 1.5;
	--df-messenger-titlebar-title-letter-spacing: -0.025em;                   /* tracking-tight = -0.025em (-0.4px at 16px) */
	--df-messenger-titlebar-icon-width: auto;                                 /* logo h-6 w-auto → 95.9px from the 1035×259 PNG; a forced 96px resampled it */
	--df-messenger-titlebar-icon-height: 24px;
	--df-messenger-titlebar-icon-padding: 0;
	--df-messenger-chat-collapse-icon-size: 18px;                             /* ArrowDownRight size 18 */
	/* "Aloita uusi keskustelu" row (plugin-injected button) — px-5 py-3, border-b,
	 * text-sm(15px) font-semibold text-primary, hover:bg-secondary. */
	--sl-ai-new-conv-padding: 12px 20px;           /* py-3 around the 24px text-sm line box (set on the span in aibot-fab.php) + 1px border = 49px */
	--sl-ai-new-conv-margin: 0;
	--sl-ai-new-conv-gap: 12px;
	--sl-ai-new-conv-font-size: 15px;
	--sl-ai-new-conv-font-weight: 600;
	--sl-ai-new-conv-color: var(--wp--preset--color--primary, hsl(222, 68%, 33%));
	--sl-ai-new-conv-background: var(--wp--preset--color--background, #fff);
	--sl-ai-new-conv-background-hover: var(--wp--preset--color--secondary, hsl(220, 20%, 96%));
	--sl-ai-new-conv-color-hover: var(--wp--preset--color--primary, hsl(222, 68%, 33%));
	--sl-ai-new-conv-border-bottom: 1px solid var(--wp--preset--color--border, hsl(220, 14%, 88%));
	--sl-ai-new-conv-svg-fill: none;               /* the icon is lucide MessageSquarePlus (stroke); the plugin's default `fill: currentColor` painted it as a solid speech bubble and hid the + */
	/* Messages — "Esterina" label text-sm(15px) muted pl-1, bubble bg-secondary
	 * rounded-md p-4 text-[15px] leading-relaxed. */
	--sl-ai-bot-name-font-size: 15px;
	--sl-ai-bot-name-font-weight: 400;
	--sl-ai-bot-name-font-color: var(--wp--preset--color--muted-foreground, hsl(222, 10%, 42%));
	--sl-ai-bot-name-margin: 0 0 12px 4px;
	--sl-ai-bot-name-line-height: 1.6;
	--df-messenger-message-font-family: var(--wp--preset--font-family--sans, Inter, sans-serif);
	--df-messenger-message-font-size: 15px;
	--df-messenger-message-bot-background: var(--wp--preset--color--secondary, hsl(220, 20%, 96%));
	--df-messenger-message-bot-font-color: var(--wp--preset--color--foreground, hsl(222, 30%, 12%));
	--df-messenger-message-user-background: var(--wp--preset--color--secondary, hsl(220, 20%, 96%));
	--df-messenger-message-user-font-color: var(--wp--preset--color--foreground, hsl(222, 30%, 12%));
	--df-messenger-message-border-radius: calc(var(--radius, 4px) - 2px);
	--df-messenger-message-bot-border-top-left-radius: calc(var(--radius, 4px) - 2px);
	--df-messenger-message-bot-border-top-right-radius: calc(var(--radius, 4px) - 2px);
	--df-messenger-message-bot-border-bottom-left-radius: calc(var(--radius, 4px) - 2px);
	--df-messenger-message-bot-border-bottom-right-radius: calc(var(--radius, 4px) - 2px);
	--df-messenger-message-user-border-top-left-radius: calc(var(--radius, 4px) - 2px);
	--df-messenger-message-user-border-top-right-radius: calc(var(--radius, 4px) - 2px);
	--df-messenger-message-user-border-bottom-left-radius: calc(var(--radius, 4px) - 2px);
	--df-messenger-message-user-border-bottom-right-radius: calc(var(--radius, 4px) - 2px);
	/* Input — bg-secondary px-4 py-3 border-t; white rounded-full pill border-border
	 * px-4 py-2; text-[15px]; Send icon muted → primary on hover; disclaimer
	 * text-sm(15px) muted leading-snug mt-2 px-1. */
	--df-messenger-input-background: var(--wp--preset--color--secondary, hsl(220, 20%, 96%));
	--df-messenger-input-border-top: 1px solid var(--wp--preset--color--border, hsl(220, 14%, 88%));
	--df-messenger-input-padding: 12px 16px 0;
	--df-messenger-input-inner-padding: 0;         /* pill spans the row; the 48px send wrapper overlays its right end */
	--df-messenger-input-gutter: auto;             /* default `stable` reserves a 15px scrollbar gutter right of the pill */
	--df-messenger-send-icon-offset-x: 0px;        /* MUST carry a unit: Google uses it inside calc(-48px + …); a bare 0 voids the margin and the pill shrinks 48px */
	--df-messenger-input-box-border: 1px solid var(--wp--preset--color--border, hsl(220, 14%, 88%));
	--df-messenger-input-box-focus-border: 1px solid var(--wp--preset--color--border, hsl(220, 14%, 88%));
	--df-messenger-input-box-border-radius: 9999px;
	--df-messenger-input-box-padding: 8px 44px 8px 16px;     /* py-2 px-4 around the 23.25px line box (textarea line-height in aibot-fab.php), room for the Send icon; 41.25px pill like the source's 41.3 */
	--df-messenger-input-box-focus-padding: 8px 44px 8px 16px;
	--df-messenger-input-font-family: var(--wp--preset--font-family--sans, Inter, sans-serif);
	--df-messenger-input-font-size: 15px;
	--df-messenger-input-font-color: var(--wp--preset--color--foreground, hsl(222, 30%, 12%));
	--df-messenger-input-placeholder-font-color: var(--wp--preset--color--muted-foreground, hsl(222, 10%, 42%));   /* placeholder:text-muted-foreground (Google also sets opacity .7 — zeroed in aibot-fab.php) */
	--df-messenger-send-icon-color: var(--wp--preset--color--muted-foreground, hsl(222, 10%, 42%));
	--df-messenger-send-icon-color-hover: var(--wp--preset--color--primary, hsl(222, 68%, 33%));
	--df-messenger-send-icon-color-active: var(--wp--preset--color--primary, hsl(222, 68%, 33%));
	--df-messenger-send-icon-offset-y: 0px;
	--df-messenger-send-icon-color-disabled: var(--wp--preset--color--muted-foreground, hsl(222, 10%, 42%));
	--sl-ai-disclaimer-font-size: 15px;
	--sl-ai-disclaimer-color: var(--wp--preset--color--muted-foreground, hsl(222, 10%, 42%));
	--sl-ai-disclaimer-line-height: 1.375;
	--sl-ai-disclaimer-padding: 8px 20px 12px;
	--sl-ai-disclaimer-background: var(--wp--preset--color--secondary, hsl(220, 20%, 96%));
	--sl-ai-disclaimer-border-top: none;
}
/* The plugin's footer <style> positions the host at 16px/16px, z 999999 with a
 * bare `df-messenger` selector; `body df-messenger` outranks that tie and puts
 * the launcher where the design's FAB sits (bottom-6 right-6 z-50). */
body df-messenger {
	bottom: 1.5rem;
	right: 1.5rem;
	z-index: 50;
}
/* Same lift as the stub FAB above — product-detail.js toggles the body flag. */
body.has-sticky-banner df-messenger { bottom: 6rem; }
/* Live: our root carries only the teaser; park it above the 3.5rem launcher so
 * the tail keeps pointing at it (bubble mb-3 stays on the bubble itself). */
.esteri-aibot.is-live { bottom: 5rem; pointer-events: none; }
.esteri-aibot.is-live .esteri-aibot__bubble { pointer-events: auto; }
.esteri-aibot.is-live .esteri-aibot__fab,
.esteri-aibot.is-live .esteri-aibot__panel { display: none; }
body.has-sticky-banner .esteri-aibot.is-live { bottom: 9.5rem; }

/* Esterina chat panel — UI port of AiBotWidget.tsx open state.
 * w-[380px] max-w-[calc(100vw-3rem)] h-[min(560px,calc(100vh-7rem))], mb-4. */
.esteri-aibot__panel {
	width: 380px;
	max-width: calc(100vw - 3rem);
	height: min(560px, calc(100vh - 7rem));
	margin-bottom: 1rem;     /* source mb-4 */
	display: flex;
	flex-direction: column;
	overflow: hidden;
	text-align: left;
	background: var(--wp--preset--color--background, #fff);
	border: 1px solid var(--wp--preset--color--border, hsl(220, 14%, 88%));
	border-radius: var(--radius, 4px);
	box-shadow: 0 25px 50px -12px hsl(0 0% 0% / 0.25);  /* shadow-2xl */
}
.esteri-aibot__panel[hidden] { display: none; }
.esteri-aibot__header {
	display: flex;
	align-items: center;
	justify-content: space-between;
	padding: 1rem 1.25rem;   /* py-4 px-5 */
	border-bottom: 1px solid var(--wp--preset--color--border, hsl(220, 14%, 88%));
	background: var(--wp--preset--color--background, #fff);
}
.esteri-aibot__logo-img { height: 1.5rem; width: auto; }  /* h-6 */
.esteri-aibot__logo-text { font-weight: 700; color: var(--wp--preset--color--primary, hsl(222, 68%, 33%)); }
.esteri-aibot__min {
	display: inline-flex;
	align-items: center;
	gap: 0.5rem;
	padding: 0;
	border: 0;
	background: transparent;
	cursor: pointer;
	color: var(--wp--preset--color--primary, hsl(222, 68%, 33%));
	transition: opacity 0.15s ease;
}
.esteri-aibot__min:hover { opacity: 0.8; }
.esteri-aibot__min-label { font-size: 1rem; font-weight: 600; letter-spacing: -0.01em; }  /* text-base font-semibold tracking-tight */
.esteri-aibot__min svg { width: 18px; height: 18px; }
.esteri-aibot__newchat {
	display: flex;
	align-items: center;
	gap: 0.75rem;            /* gap-3 */
	width: 100%;
	padding: 0.75rem 1.25rem; /* py-3 px-5 */
	border: 0;
	border-bottom: 1px solid var(--wp--preset--color--border, hsl(220, 14%, 88%));
	background: transparent;
	cursor: pointer;
	text-align: left;
	font-family: inherit;
	font-size: 0.9375rem;    /* text-sm (15px) */
	font-weight: 600;
	color: var(--wp--preset--color--primary, hsl(222, 68%, 33%));
	transition: background-color 0.15s ease;
}
.esteri-aibot__newchat:hover { background: var(--wp--preset--color--secondary, hsl(220, 20%, 96%)); }
.esteri-aibot__newchat svg { width: 18px; height: 18px; flex: none; }
.esteri-aibot__messages {
	flex: 1 1 auto;
	overflow-y: auto;
	display: flex;
	flex-direction: column;
	gap: 0.75rem;            /* space-y-3 */
	padding: 2.5rem 1.25rem 1rem;  /* pt-10 px-5 pb-4 */
	background: var(--wp--preset--color--background, #fff);
}
.esteri-aibot__msg-from {
	margin: 0;
	padding-left: 0.25rem;
	font-size: 0.9375rem;    /* text-sm */
	color: var(--wp--preset--color--muted-foreground, hsl(222, 10%, 42%));
}
.esteri-aibot__msg {
	background: var(--wp--preset--color--secondary, hsl(220, 20%, 96%));
	border-radius: var(--radius, 4px);
	padding: 1rem;           /* p-4 */
}
.esteri-aibot__msg p {
	margin: 0;
	font-size: 15px;
	line-height: 1.625;      /* leading-relaxed */
	color: var(--wp--preset--color--foreground, hsl(222, 30%, 12%));
}
.esteri-aibot__msg p + p { margin-top: 0.75rem; }  /* mt-3 */
.esteri-aibot__input {
	padding: 0.75rem 1rem;   /* py-3 px-4 */
	border-top: 1px solid var(--wp--preset--color--border, hsl(220, 14%, 88%));
	background: var(--wp--preset--color--secondary, hsl(220, 20%, 96%));
}
.esteri-aibot__inputbar {
	display: flex;
	align-items: center;
	gap: 0.5rem;
	padding: 0.5rem 1rem;    /* py-2 px-4 */
	background: var(--wp--preset--color--background, #fff);
	border: 1px solid var(--wp--preset--color--border, hsl(220, 14%, 88%));
	border-radius: 9999px;
}
.esteri-aibot__inputbar input {
	flex: 1 1 auto;
	min-width: 0;
	border: 0;
	background: transparent;
	outline: none;
	font-family: inherit;
	font-size: 15px;
	color: var(--wp--preset--color--foreground, hsl(222, 30%, 12%));
}
.esteri-aibot__inputbar input::placeholder { color: var(--wp--preset--color--muted-foreground, hsl(222, 10%, 42%)); }
.esteri-aibot__send {
	padding: 0;
	border: 0;
	background: transparent;
	cursor: pointer;
	display: inline-flex;
	color: var(--wp--preset--color--muted-foreground, hsl(222, 10%, 42%));
	transition: color 0.15s ease;
}
.esteri-aibot__send:hover { color: var(--wp--preset--color--primary, hsl(222, 68%, 33%)); }
.esteri-aibot__send svg { width: 18px; height: 18px; }
.esteri-aibot__disclaimer {
	margin: 0.5rem 0 0;      /* mt-2 */
	padding: 0 0.25rem;      /* px-1 */
	font-size: 0.9375rem;    /* text-sm */
	line-height: 1.375;      /* leading-snug */
	color: var(--wp--preset--color--muted-foreground, hsl(222, 10%, 42%));
}

/* ─── Editor parity ─────────────────────────────────────────────── */

.editor-styles-wrapper {
	font-family: 'Inter', system-ui, -apple-system, sans-serif;
}

/* Raw-HTML (`wp:html`) blocks preview inside isolated SandBox iframes that
 * load this stylesheet but know nothing of their surrounding context — no
 * parent height to fill, no ancestor background to sit on. The SandBox wraps
 * each block's markup as `<body><div>…</div></body>`, so `body > div > …`
 * only ever matches inside that sandbox; on the front-end these elements are
 * nested deep inside section markup, never two levels under <body>, so the
 * rules are inert there. They restore editor parity for the two blocks that
 * depend on their surroundings. */

/* Hero video: front-end fills its stretched 50% column (`height:100%`); the
 * sandbox iframe auto-sizes to content, collapsing to min-height. Give it the
 * column's real height so the editor preview matches the front-end. */
body > div > .esteri-hero-video {
	min-height: 38rem;
}

/* Product-categories "+ Koko valikoima" placeholder is borderless/white,
 * designed to sit on the section's deep-surface background. In its own
 * sandbox (white) it vanishes into a white box — paint the section bg. */
body > div > .esteri-tile-placeholder:not(.esteri-tile-placeholder--light) {
	background: var(--wp--preset--color--surface-deep);
}

/* ─── Breadcrumb strip (template-level, page-wide.html) ─── */
.esteri-breadcrumbs-strip {
	border-bottom: 1px solid var(--wp--preset--color--border, hsl(220, 13%, 91%));
	background: var(--wp--preset--color--background, #fff);
}
.esteri-breadcrumbs-strip .esteri-breadcrumbs {
	max-width: 1508px;
	margin-inline: auto;
	padding-inline: var(--wire-edge);
	padding-block: 0.75rem;
}
.esteri-breadcrumbs__list {
	list-style: none;
	margin: 0;
	padding: 0;
	display: flex;
	align-items: center;
	/* Default: WRAP and show the full current-page label, matching the source
	 * PageBreadcrumbs / PageHero strips (`flex items-center flex-wrap gap-x-2
	 * gap-y-1`). The article single overrides this to a single-row ellipsis
	 * strip via the `--truncate` modifier below. */
	flex-wrap: wrap;
	column-gap: 0.5rem;
	row-gap: 0.25rem;
	font-family: var(--wp--preset--font-family--mono, "Space Mono", monospace);
	font-size: 0.875rem;
	/* source PageBreadcrumbs: text-muted-foreground (not Tailwind gray-500) */
	color: var(--wp--preset--color--muted-foreground, hsl(222, 10%, 42%));
}
.esteri-breadcrumbs__item {
	display: inline-flex;
	align-items: center;
	gap: 0.5rem;
	flex-shrink: 0;
}
/* Robustness net (cont.71, found via tuote 3169's 96-char unbreakable test
   title): the CURRENT crumb is customer content — without shrink+break an
   unbreakable token wider than the viewport pushes document scrollWidth to
   ~870px (body widens → chrome-wide fallout). Source PageBreadcrumbs.tsx has
   the same flaw (no truncate/min-width) — deliberate divergence, ROADMAP
   § Decisions. Invisible for normal titles (no wrap unless needed); the
   article-only --truncate variant below still overrides with its single-line
   ellipsis (white-space:nowrap wins over overflow-wrap). */
.esteri-breadcrumbs__item:last-child { flex-shrink: 1; min-width: 0; }
.esteri-breadcrumbs__current { overflow-wrap: anywhere; }
/* ── Article single only: single-row strip, current title truncates with an
 * ellipsis instead of wrapping the chevron to a second line
 * (ArticleDetailPage.tsx:379-386 `truncate min-w-0`). ── */
.esteri-breadcrumbs--truncate .esteri-breadcrumbs__list { flex-wrap: nowrap; min-width: 0; }
.esteri-breadcrumbs--truncate .esteri-breadcrumbs__item:last-child { flex-shrink: 1; min-width: 0; }
.esteri-breadcrumbs--truncate .esteri-breadcrumbs__current {
	display: block;
	white-space: nowrap;
	overflow: hidden;
	text-overflow: ellipsis;
}
.esteri-breadcrumbs__item + .esteri-breadcrumbs__item::before {
	content: '›';
	/* source chevron: muted-foreground at opacity-50 (ChevronRight className) */
	color: hsla(222, 10%, 42%, 0.5);
	display: inline-block;
}
.esteri-breadcrumbs__link {
	/* source: inherits the strip's text-muted-foreground */
	color: var(--wp--preset--color--muted-foreground, hsl(222, 10%, 42%));
	text-decoration: none;
	/* no transition: the source crumb (`nav-link-hover … py-1`) carries none — only its ::after eases */
	display: inline-flex;        /* aligns the home-crumb glyph (CC5) */
	align-items: center;
	/* source PageBreadcrumbs.tsx: both the home glyph and each label link carry
	 * `py-1` — dropped in the first port, which rendered the strip 8px short
	 * (46 vs 54 @390) on every breadcrumbed route and collapsed the home
	 * icon's tap target to the 14px glyph itself (a11y §5b). */
	padding-block: 4px;
}
.esteri-breadcrumbs__home-icon { display: block; }
.esteri-breadcrumbs__link { position: relative; }
.esteri-breadcrumbs__link::after {
	/* source PageBreadcrumbs.tsx:21/26 `nav-link-hover nav-link-hover-flush` (index.css L395-419):
	 * a 2px accent underline sweeps in from the left; the text colour does NOT change. */
	content: '';
	position: absolute;
	left: 0; right: 0; bottom: 0;
	height: 2px;
	background: hsl(74 87% 40%);
	transform: scaleX(0);
	transform-origin: left;
	transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.esteri-breadcrumbs__link:hover::after,
.esteri-breadcrumbs__link:focus-visible::after { transform: scaleX(1); }
/* ProductDetailPage.tsx:112/209 renders its OWN crumb strip (not PageBreadcrumbs): the links are
 * `hover:text-brand-green transition-colors` — colour to green, no underline sweep. */
.single-tuote .esteri-breadcrumbs__link { transition: color 0.15s cubic-bezier(0.4, 0, 0.2, 1); }
.single-tuote .esteri-breadcrumbs__link:hover { color: hsl(74 87% 40%); }
.single-tuote .esteri-breadcrumbs__link::after { display: none; }
.esteri-breadcrumbs__current {
	color: hsl(222, 30%, 12%);
}

/* ─── Sections inside the ContactPage Esteri-pane ─── */
.esteri-contact-tab--esteri .wire-section + .wire-section {
	border-top: 1px solid var(--wp--preset--color--border, hsl(220, 13%, 91%));
}

/* The team section uses surface bg; remove the inherited section border. */
.esteri-team-section.has-surface-background-color {
	background: hsl(220, 30%, 97%) !important;
	border-bottom: 0;
}

/* Department h3 spacing inside team grids. */
.esteri-team__dept-heading {
	font-size: 1.5rem;
	font-weight: 700;
	/* source text-2xl pairs a FIXED 2rem line-height that md:text-[1.75rem]
	 * keeps (arbitrary size sets no lh) — a relative 1.2 gave 33.6px at 28px. */
	line-height: 2rem;
	letter-spacing: -0.02em;
	margin: 0 0 1.5rem 0;
	color: hsl(222, 30%, 12%);
}
.esteri-team__dept-heading--pets {
	display: inline-flex;
	align-items: center;
	gap: 0.75rem;
}
/* Green paw icon before the heading text (source <PawPrint size={22}
 * className="text-brand-green"/>). CSS-mask icon, not inline SVG, so the
 * core/heading content stays plain text (no editor "unexpected content"). */
.esteri-team__dept-heading--pets::before {
	content: '';
	flex-shrink: 0;
	width: 1.375rem;
	height: 1.375rem;
	background-color: var(--wp--preset--color--accent);
	-webkit-mask: var(--esteri-paw) center / contain no-repeat;
	        mask: var(--esteri-paw) center / contain no-repeat;
}

@media (min-width: 768px) {
	.esteri-team__dept-heading {
		font-size: 1.75rem;
	}
}

/* Vertical rhythm between department groups in the team section.
 * Source: ContactPage.tsx staff container `space-y-14` = 56px between
 * department blocks. Two structures must both hit 56px:
 * - headings as direct children of the container (pre-cont.77c pages),
 * - the cont.77c `.esteri-team__dept` wrapper groups — the direct-child
 *   heading selector stopped matching after the wrap and the gap
 *   silently collapsed to 0 (Saku report 2026-07-31). */
.esteri-team-section .wire-container > .esteri-team__dept-heading {
	margin-top: 3.5rem;
}
.esteri-team-section .wire-container > .esteri-team__dept-heading:first-of-type {
	margin-top: 0;
}
.esteri-team-section .esteri-team__dept + .esteri-team__dept {
	margin-top: 3.5rem;
}

/* Hide the breadcrumb strip on the front page (front-page.html doesn't
 * use this template, but the rule keeps the styles self-isolating). */
.home .esteri-breadcrumbs-strip { display: none; }

/* Eyebrow dash suppression inside the contact-tabs panes. Source's
 * ContactPage eyebrows use `wire-label block mb-3` — `display: block`
 * collapses the inline-flex ::before dash to zero width because
 * block-level pseudo-elements default to `display: inline` (which
 * ignores width/height). Our WP eyebrows are <p>'s with
 * `is-style-wire-label` which we render as inline-flex by default
 * (correct for the front-page sections). Scope-strip here so the
 * contact-page panes match Lovable. Lives in theme.css (not the
 * plugin's contact-page.css) so add_editor_style picks it up in the
 * editor iframe too — plugin block-style assets don't auto-load
 * inside the editor canvas. */
.esteri-contact-tabs .is-style-wire-label,
.esteri-contact-tabs .wire-label {
	display: block;
}
.esteri-contact-tabs .is-style-wire-label::before,
.esteri-contact-tabs .wire-label::before {
	display: none;
}

/* Editor-only pane labels. The contact-tabs block stacks both panes in
 * the editor (display:block) so every native block inside both panes is
 * reachable. But the front-end sticky pill bar (scrolls out of view in
 * the editor) plus the very faint dashed pane-separator left the
 * customer unsure which content belongs to which tab — the named
 * "Aluemyynti tab can't be edited" defect from the 2026-05-29 re-gate
 * was actually "the second pane is reachable but invisible-by-context"
 * (no header to say "you have entered the second tab here"). These
 * pseudo-element labels show a Space-Mono chip above each pane in the
 * editor canvas, scoped to .esteri-contact-tabs--editor so the
 * front-end never sees them. Front-end stays sticky-pill-driven. */
.esteri-contact-tabs--editor .esteri-contact-tab--esteri::before,
.esteri-contact-tabs--editor .esteri-contact-tab--aluemyynti::before {
	display: block;
	padding: 0.625rem 1rem;
	margin: 0 0 1.25rem 0;
	font-family: var(--wp--preset--font-family--mono, "Space Mono", ui-monospace, SFMono-Regular, Menlo, monospace);
	font-size: 0.75rem;
	font-weight: 700;
	letter-spacing: 0.1em;
	text-transform: uppercase;
	color: hsl(222, 30%, 12%);
	border-radius: 4px;
}
.esteri-contact-tabs--editor .esteri-contact-tab--esteri::before {
	content: "Välilehti 1 / 2 — Esteri (yhteystiedot · laskutus · henkilöstö)";
	background: hsla(74, 87%, 40%, 0.12);
	border: 1px dashed #97BF0D;
}
.esteri-contact-tabs--editor .esteri-contact-tab--aluemyynti::before {
	content: "Välilehti 2 / 2 — Alue-edustajat (myyntialueet · auto-luettelo)";
	background: hsla(222, 68%, 33%, 0.10);
	border: 1px dashed #1A3A8D;
}

/* ─── Contact-info card (company + billing) headings ───────────────────
   Source `text-2xl md:text-3xl tracking-[-0.02em]` (1.5rem → 1.875rem at
   md). Without an explicit rule these inherit the global h2 default
   (xl-4, ~2.25rem), which fit on one line only while the column was a
   full-width 1-col stack. Now that the card is 2-up from 768 (#11), the
   oversized default wrapped "Esteri / Pesulakoneet Oy" onto two lines in
   the half-width column; the source size keeps it on one. */
.esteri-contact-info__h2 {
	font-size: 1.5rem;        /* source text-2xl */
	letter-spacing: -0.02em;  /* source tracking-[-0.02em] */
}
@media (min-width: 768px) {
	.esteri-contact-info__h2 {
		font-size: 1.875rem;  /* source md:text-3xl */
	}
}

/* ─── Page hero block (esteri/page-hero) ─── */
.esteri-page-hero,
.esteri-page-hero *,
.esteri-page-hero *::before,
.esteri-page-hero *::after {
	box-sizing: border-box;
	/* Without this the panel's min-height + padding-block stack (content-box
	   default), yielding 500 + 80 + 80 = 660 px instead of the source's
	   500 px min. Trap caught the second 🟢 gate after the user noticed
	   the hero was visibly taller than Lovable. */
}

.esteri-page-hero {
	position: relative;
	overflow: hidden;
	border-bottom: 1px solid var(--wp--preset--color--border, hsl(220, 13%, 91%));
	background: var(--wp--preset--color--background, #fff);
}

.esteri-page-hero__grid {
	display: grid;
	/* minmax(0,1fr), not `1fr` (= minmax(auto,1fr)): a title word wider than the
	 * phone ("ammattipesularatkaisut", "Ammattipesulakoneet", "Ripustusjärjestelmät")
	 * set the ONE column's min-content, so panel, lede, buttons and the image were
	 * 17–113px wider than the screen and the section's overflow:hidden cut them
	 * (14 routes, phone-offscreen-sweep 2026-09-25). The source has the same flaw;
	 * divergence on Saku's "fix … as it goes off the screen". The word itself is
	 * fitted by assets/hero-title-fit.js (sl-esteri-core).
	 * refs: parity-acceptance-standard.md § divergence ledger. */
	grid-template-columns: minmax(0, 1fr);
}

@media (min-width: 1024px) {
	.esteri-page-hero--bold.esteri-page-hero--has-visual .esteri-page-hero__grid,
	.esteri-page-hero--default.esteri-page-hero--has-visual .esteri-page-hero__grid {
		/* `1fr` = minmax(auto,1fr); a long unbreakable H1 word (e.g.
		 * "Ammattipesulakoneet" on /tuotteet) blows the panel's min-content past
		 * 50%, shrinking the image off the source's exact 50/50 (lg:grid-cols-2).
		 * minmax(0,1fr) lets both columns shrink to an equal split. */
		grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
	}
}

.esteri-page-hero__panel {
	position: relative;
	display: flex;
	align-items: center;
	/* Source bold PageHero panel: `py-10 md:py-12 lg:py-20` (40/48/80px). */
	padding-block: 2.5rem;
	padding-inline: var(--wire-edge, 1.5rem);
}
/* Source bold PageHero (has-visual branch) pads the panel asymmetrically at
 * EVERY width via inline style (PageHero.tsx): left snaps to the 1508 rail,
 * right is clamp(1.5rem, 4vw, 4rem) — 24px in the phone band vs --wire-edge's
 * 20px. This used to apply only ≥1024, so the hero text column ran 4px wide
 * below lg and wrapped differently (huolto-ja-tuki lede @360: 2 lines vs the
 * design's 3). */
.esteri-page-hero--has-visual .esteri-page-hero__panel {
	padding-left: max(var(--wire-edge, 1.5rem), calc((100vw - 1508px) / 2 + var(--wire-edge, 1.5rem)));
	padding-right: clamp(1.5rem, 4vw, 4rem);
}

@media (min-width: 768px) {
	.esteri-page-hero__panel {
		padding-block: 3rem;
	}
}

@media (min-width: 1024px) {
	/* Source PageHero (bold branch) puts lg:min-h + lg:py-20 on the gradient
	 * panel for BOTH variants — a text-only hero (e.g. /huolto-ja-tuki/takuu)
	 * was ~140px shorter than the design without them. */
	.esteri-page-hero--no-visual .esteri-page-hero__panel {
		min-height: var(--hero-min-page-lg, 28rem);
		padding-block: 5rem;
	}
	.esteri-page-hero--has-visual .esteri-page-hero__panel {
		min-height: var(--hero-min-page-lg, 28rem);
		padding-block: 5rem;
		/* The asymmetric edge (1508-rail left / clamp right) now lives in the
		   base has-visual rule above — the source applies it at every width. */
	}
	.esteri-page-hero--no-visual .esteri-page-hero__panel {
		justify-content: center;
		padding-block: 5rem;
	}
}

.esteri-page-hero--bold .esteri-page-hero__panel {
	background: var(--gradient-hero-split, linear-gradient(135deg, #1A3A8D 0%, #16307a 100%));
	color: #fff;
}

.esteri-page-hero--default .esteri-page-hero__panel {
	background: var(--wp--preset--color--surface, hsl(220, 30%, 97%));
	color: hsl(222, 30%, 12%);
}

.esteri-page-hero__pattern {
	position: absolute;
	inset: 0;
	pointer-events: none;
	opacity: 0.55;
	background-image:
		radial-gradient(circle at 25% 30%, hsla(0, 0%, 100%, 0.04) 0, transparent 60%),
		radial-gradient(circle at 80% 70%, hsla(0, 0%, 100%, 0.04) 0, transparent 60%);
}

.esteri-page-hero--default .esteri-page-hero__pattern {
	opacity: 0;
}

.esteri-page-hero__content {
	position: relative;
	z-index: 1;
}
/* has-visual branch: source `relative z-10 max-w-xl`. */
.esteri-page-hero--has-visual .esteri-page-hero__content {
	max-width: 36rem;
}
/* no-visual branch: the source renders the inner div as `wire-container
 * w-full` at EVERY width and zeroes the panel's own inline padding
 * (PageHero.tsx: paddingLeft/Right 0 when !hasVisual). The old port —
 * panel padded --wire-edge + a bare centered 1508px content box ≥1024 —
 * coincided with the design below ~1620px, but on wider screens the text
 * started at the bare rail edge (56px LEFT of the design's wire-container
 * text) and ran 112px wider; below 1024 a stray 36rem cap (the has-visual
 * max-w-xl) bounded content the design leaves full-width. */
.esteri-page-hero--no-visual .esteri-page-hero__panel {
	padding-inline: 0;
}
.esteri-page-hero--no-visual .esteri-page-hero__content {
	width: 100%;
	max-width: 1508px;
	margin-inline: auto;
	padding-inline: var(--wire-edge, 1.5rem);
}

/* The BOLD PageHero builds its eyebrow INLINE (PageHero.tsx bold branch):
 * `inline-flex gap-3` + an explicit 40px green dash `<span class="w-10
 * h-[2px] bg-brand-green">` + `tracking-[0.2em]` + font-mono with no weight
 * class (= 400). The global `.is-style-wire-label::before{display:none}`
 * suppression above is correct ONLY for the source's `wire-label block`
 * eyebrows (the Tailwind `block` collapses their dash); the bold hero is a
 * bespoke instance that DOES paint one. Re-enable + size it here. */
.esteri-page-hero--bold .esteri-page-hero__content .is-style-wire-label {
	color: hsla(0, 0%, 100%, 0.7);
	/* Bespoke inline-flex row with a REAL 40px dash element (see ::before
	 * below) — must stay inline-level even though paragraph eyebrows are now
	 * block by default. The source wrapper is inline-flex too, so both origins
	 * carry the same line-box strut here. */
	display: inline-flex;
	gap: 0.75rem;
	font-weight: 400;
	letter-spacing: 0.2em;
	/* Inline-built eyebrow inherits body line-height in source (probed 24px). */
	line-height: 1.6;
	/* The source wrapper carries `mb-6` (24px), not the generic eyebrow's 1rem
	 * — and the panel is `flex items-center`, so an 8px-short content block
	 * centres 4px lower and ends 4px higher. That turned one wrong margin into
	 * three sweep flags on every bold hero (eyebrow→h1 −8, breadcrumb→eyebrow
	 * +4, hero→next-band +4). PageHero.tsx bold branch. */
	margin-bottom: 1.5rem;
}
.esteri-page-hero--bold .esteri-page-hero__content .is-style-wire-label::before {
	display: block;
	width: 40px;
}

.esteri-page-hero--bold .esteri-page-hero__content .esteri-page-hero__title,
.esteri-page-hero--bold .esteri-page-hero__content h1 {
	color: #fff;
}

.esteri-page-hero--bold .esteri-page-hero__content .esteri-page-hero__lead,
.esteri-page-hero--bold .esteri-page-hero__content > p {
	/* source PageHero.tsx: text-primary-foreground/75 (was mis-ported 0.78) */
	color: hsla(0, 0%, 100%, 0.75);
}

.esteri-page-hero__title,
.esteri-page-hero .esteri-page-hero__title {
	font-size: 2.25rem;            /* text-4xl */
	line-height: 1.05;
	letter-spacing: -0.035em;      /* source base `h1 { letter-spacing: -0.035em }`; the
	                                  looser -0.02em let the long word "Ammattipesulakoneet"
	                                  overrun the right edge at 390 (H4). */
	font-weight: 700;
	margin: 0 0 1.25rem 0;
	/* Hero titles keep their words WHOLE and wrap only at spaces — the source h1
	 * (PageHero.tsx:137) carries no wrap class, i.e. `overflow-wrap: normal`, and
	 * MEASURES as one line at every width: at ≥1280 "Ammattipesulakoneet" is 606px
	 * of ink in a 576px box and Lovable simply lets it overrun (overflow:visible).
	 *
	 * `anywhere` was tried here to stop a customer-entered no-space run from being
	 * clipped, on the assumption that "a realistic title always fits the desktop
	 * panel". That assumption is FALSE for this very title: it broke mid-character
	 * at 320/360/390 and at every width ≥1024 (Saku: "the title is wrapping wrong").
	 * It also silently defeated the page-id-8 band shrinks below, which exist
	 * precisely to keep this word whole and were calibrated under `normal` — with
	 * `anywhere` the title ended up BOTH smaller than the design AND broken.
	 *
	 * Parity wins over the pathological case: a no-space run wider than the panel
	 * overflows exactly as it does in the source. If that ever needs containing,
	 * fix it where the source differs (the panel is narrower than Lovable's at
	 * 1024/1280 — see the divergence note on the bands below), not by breaking
	 * real words.
	 * refs: parity-acceptance-standard.md § divergence ledger; source PageHero.tsx:137. */
	overflow-wrap: normal;
}

/* Source PageHero h1 (both tones): text-4xl md:text-5xl lg:text-[3.25rem]; the bold
 * tone adds xl:text-6xl. Stepped — old clamp(2rem,5vw,3.25rem) undershot md:text-5xl
 * badly at 768 (~38px vs 48px) and applied the xl step to BOTH tones (default caps
 * at 3.25rem per source). */
/* Match the base rule's 2-selector specificity (0,2,0) so these win — the base
 * group above includes `.esteri-page-hero .esteri-page-hero__title`. */
@media (min-width: 768px) {
	/* From md up the source h1 renders line-height 1.0, not the base leading-[1.05]
	 * (Tailwind text-5xl/6xl set lh:1 and win; probed 48/48, 52/52, 60/60 on
	 * every PageHero route — WP's 1.05 added 2-3px per line site-wide). */
	.esteri-page-hero .esteri-page-hero__title { font-size: 3rem; line-height: 1; }      /* md:text-5xl */
}
@media (min-width: 1024px) {
	.esteri-page-hero .esteri-page-hero__title { font-size: 3.25rem; }   /* lg:text-[3.25rem] */
}
@media (min-width: 1280px) {
	.esteri-page-hero--bold .esteri-page-hero__title { font-size: 3.75rem; }   /* bold only: xl:text-6xl */
}

/* /tuotteet (#8) hero — reflow-band shrink-to-fit (user-reported break; logged
 * divergence from source, which has the SAME flaw). The exceptionally long single
 * word "Ammattipesulakoneet" (overflow-wrap:normal so it can't break) is wider than
 * the bold panel's ~50vw content box across two sub-bands, so its INK overran into
 * the image (measured with a text Range, not the element box — the word overflows
 * its box with overflow:visible). Shrink the title fluidly so the whole word stays
 * on ONE line clear of the image, each band capped at its design size where that
 * already fits. Scoped to page-id-8 so other bold heroes with short titles keep the
 * full size; gated breakpoints (1920/768/390) and ≥1508px (padding-left grows to
 * align the rail, so the word clears) are untouched.
 * refs: gotchas.md §Responsive reflow-band; parity-acceptance-standard.md §divergence ledger.
 *
 * Band 1 (1024–1279, design 52px / 3.25rem): 52px word ink ~525px overran by +53px @1024. */
@media (min-width: 1024px) and (max-width: 1279.98px) {
	.page-id-8 .esteri-page-hero--bold .esteri-page-hero__title {
		font-size: min(3.25rem, calc(8vw - 2.4rem));
	}
}
/* Band 2 (1280–1507, design 60px / 3.75rem = xl:text-6xl): 60px word ink ~606px sits
 * at x=654 while the image starts at 50vw, so it overran by +14px @1280, +4px @1300,
 * clearing by ~1330. min() restores the full 3.75rem from ~1345px up. */
@media (min-width: 1280px) and (max-width: 1507.98px) {
	.page-id-8 .esteri-page-hero--bold .esteri-page-hero__title {
		font-size: min(3.75rem, calc(4.95vw - 0.4rem));
	}
}

.esteri-page-hero__lead {
	/* Source bold PageHero lede: `text-base lg:text-lg` (probed 16/26 → 18/28). */
	font-size: 1rem;
	line-height: 1.625;
	margin: 0;
	max-width: 42rem;   /* source PageHero lede `max-w-2xl` — was unconstrained (full-width, never wrapped) */
}
/* WarrantyPage (#6) hero lede uses the tighter `max-w-xl` (576px) → 2 lines. */
.page-id-6 .esteri-page-hero__lead {
	max-width: 36rem;
}
@media (min-width: 1024px) {
	.esteri-page-hero__lead { font-size: 1.125rem; line-height: 1.5556; }
}

.esteri-page-hero__media {
	position: relative;
	/* Source stacks the hero image below the text on mobile at
	 * min-height var(--hero-visual-min) = 320px; WP hard-coded 18rem (288px),
	 * so every page-hero image was ~32px shorter than the design at <1024px. */
	min-height: var(--hero-visual-min, 20rem);
	background: hsl(220, 13%, 92%);
	overflow: hidden;
}

@media (min-width: 1024px) {
	.esteri-page-hero__media {
		min-height: 100%;
	}
}

.esteri-page-hero__img,
.esteri-page-hero__media img {
	position: absolute;
	inset: 0;
	width: 100%;
	height: 100%;
	object-fit: cover;
}

/* ─── ServiceSupportPage /huolto-ja-tuki section styles ────────────
 * Patterns: service-links, service-action-steps, warranty-highlight,
 * cta-block-light. Source: src/pages/ServiceSupportPage.tsx +
 * src/components/shared/{ServiceHighlight,WarrantyHighlight}.tsx. */

/* ─── is-style-with-arrow — primary CTA with trailing → glyph ──── */
/* Mirrors `is-style-with-bot` but uses the right-arrow SVG after the
 * label. Source pattern: `{ctaLabel} <ArrowRight size={16} />` on the
 * service-action / warranty CTAs. Icon currentColor inherits text color. */
.wp-block-button.is-style-with-arrow > .wp-block-button__link,
.wp-block-button.has-arrow > .wp-block-button__link {
	display: inline-flex;
	align-items: center;
	gap: 0.5rem;
}
.wp-block-button.is-style-with-arrow > .wp-block-button__link::after,
.wp-block-button.has-arrow > .wp-block-button__link::after {
	content: '';
	width: 16px;
	height: 16px;
	flex-shrink: 0;
	background-color: currentColor;
	-webkit-mask: var(--esteri-arrow-right) center / contain no-repeat;
	        mask: var(--esteri-arrow-right) center / contain no-repeat;
	transition: transform 200ms ease-out;   /* source index.css L366-372: `.btn-* svg { transition: transform 200ms ease-out }` */
}
.wp-block-button.is-style-with-arrow > .wp-block-button__link:hover::after,
.wp-block-button.has-arrow > .wp-block-button__link:hover::after {
	/* Source `.btn-primary:hover svg { translateX(3px) }` (index.css L374-376). */
	transform: translateX(3px);
}

/* ─── Tablet (768–781) un-stack — shared fix (#11) ───────────────────
   These groups are core wp:columns, which stack 1-up at <=781px via core's
   `.wp-block-column:not(:only-child){flex-basis:100%!important}` mobile
   default. But every source counterpart un-stacks at the Tailwind `md`
   breakpoint (768px): ContactPage `md:grid-cols-2`, the service-links cards
   `md:grid-cols-3`, the 01/02/03 steps `md:grid-cols-3`. So in the 768–781
   band WP stacked where Lovable was already multi-col. Un-stack them from
   768 with true-equal columns (flex-basis:0 + grow, matching grid-cols-N).
   The >=768 query also covers >=782, replacing core's content-sized columns
   with equal ones — an improvement, and identical above 782 in practice. */
@media (min-width: 768px) {
	.esteri-contact-info.wp-block-columns,
	.esteri-service-links__grid.wp-block-columns,
	.esteri-service-action__steps.wp-block-columns,
	.esteri-warranty-highlight__grid.wp-block-columns,
	.esteri-sol-prose.wp-block-columns {
		flex-wrap: nowrap !important;
	}
	.esteri-contact-info.wp-block-columns > .wp-block-column,
	.esteri-service-links__grid.wp-block-columns > .wp-block-column,
	.esteri-service-action__steps.wp-block-columns > .wp-block-column,
	.esteri-warranty-highlight__grid.wp-block-columns > .wp-block-column,
	.esteri-sol-prose.wp-block-columns > .wp-block-column {
		flex-basis: 0 !important;
		flex-grow: 1;
	}
}

/* ─── Pattern: service-links (3-col grid, Huolto/Varaosat/Takuu) ── */

.esteri-service-links {
	background: var(--wp--preset--color--background, #fff);
}
.esteri-service-links .wire-container {
	border-top: 1px solid var(--wp--preset--color--border, hsl(220, 13%, 91%));
	padding-top: 2.5rem;
}
/* Mobile: stacked, 2rem gap. Desktop: 3-col grid, dividers via column borders. */
.esteri-service-links__grid {
	margin: 0;
}
.esteri-service-link {
	display: flex;
	flex-direction: column;
	position: relative;
	padding: 0;
}
.esteri-service-link::before {
	content: '';
	display: block;
	width: 28px;
	height: 28px;
	margin: 0 0 1.25rem 0;
	background-color: var(--wp--preset--color--primary-deep, hsl(222, 68%, 20%));
	transition: transform 0.2s ease-out;
}
.esteri-service-link--huolto::before {
	-webkit-mask: var(--esteri-wrench) center / contain no-repeat;
	        mask: var(--esteri-wrench) center / contain no-repeat;
}
.esteri-service-link--varaosat::before {
	-webkit-mask: var(--esteri-phone) center / contain no-repeat;
	        mask: var(--esteri-phone) center / contain no-repeat;
}
.esteri-service-link--takuu::before {
	-webkit-mask: var(--esteri-shield) center / contain no-repeat;
	        mask: var(--esteri-shield) center / contain no-repeat;
}
/* Generic, swappable icon modifiers for the 3-column-list pattern — change a
   column's icon by editing its Advanced → "Additional CSS class" to one of
   these (the design's icon set). The --huolto/--varaosat/--takuu aliases above
   stay for the service page's own markup. */
.esteri-service-link--icon-wrench::before   { -webkit-mask: var(--esteri-wrench)   center / contain no-repeat; mask: var(--esteri-wrench)   center / contain no-repeat; }
.esteri-service-link--icon-phone::before    { -webkit-mask: var(--esteri-phone)    center / contain no-repeat; mask: var(--esteri-phone)    center / contain no-repeat; }
.esteri-service-link--icon-shield::before   { -webkit-mask: var(--esteri-shield)   center / contain no-repeat; mask: var(--esteri-shield)   center / contain no-repeat; }
.esteri-service-link--icon-check::before    { -webkit-mask: var(--esteri-check)    center / contain no-repeat; mask: var(--esteri-check)    center / contain no-repeat; }
.esteri-service-link--icon-search::before   { -webkit-mask: var(--esteri-search)   center / contain no-repeat; mask: var(--esteri-search)   center / contain no-repeat; }
.esteri-service-link--icon-sparkles::before { -webkit-mask: var(--esteri-sparkles) center / contain no-repeat; mask: var(--esteri-sparkles) center / contain no-repeat; }
.esteri-service-link--icon-paw::before      { -webkit-mask: var(--esteri-paw)      center / contain no-repeat; mask: var(--esteri-paw)      center / contain no-repeat; }
.esteri-service-link--icon-bot::before      { -webkit-mask: var(--esteri-bot)      center / contain no-repeat; mask: var(--esteri-bot)      center / contain no-repeat; }
/* Card-wide hover ON PURPOSE — parity with the source, where the whole card
 * is one <Link> (`group` + group-hover: ServiceSupportPage.tsx), so hovering
 * anywhere on it plays the choreography. cont.83 briefly re-scoped this to
 * the CTA link only (":has(link:hover)") on a "should hover when on the
 * link" report; Saku checked Lovable and reverted — the card-wide trigger is
 * the design ("ok it was working correctly against lovable"). */
.esteri-service-link:hover::before {
	transform: translateY(-2px);
}
.esteri-service-link__title {
	font-size: 1.25rem;
	font-weight: 700;
	/* The design renders this title as an <h3> (ServiceSupportPage.tsx:41), so it
	 * takes index.css's `h1,h2,h3,h4 { letter-spacing: -0.02em }` — NOT the `h2
	 * { -0.025em }` override. WP emits an <h2> on purpose: the section sits
	 * directly under the page <h1>, and an <h3> there would skip a level and trip
	 * axe's heading-order rule (the design's own flaw, not worth porting). So pin
	 * the design's h3 tracking explicitly instead of inheriting h2's. Without this
	 * the word rendered 0.1px/char tight — under parity-typography's 0.1px
	 * tolerance, caught by the L3 pixel oracle (text-parity 4.34%). */
	letter-spacing: -0.02em;
	line-height: 1.25;   /* source text-xl leading-tight = 25px (was 1.2 → 24px) */
	margin: 0 0 0.75rem 0;
	color: var(--wp--preset--color--foreground, hsl(222, 30%, 12%));
	transition: color 0.2s ease-out;
}
.esteri-service-link:hover .esteri-service-link__title {
	color: var(--wp--preset--color--primary-deep, hsl(222, 68%, 20%));
}
.esteri-service-link__desc {
	font-size: 1rem;
	line-height: 1.625;   /* source `leading-relaxed` */
	color: var(--wp--preset--color--muted-foreground, hsl(220, 9%, 46%));
	margin: 0 0 1.25rem 0;
	flex: 1;
}
.esteri-service-link__cta {
	margin-top: auto;
}
.esteri-service-link__cta .wp-block-button {
	margin: 0;
}
/* Source card is one `group` link — hovering ANYWHERE on it plays the CTA's
 * hover choreography (text → blue, blue sweep over the green underline,
 * arrow nudge 4px). The wire-link's own :hover only fired on the button. */
.esteri-service-link__cta .wp-block-button.is-style-wire-link > .wp-block-button__link {
	padding-bottom: 0.5rem;   /* source `pb-2` */
}
.esteri-service-link:hover .wp-block-button.is-style-wire-link > .wp-block-button__link {
	/* !important because the base wire-link colour now carries it (§ "Buttons")
	 * and an important declaration outranks a non-important one at ANY
	 * specificity — without this the card-hover colour change stops firing. */
	color: var(--wp--preset--color--primary-deep) !important;
}
.esteri-service-link:hover .wp-block-button.is-style-wire-link > .wp-block-button__link::before {
	transform: scaleX(1);
}
.esteri-service-link:hover .wp-block-button.is-style-wire-link > .wp-block-button__link::after {
	transform: translate(4px, -4px);   /* source arrow `translate-x-1 -translate-y-1` */
}
@media (min-width: 768px) {
	/* Equal columns + vertical dividers between them. Override the
	 * WP-default 32px block-gap so the divider line sits on the column
	 * edge (source: `md:gap-0 md:divide-x md:divide-border`, md = 768). */
	.esteri-service-links__grid.wp-block-columns {
		gap: 0 !important;
	}
	.esteri-service-links__grid > .wp-block-column {
		padding: 0 2rem;
		/* source `grid-cols-3` = exact equal thirds; WP flex-sizes columns by
		 * content (454/487/455) → the desc wraps at a different word. Force
		 * equal-basis thirds to match the source. min-width:0 defeats the flex
		 * `min-width:auto` default so a longer-content column can't grow wider. */
		flex: 1 1 0 !important;
		min-width: 0;
	}
	.esteri-service-links__grid > .wp-block-column:first-child {
		padding-left: 0;
	}
	.esteri-service-links__grid > .wp-block-column:last-child {
		padding-right: 0;
	}
	.esteri-service-links__grid > .wp-block-column + .wp-block-column {
		border-left: 1px solid var(--wp--preset--color--border, hsl(220, 13%, 91%));
	}
}

/* ─── Pattern: service-action-steps (numbered 01/02/03 grid) ───── */

/* The band surface — source ServiceHighlight / WarrantyActionHighlight
 * `bg-secondary`. It used to reach the page ONLY as core's colour utility
 * (`has-secondary-background-color has-background` in the pattern markup), i.e.
 * the design's one background was stored as an author-editable colour attribute:
 * a designed look wearing the colour picker, which is the defect
 * block-style-variations-from-uikit.md § "Every style you OFFER must exist in the
 * design" describes. Now it is CSS, the pattern ships no colour classes, and the
 * Colour panel is off for this band (sl-esteri-core/assets/band-colour-lock.js).
 * Published pages that still carry the utility classes render IDENTICALLY — the
 * utility resolves to the same `--secondary` preset and its `!important` simply
 * wins over this rule. */
.esteri-service-action {
	background-color: var(--wp--preset--color--secondary, hsl(220, 20%, 96%));
}

.esteri-service-action__head {
	max-width: 48rem;
	margin: 0 0 2.5rem 0;
	/* Direct child of the constrained `.wire-container`, so WP injects
	 * margin-inline:auto !important and CENTRES the 48rem head (its
	 * left-aligned step cards then mis-align under a centred header). Source
	 * head is `max-w-3xl` left-aligned — pin it left (matches the warranty
	 * reading-rail fix). Applies to both service-action + warranty variants. */
	margin-inline: 0 auto !important;
}
@media (min-width: 782px) {
	.esteri-service-action__head {
		margin-bottom: 3.5rem;
	}
}
/* Optional eyebrow above the h2 (used by warranty-action-steps; absent on
 * service-action-steps). Sits one wire-label-baseline above the heading,
 * with its own bottom margin so the h2 keeps its existing rhythm. */
.esteri-service-action__eyebrow {
	margin: 0 0 0.75rem 0;
}
/* Source (WarrantyActionHighlight.tsx) eyebrow is a SMALL muted-gray mono label
 * — `text-xs font-mono uppercase tracking-[0.2em] text-muted-foreground` (12px /
 * 400 / muted / 0.2em), NOT the brand-blue 15px/500 wire-label the
 * is-style-wire-label class otherwise applies. Override on `p.` (0,1,1). */
p.esteri-service-action__eyebrow {
	font-size: 0.75rem;       /* text-xs (12px) */
	font-weight: 400;
	line-height: 1.333;       /* 16px */
	letter-spacing: 0.2em;    /* tracking-[0.2em] */
	color: hsl(222, 10%, 42%);
}
.esteri-service-action__h2 {
	font-size: 1.875rem;   /* text-3xl */
	font-weight: 700;
	/* source text-3xl/text-4xl pair FIXED line-heights (36px/40px); a flat 1.15
	 * gave 34.5/41.4px — replicate the per-breakpoint paired lh (gotchas § Layout). */
	line-height: 2.25rem;  /* text-3xl → 36px */
	letter-spacing: -0.025em;
	margin: 0 0 1rem 0;
	color: var(--wp--preset--color--foreground, hsl(222, 30%, 12%));
}
@media (min-width: 768px) {
	.esteri-service-action__h2 { font-size: 2.25rem; line-height: 2.5rem; }   /* md:text-4xl → 40px */
}
.esteri-service-action__lead {
	font-size: 1rem;
	line-height: 1.625;   /* source leading-relaxed = 26px (was 1.6 → 25.6, 1px shift) */
	color: var(--wp--preset--color--muted-foreground, hsl(220, 9%, 46%));
	margin: 0;
}
.esteri-service-action__lead strong {
	color: var(--wp--preset--color--foreground, hsl(222, 30%, 12%));
	font-weight: 700;
}
.esteri-service-action__steps {
	margin: 0 0 2.5rem 0;
	counter-reset: esteri-step;
}
/* Source steps are `gap-8 md:gap-10` (2rem → 2.5rem); the wp:columns blockGap:0
 * left the stacked 01/02/03 steps with no vertical gap at 390 (cramped). The
 * 2-class selector beats core's :where() columns-gap. (Both the huolto-ja-tuki
 * service-action + huolto-takuu warranty-action variants use this class.) */
.esteri-service-action__steps.wp-block-columns {
	gap: 2rem;
}
@media (min-width: 768px) {
	.esteri-service-action__steps.wp-block-columns { gap: 2.5rem; }
}
.esteri-service-step {
	position: relative;
	padding: 0;
}
/* Icon row (Search / PhoneCall / Wrench) — 28px lucide via CSS mask. */
.esteri-service-step::before {
	content: '';
	display: block;
	width: 28px;
	height: 28px;
	margin: 0 0 1rem 0;
	background-color: var(--wp--preset--color--primary, hsl(222, 68%, 33%));
}
.esteri-service-step--etsi::before {
	-webkit-mask: var(--esteri-search) center / contain no-repeat;
	        mask: var(--esteri-search) center / contain no-repeat;
}
.esteri-service-step--soita::before {
	-webkit-mask: var(--esteri-phone-call) center / contain no-repeat;
	        mask: var(--esteri-phone-call) center / contain no-repeat;
}
.esteri-service-step--ilmoita::before {
	-webkit-mask: var(--esteri-clipboard-list) center / contain no-repeat;
	        mask: var(--esteri-clipboard-list) center / contain no-repeat;
}
.esteri-service-step--huolto::before {
	-webkit-mask: var(--esteri-wrench) center / contain no-repeat;
	        mask: var(--esteri-wrench) center / contain no-repeat;
}
/* Numbered prefix on each step heading — source: `<span class="font-mono
 * text-primary mr-2">0{i + 1}.</span>{title}`. CSS counter so customer
 * can add/remove steps without renumbering. */
.esteri-service-step__title {
	font-size: 1.125rem;   /* source text-lg (18px) */
	font-weight: 700;
	line-height: 1.25;     /* leading-tight → 22.5px */
	margin: 0 0 0.5rem 0;
	color: var(--wp--preset--color--foreground, hsl(222, 30%, 12%));
}
.esteri-service-step__title::before {
	counter-increment: esteri-step;
	/* No trailing space inside content: in Space Mono a trailing " " adds a full
	 * monospace cell after the dot, detaching it ("01 ." with an oversized gap).
	 * Source is `<span class="font-mono mr-2">01.</span>` = "01." + mr-2 (8px);
	 * match it with a clean "01." + 0.5rem margin. */
	content: "0" counter(esteri-step) ".";
	font-family: var(--wp--preset--font-family--mono, 'Space Mono', monospace);
	color: var(--wp--preset--color--primary, hsl(222, 68%, 33%));
	/* Weight INHERITS the title's 700 on purpose — source is a bare
	 * `font-mono` span inside the font-bold h3, so the number is BOLD
	 * Space Mono (was a 400 mis-port: "numbers are different weight"). */
	margin-right: 0.5rem;
}
@media (min-width: 768px) {
	.esteri-service-step__title {
		font-size: 1.25rem;   /* source md:text-xl (20px) */
		line-height: 1.4;     /* 28px */
	}
}
.esteri-service-step__desc {
	font-size: 1rem;
	line-height: 1.6;
	color: var(--wp--preset--color--muted-foreground, hsl(220, 9%, 46%));
	margin: 0;
}
.esteri-service-action__cta .wp-block-button {
	margin: 0;
}

/* ─── Pattern: warranty-highlight (dark gradient + stat tiles) ─── */

.esteri-warranty-highlight.has-warranty-bg {
	background:
		linear-gradient(135deg,
			hsl(222, 50%, 8%) 0%,
			hsl(222, 68%, 20%) 60%,
			hsl(222, 68%, 33%) 100%);
	position: relative;
	overflow: hidden;
	border-bottom: 1px solid hsla(0, 0%, 100%, 0.08);
}
/* Source has two corner radial glows (`-top-24 -right-24` + `-bottom-32
 * -left-32`). Implemented as ::before/::after on the section wrapper. */
.esteri-warranty-highlight.has-warranty-bg::before,
.esteri-warranty-highlight.has-warranty-bg::after {
	content: '';
	position: absolute;
	pointer-events: none;
	border-radius: 50%;
}
.esteri-warranty-highlight.has-warranty-bg::before {
	top: -6rem;
	right: -6rem;
	width: 24rem;
	height: 24rem;
	background: radial-gradient(circle, hsla(74, 87%, 40%, 0.12) 0%, transparent 70%);
}
.esteri-warranty-highlight.has-warranty-bg::after {
	bottom: -8rem;
	left: -8rem;
	width: 28rem;
	height: 28rem;
	background: radial-gradient(circle, hsla(0, 0%, 100%, 0.06) 0%, transparent 70%);
}
.esteri-warranty-highlight .wire-container {
	position: relative;
	z-index: 1;
}
.esteri-warranty-highlight__grid {
	gap: 2.5rem;
}
@media (min-width: 1024px) {
	.esteri-warranty-highlight__grid {
		gap: 4rem;
	}
}
/* Eyebrow — green dash + Space-Mono uppercase, white text at 70 % alpha. */
.esteri-warranty-highlight__eyebrow {
	display: inline-flex;
	align-items: center;
	gap: 0.75rem;
	margin: 0 0 1.5rem 0;
	font-family: var(--wp--preset--font-family--mono, 'Space Mono', monospace);
	font-size: 0.9375rem;
	font-weight: 400;
	letter-spacing: 0.2em;
	line-height: 1.6;   /* source inherits 24px (was leading-none → 15px) */
	text-transform: uppercase;
	color: hsla(0, 0%, 100%, 0.7);
}
.esteri-warranty-highlight__eyebrow::before {
	content: '';
	display: inline-block;
	width: 2.5rem;
	height: 2px;
	background: var(--wp--preset--color--accent, hsl(74, 87%, 40%));
	flex-shrink: 0;
}
.esteri-warranty-highlight__h2 {
	font-size: 1.875rem;   /* text-3xl */
	font-weight: 700;
	line-height: 2.25rem;  /* text-3xl paired lh = 36px (was flat 1.15) */
	letter-spacing: -0.025em;
	color: #fff;
	margin: 0 0 1.25rem 0;
}
@media (min-width: 768px) {
	.esteri-warranty-highlight__h2 { font-size: 2.25rem; line-height: 2.5rem; }   /* md:text-4xl → 40px */
}
.esteri-warranty-highlight__lede {
	font-size: 1rem;
	line-height: 1.625;   /* source leading-relaxed = 26px (was 1.6 → 1px shift) */
	color: hsla(0, 0%, 100%, 0.75);
	margin: 0 0 2rem 0;
}
/* Bullet list — replace default markers with mask-image checkmarks. */
.esteri-warranty-highlight__list {
	list-style: none;
	padding-left: 0;
	margin: 0 0 2rem 0;
}
.esteri-warranty-highlight__list li {
	position: relative;
	padding-left: 1.875rem;
	margin: 0 0 0.75rem 0;
	font-size: 1rem;
	line-height: 1.375;   /* source leading-snug = 22px (was 1.45 → 23.2px) */
	color: hsla(0, 0%, 100%, 0.85);
}
.esteri-warranty-highlight__list li::before {
	content: '';
	position: absolute;
	left: 0;
	top: 0.125rem;
	width: 18px;
	height: 18px;
	background-color: var(--wp--preset--color--accent, hsl(74, 87%, 40%));
	-webkit-mask: var(--esteri-check) center / contain no-repeat;
	        mask: var(--esteri-check) center / contain no-repeat;
}
.esteri-warranty-highlight__cta .wp-block-button {
	margin: 0;
}
/* Right column — 3 tiles in a 2-col grid; 3rd tile spans both columns. */
.esteri-warranty-highlight__tiles {
	display: grid;
	grid-template-columns: 1fr 1fr;
	gap: 0.75rem;
}
@media (min-width: 640px) {
	.esteri-warranty-highlight__tiles {
		gap: 1rem;
	}
}
.esteri-warranty-tile {
	position: relative;
	/* Source WarrantyHighlight tile: `p-4 sm:p-6` — 16px below 640, not 20px.
	 * The extra 4px/side squeezed the label box 120→112 and wrapped
	 * "Rakennetakuu" onto 2 lines at 360. */
	padding: 1rem;
	background: hsla(0, 0%, 100%, 0.04);
	backdrop-filter: blur(2px);
	border: 1px solid hsla(0, 0%, 100%, 0.15);
	overflow: hidden;
	min-width: 0;
}
@media (min-width: 640px) {
	.esteri-warranty-tile {
		padding: 1.5rem;
	}
}
/* 12 × 3 px green accent bar at top-left of each tile. */
.esteri-warranty-tile::before {
	content: '';
	position: absolute;
	top: 0;
	left: 0;
	width: 3rem;
	height: 3px;
	background: var(--wp--preset--color--accent, hsl(74, 87%, 40%));
}
.esteri-warranty-tile--wide {
	grid-column: span 2;
}
/* Stat-tile content: ShieldCheck icon → big number → label → small desc.
 * Source (WarrantyHighlight.tsx) renders the ShieldCheck as a block element
 * top-LEFT, ABOVE the number (`mb-4`). The icon is CSS-only here (no markup
 * element), so anchor the ::after top-LEFT and RESERVE its row via the tile's
 * padding-top (icon top + 22px + mb-4 gap). Padding is used, not the number's
 * margin-top, because the tile is a flow-layout group with blockGap:0 → WP
 * zeroes the first child's margin-block-start (it can't push the number down). */
.esteri-warranty-tile--stat {
	padding-top: 3.375rem; /* 1.25rem icon-top + 22px icon + ~1rem mb-4 */
}
.esteri-warranty-tile--stat::after {
	content: '';
	position: absolute;
	top: 1.25rem;
	left: 1.25rem;
	width: 22px;
	height: 22px;
	background-color: var(--wp--preset--color--accent, hsl(74, 87%, 40%));
	-webkit-mask: var(--esteri-shield-check) center / contain no-repeat;
	        mask: var(--esteri-shield-check) center / contain no-repeat;
}
@media (min-width: 640px) {
	.esteri-warranty-tile--stat {
		padding-top: 4rem; /* 1.5rem icon-top + 22px icon + ~1rem mb-4 */
	}
	.esteri-warranty-tile--stat::after {
		top: 1.5rem;
		left: 1.5rem;
	}
}
.esteri-warranty-tile__num {
	font-size: 2.25rem; /* text-4xl (source WarrantyHighlight.tsx); sm:text-5xl=3rem @640 below */
	font-weight: 700;
	line-height: 1;
	letter-spacing: -0.03em;
	color: #fff;
	margin: 0 0 0.25rem 0; /* mb-1; top reserved by the tile's padding-top above */
}
@media (min-width: 640px) {
	.esteri-warranty-tile__num {
		font-size: 3rem;
	}
}
.esteri-warranty-tile__unit {
	font-family: var(--wp--preset--font-family--mono, 'Space Mono', monospace);
	font-size: 1.25rem;
	font-weight: 400;
	color: hsla(0, 0%, 100%, 0.7);
	margin-left: 0.25rem;
}
@media (min-width: 640px) {
	.esteri-warranty-tile__unit {
		font-size: 1.5rem;
	}
}
.esteri-warranty-tile__label {
	font-family: var(--wp--preset--font-family--mono, 'Space Mono', monospace);
	font-size: 0.9375rem;   /* source text-sm = 15px (was 14px); lh inherits 1.6 → 24px */
	letter-spacing: 0.05em; /* source 0.75px @15px (was 0.15em → 2.1px) */
	text-transform: uppercase;
	color: hsla(0, 0%, 100%, 0.7);
	margin: 0.75rem 0 0.375rem 0;
}
.esteri-warranty-tile__desc {
	font-size: 0.9375rem;   /* source text-sm = 15px (was 14px) */
	line-height: 1.375;     /* source leading-snug = 20.625px (was 1.45) */
	color: hsla(0, 0%, 100%, 0.6);
	margin: 0;
	overflow-wrap: anywhere;
}
/* Wide tile: Wrench-icon row + body. Source: `text-sm font-mono uppercase
 * tracking-wider` — 15px/24px with 0.05em tracking, a plain mono label, NOT
 * the 14px/0.15em wire-label eyebrow this had been styled as. */
.esteri-warranty-tile--wide .esteri-warranty-tile__eyebrow {
	display: inline-flex;
	align-items: center;
	gap: 0.75rem;
	font-family: var(--wp--preset--font-family--mono, 'Space Mono', monospace);
	font-size: 0.9375rem;   /* text-sm = 15px (project override) */
	line-height: 1.5rem;    /* text-sm's paired 24px */
	letter-spacing: 0.05em; /* tracking-wider */
	text-transform: uppercase;
	color: #fff;
	margin: 0 0 0.75rem 0;
}
.esteri-warranty-tile--wide .esteri-warranty-tile__eyebrow::before {
	content: '';
	display: inline-block;
	width: 16px;
	height: 16px;
	flex-shrink: 0;
	background-color: var(--wp--preset--color--accent, hsl(74, 87%, 40%));
	-webkit-mask: var(--esteri-wrench) center / contain no-repeat;
	        mask: var(--esteri-wrench) center / contain no-repeat;
}
.esteri-warranty-tile--wide .esteri-warranty-tile__body {
	font-size: 1rem;
	line-height: 1.45;
	color: hsla(0, 0%, 100%, 0.85);
	margin: 0;
}

/* ─── Pattern: cta-block-light (final-CTA, secondary surface) ──── */

.esteri-cta-block.esteri-cta-block--light {
	border-top: 1px solid var(--wp--preset--color--border, hsl(220, 13%, 91%));
	border-bottom: 1px solid var(--wp--preset--color--border, hsl(220, 13%, 91%));
}
/* `.esteri-cta.is-style-esteri-cta-light` aliased in so light pages migrated onto
   the unified wrapper keep their heading/lede byte-for-byte (inner blocks kept). */
.esteri-cta-block--light .esteri-cta-block__title,
.esteri-cta.is-style-esteri-cta-light .esteri-cta-block__title {
	font-size: 1.875rem;   /* text-3xl */
	font-weight: 700;
	line-height: 1.1;
	letter-spacing: -0.025em;
	color: var(--wp--preset--color--foreground, hsl(222, 30%, 12%));
	margin: 0 0 1rem 0;
}
@media (min-width: 768px) {
	.esteri-cta-block--light .esteri-cta-block__title,
	.esteri-cta.is-style-esteri-cta-light .esteri-cta-block__title { font-size: 2.25rem; }   /* md:text-4xl */
}
.esteri-cta-block--light .esteri-cta-block__lede,
.esteri-cta.is-style-esteri-cta-light .esteri-cta-block__lede {
	font-size: 1rem;
	line-height: 1.625;   /* source leading-relaxed = 26px (was 1.55 → 24.8px) */
	color: var(--wp--preset--color--muted-foreground, hsl(220, 9%, 46%));
	max-width: 36rem;
	margin: 0 auto 2.5rem auto;
}

/* ─── Pattern: warranty-terms (long-form takuuehdot prose) ──────── */

/* Reading-rail width matches source `<div class="max-w-3xl">` = 48rem.
 * The rail is a direct child of the constrained `.wire-container`, so WP's
 * `:where(.is-layout-constrained) > *` rule injects margin-left/right:auto and
 * CENTRES the narrow 48rem rail (a big empty left gutter at ≥1508). Source
 * `max-w-3xl` has NO mx-auto → left-aligned to the rail. Pin it left. */
.esteri-warranty-terms__rail {
	max-width: 48rem;
	/* WP's constrained-layout child rule sets margin-inline:auto !important, so
	 * the !important here is load-bearing to win and pin the rail left. */
	margin-left: 0 !important;
	margin-right: auto !important;
}
.esteri-warranty-terms__lead {
	font-size: 1.125rem;
	line-height: 1.625;
	color: var(--wp--preset--color--muted-foreground, hsl(220, 9%, 46%));
	margin: 0 0 2rem 0;
}
/* Wire-label eyebrow inside the rail — `is-style-wire-label` carries
 * the green dash + spacing; this rule only owns the vertical rhythm. */
.esteri-warranty-terms__eyebrow {
	margin: 0 0 1.5rem 0;
}
/* Source renders this lead-in ("Kiinteistöpesulakoneiden ESTERI 5
 * takuu:") as a muted bold-mono label (`text-base font-mono font-bold
 * text-muted`), NOT the brand-blue `.wire-label` (hsl(--primary) / 15px /
 * 500 / 0.18em tracking). The pattern tags it `is-style-wire-label`, so
 * override on a `p.` selector (0,1,1 — beats is-style-wire-label and the
 * theme.json `:root :where(p)` 16px tie) to match the source computed
 * style: gray hsl(222 10% 42%) / 16px / 700 / ~0.05em. */
p.esteri-warranty-terms__eyebrow {
	color: hsl(222, 10%, 42%);
	font-size: 1rem;
	font-weight: 700;
	line-height: 1.5;          /* source mono label lh 24px (was a cramped 1.0) */
	letter-spacing: 0.05em;
}
/* mb 0: the label is inline-flex (is-style-wire-label), so its bottom
 * margin can NOT collapse into the h2's mt-10 like the source's block
 * label does — any mb here ADDS to the 2.5rem and opened a 56px gap
 * (source renders 40px = collapse(mb-6, mt-10)). Compound selector on
 * purpose: (0,2,1) beats the generic `.wp-block-paragraph.is-style-wire-label
 * { margin-bottom: 1rem }` (0,2,0). */
p.is-style-wire-label.esteri-warranty-terms__eyebrow {
	margin-bottom: 0;
}
.esteri-warranty-terms__h2 {
	font-size: 1.5rem;
	font-weight: 700;
	line-height: 1.333;   /* source text-2xl default lh 2rem (32px); was 30px */
	color: var(--wp--preset--color--foreground, hsl(222, 30%, 12%));
	margin: 2.5rem 0 1rem 0;
}
/* Source uses `mt-12` (3rem) on the third and later h2s; the FIRST TWO
 * ("2 vuoden täystakuu", "5 vuoden rakennetakuu") use `mt-10` (2.5rem).
 * Order-based like the source's authored classes — the old
 * "any h2 after a list/parts" sibling rule wrongly promoted h2 #2 to
 * 3rem (ink-gap oracle: wp 52 vs lv 44). */
.esteri-warranty-terms__rail h2:nth-of-type(n+3) {
	margin-top: 3rem;
}
/* The first h2 ("2 vuoden täystakuu") follows the wire-label eyebrow,
 * which sets its own margin — keep the heading's mt-10 (2.5rem). */
.esteri-warranty-terms__list {
	list-style: disc;
	padding-left: 1.5rem;
	margin: 0 0 2rem 0;
}
.esteri-warranty-terms__list li {
	font-size: 1rem;
	line-height: 1.625;
	color: var(--wp--preset--color--muted-foreground, hsl(220, 9%, 46%));
	margin: 0 0 0.75rem 0;
	/* Source wraps these lists in Tailwind `prose prose-lg`, whose
	 * `ul > li` rule adds padding-inline-start 0.444444em (7.11px @16px) —
	 * without it the text column is 7px wider and wraps one line short
	 * ("Käyttöohjeen vastaisesta…" li: 3 lines @390 vs the design's 4). */
	padding-left: 0.444444em;
}
.esteri-warranty-terms__list li:last-child {
	margin-bottom: 0;
}
/* Source uses Tailwind `prose` — list bullets are the light prose-bullets gray
 * (gray-300), not the darker li text colour. */
.esteri-warranty-terms__list li::marker {
	color: rgb(209, 213, 219);
}
/* The last list before the closing paragraph and before the parts-block
 * gets the wider mb-10 spacing (source: `mb-10` on those two lists). */
.esteri-warranty-terms__h2 + .esteri-warranty-terms__list {
	margin-bottom: 2.5rem;
}
/* Parts breakdown: 7 rows of h3 + paragraph stacked, space-y-6 between. */
.esteri-warranty-terms__parts {
	margin: 0 0 2.5rem 0;
}
.esteri-warranty-terms__part + .esteri-warranty-terms__part {
	/* NOT space-y-6's 24px: the source wrapper is `prose prose-lg`, and the
	 * plugin's `h3 { margin-top: 1.6666667em }` — em of the h3's OWN 16px
	 * (text-base overrides prose's 1.5em) = 26.67px — COLLAPSES with the
	 * space-y-6 margin and wins. Probed 26.6667px on the design h3, 24px here;
	 * the prose geometry is invisible in the JSX (systemic root-cause #8).
	 * refs: tailwind-cheatsheet.md § "The typography plugin's `prose` geometry
	 *       is part of the design". */
	margin-top: 1.6666667rem;
}
.esteri-warranty-terms__part-h3 {
	font-size: 1rem;
	font-weight: 700;
	line-height: 1.5;   /* source 24px (was 22.4px) */
	color: var(--wp--preset--color--foreground, hsl(222, 30%, 12%));
	margin: 0 0 0.25rem 0;
}
.esteri-warranty-terms__part-p {
	font-size: 1rem;
	line-height: 1.625;
	color: var(--wp--preset--color--muted-foreground, hsl(220, 9%, 46%));
	margin: 0;
}
/* The parts h2 ("Rakennetakuuseen kuuluvat osat") has source `mb-6`,
 * whereas other h2s use `mb-4`. Override only when the next sibling
 * is the parts container. */
.esteri-warranty-terms__h2 + .esteri-warranty-terms__parts {
	margin-top: -1rem; /* compensate the 1.5rem h2 bottom-margin → effective mb-6 */
	margin-top: 0;     /* (browsers without parent-sibling-margin tricks) */
}
.esteri-warranty-terms__h2:has(+ .esteri-warranty-terms__parts) {
	margin-bottom: 1.5rem;
}
/* Closing paragraph above a top-border, mimicking source's
 * `border-t border-border pt-6`. */
.esteri-warranty-terms__closing {
	font-size: 1rem;
	line-height: 1.625;
	color: var(--wp--preset--color--muted-foreground, hsl(220, 9%, 46%));
	border-top: 1px solid var(--wp--preset--color--border, hsl(220, 13%, 91%));
	padding-top: 1.5rem;
	margin: 0;
}

/* ─── ProductsPage category grid ──────────────────────────────────────── */
/*
 * Engineered light-surface card grid for /tuotteet TUOTEKATEGORIAT — a
 * distinct card shape from the Index `product-categories` pattern (which
 * uses the full-bleed `.esteri-tile` image-cover with overlay). Source:
 * `source/src/pages/ProductsPage.tsx` — `bg-muted/40`, top accent line
 * with green sweep, h-52 image strip, body with title/description/sub-
 * category chips.
 *
 * Only `--active` cards (cursor-pointer) lift on hover; `--inactive` ones
 * render at opacity-85 cursor-default per source.
 */
.esteri-product-card-grid {
	display: grid;
	grid-template-columns: minmax(0, 1fr);
	gap: 1rem;
}
@media (min-width: 640px) {
	.esteri-product-card-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (min-width: 1024px) {
	.esteri-product-card-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
.esteri-product-card {
	position: relative;
	display: block;
	background: hsla(220, 9%, 46%, 0.06); /* bg-muted/40 — muted at 0.4 alpha; we keep it light. */
	/* Source card is `card-engineered` (ProductsPage.tsx): a 1px border, gray at
	 * rest → primary-blue on hover. The build had dropped it (border-width:0) so
	 * the card blended into the page; restore it. */
	border: 1px solid var(--wp--preset--color--border, hsl(220, 13%, 91%));
	border-radius: var(--radius, 4px);
	overflow: hidden;
	color: inherit;
	text-decoration: none;
	transition: transform 0.5s ease, border-color 0.15s ease;
	box-sizing: border-box;
}
.esteri-product-card *, .esteri-product-card *::before, .esteri-product-card *::after { box-sizing: border-box; }
.esteri-product-card--inactive { opacity: 0.85; cursor: default; }
.esteri-product-card--active   { cursor: pointer; }
/* Source applies the lift + border→primary to ALL cards (active + inactive) via the
 * unconditional cardClass, so hover on the base class — not `--active`. NO box-shadow:
 * the source's `hover:shadow-[0_20px_40px_-20px_hsl(var(--brand-blue-deep)/0.25)]`
 * names an UNDEFINED css var (the real var is --primary-deep; --brand-blue-deep is only
 * a Tailwind color name), so the browser discards the declaration and Lovable renders
 * NO shadow. Matching the rendered result. Do not "restore" a shadow here. */
.esteri-product-card:hover {
	transform: translateY(-0.25rem);
	border-color: var(--wp--preset--color--primary, hsl(222, 68%, 33%));
}

/* Engineered top accent line — green bar that sweeps the full card width
 * on hover. Applies to BOTH variants per source (the `inner` block in
 * ProductsPage.tsx is identical between active + inactive). */
.esteri-product-card__accent {
	position: absolute;
	top: 0;
	left: 0;
	width: 100%;
	height: 0.25rem; /* h-1 */
	background: hsla(220, 13%, 91%, 0.6); /* bg-border/60 */
	z-index: 10;
	overflow: hidden;
	display: block;
}
.esteri-product-card__accent-bar {
	display: block;
	width: 2.5rem; /* w-10 */
	height: 100%;
	background: var(--wp--preset--color--accent, hsl(74, 87%, 40%));
	transition: width 0.5s ease;
}
.esteri-product-card:hover .esteri-product-card__accent-bar { width: 100%; }

.esteri-product-card__media {
	position: relative;
	height: 13rem; /* h-52 */
	overflow: hidden;
	background: hsla(220, 9%, 46%, 0.06); /* bg-muted */
}
.esteri-product-card__img {
	display: block;
	width: 100%;
	height: 100%;
	object-fit: cover;
	transition: transform 0.7s ease;
}
.esteri-product-card:hover .esteri-product-card__img { transform: scale(1.05); }

/* Subtle top-right sheen — `from-transparent via-transparent to-white/10`. */
.esteri-product-card__sheen {
	position: absolute;
	inset: 0;
	pointer-events: none;
	background: linear-gradient(to top right, transparent 0%, transparent 60%, hsla(0, 0%, 100%, 0.1) 100%);
}

/* Hover arrow — same brand-green square as the homepage product
 * categories. Only renders on the active card per source's
 * `hidden lg:flex` (we mirror the lg+ visibility). */
.esteri-product-card__arrow {
	position: absolute;
	top: 0.75rem;
	right: 0.75rem;
	z-index: 10;
	width: 2rem;
	height: 2rem;
	display: none;
	align-items: center;
	justify-content: center;
	background: var(--wp--preset--color--accent, hsl(74, 87%, 40%));
	color: var(--wp--preset--color--foreground, hsl(220, 13%, 18%));
	border-radius: var(--radius, 4px);
	transform: translateX(0.5rem);
	opacity: 0;
	transition: transform 0.2s ease, opacity 0.2s ease;
}
.esteri-product-card__arrow::before {
	content: '';
	width: 0.875rem;
	height: 0.875rem;
	background-color: currentColor;
	mask-image: var(--esteri-arrow-up-right);
	-webkit-mask-image: var(--esteri-arrow-up-right);
	mask-size: contain;
	-webkit-mask-size: contain;
	mask-repeat: no-repeat;
	-webkit-mask-repeat: no-repeat;
}
@media (min-width: 1024px) {
	.esteri-product-card--active .esteri-product-card__arrow { display: flex; }
	.esteri-product-card--active:hover .esteri-product-card__arrow { transform: translateX(0); opacity: 1; }
}

.esteri-product-card__body {
	position: relative;
	padding: 1.5rem; /* p-6 */
}
.esteri-product-card__title {
	font-size: 1.125rem; /* text-lg */
	font-weight: 700;
	line-height: 1.75rem; /* source text-lg paired lh = 28px (was inheriting 1.2 → 21.6) */
	letter-spacing: -0.01em;
	color: var(--wp--preset--color--foreground, hsl(220, 13%, 18%));
	margin: 0 0 0.5rem;
}
.esteri-product-card__desc {
	font-size: 0.9375rem; /* text-sm = 15px (design overrides sm to 15; was a 14px mis-port) */
	line-height: 1.625;
	color: var(--wp--preset--color--muted-foreground, hsl(220, 9%, 46%));
	margin: 0 0 1rem;
}
.esteri-product-card__subs {
	display: flex;
	flex-wrap: wrap;
	gap: 0.375rem; /* gap-1.5 */
	padding-top: 0.75rem;
	border-top: 1px solid hsla(220, 13%, 91%, 0.6); /* border-border/60 */
}
.esteri-product-card__sub {
	font-family: var(--wp--preset--font-family--mono, "Space Mono", ui-monospace, monospace);
	font-size: 0.75rem; /* text-xs */
	letter-spacing: 0.05em;
	text-transform: uppercase;
	padding: 0.125rem 0.5rem; /* px-2 py-0.5 */
	border: 1px solid var(--wp--preset--color--border, hsl(220, 13%, 91%));
	background: var(--wp--preset--color--background, #fff);
	color: hsla(222, 30%, 12%, 0.8); /* source foreground/80 = rgb(21,27,40) (was 220,13%,18% shadcn gray) */
	border-radius: var(--radius, 4px);
	line-height: 1.333;   /* source 16px @12px (was 1.4 → 16.8px) */
	/* Enlarged text (WCAG 1.4.4): at 200 % a one-word chip ("POISTOLIITÄNTÄISET") was
	 * wider than the card and cut by its overflow. Cap it at the row and break only a
	 * word that cannot fit; every default-size chip is narrower than the row. */
	max-width: 100%;
	overflow-wrap: anywhere;
}

/* ─── fade-toggle (esteri/fade-toggle block) ───────────────────────────── */
/*
 * Wraps long-form prose; the front-end JS measures the natural content
 * height and, if it overflows the cap, injects a "Lue lisää" button
 * just after the wrapper. Short content gets `.is-expanded` set
 * automatically (no cap, no button, no fade gradient clipping the last
 * line). Recipe: accordion-and-fade-toggle.md.
 */
.esteri-fade-toggle {
	position: relative;
	max-height: var(--fade-cap, 12rem);
	overflow: hidden;
	transition: max-height 0.3s ease;
}
.esteri-fade-toggle.is-expanded {
	max-height: 200rem; /* large fixed value — CSS can't transition to `auto`. */
}
.esteri-fade-toggle:not(.is-expanded)::after {
	content: '';
	position: absolute;
	inset: auto 0 0 0;
	height: var(--fade-overlay, 5rem); /* default h-20 (5rem) for ProductsPage; per-instance via `overlayRem` attr — cat/subcat source use h-16 (4rem). */
	/* The fade must end in ITS BAND'S OWN colour or the gradient reads as a haze
	 * instead of the text dissolving (Saku, 2026-08-07: "the luelisää fade effect
	 * is bit different than in lovable"). The source has TWO bands and they fade
	 * to DIFFERENT tokens:
	 *   ProductsPage  — `bg-surface`, fade `from-surface` (h-20)
	 *   Cat / subcat  — no background, fade `from-background` (h-16)
	 * We had one hardcoded `background` (white) for both, and both bands painted
	 * surface grey — measured rgb(245,247,250) under a white fade on BOTH routes.
	 * `--fade-color` lets each band name its own end colour. */
	background: linear-gradient(to top, var(--fade-color, var(--wp--preset--color--background, #fff)) 0%, transparent 100%);
	pointer-events: none;
}
/* Editor canvas mirrors the live cap + fade + a working toggle button (the
 * edit() reuses the front-end .esteri-fade-toggle / .is-expanded / __btn
 * classes), so "Lue lisää" looks and behaves the same as the published page. */

/* Front-end button — injected by fade-toggle.js when content overflows;
   also used verbatim by the editor preview toggle. */
.esteri-fade-toggle__btn {
	margin-top: 1.25rem;
	display: inline-flex;
	align-items: center;
	gap: 0.625rem; /* gap-2.5 */
	background: none;
	border: 0;
	padding: 0;
	cursor: pointer;
	font-size: 0.9375rem; /* text-sm = 15px (design overrides sm to 15; was a 14px mis-port) */
	font-weight: 600;
	color: var(--wp--preset--color--foreground, hsl(220, 13%, 18%));
	transition: color 0.15s cubic-bezier(0.4, 0, 0.2, 1);   /* source ProductsPage "Lue lisää": transition-colors */
	font-family: inherit;
	/* The source button is `text-sm` = 15px/24px (Tailwind's size+leading pair,
	 * with sm overridden to 15px in this design). WP inherited `normal`
	 * (≈18px), so the LABEL sat ~1.5px low inside the 24px row the icon circle
	 * defines: the button box matched, the text inside it didn't.
	 * refs: accordion-and-fade-toggle.md; pixel-parity-audit.md § Tolerances. */
	line-height: 1.6;
}
.esteri-fade-toggle__btn:hover { color: var(--wp--preset--color--accent, hsl(74, 87%, 40%)); }
.esteri-fade-toggle__btn-icon {
	width: 1.5rem; /* w-6 h-6 */
	height: 1.5rem;
	border-radius: 50%;
	border: 2px solid currentColor;
	display: inline-flex;
	align-items: center;
	justify-content: center;
	flex-shrink: 0;
	font-size: 0.75rem;
	font-weight: 700;
	line-height: 1;
}

/* ─── ProductsPage SEO long-form prose ────────────────────────────────── */
/*
 * Light-surface section above the dark CTA. `.esteri-products-seo` is
 * the wire-section wrapper; `.esteri-products-seo__rail` is the
 * `max-w-5xl` (64rem) reading rail. The fade-toggle wraps the prose
 * paragraphs only — eyebrow + h2 stay above the toggle, always visible.
 */
.esteri-products-seo {
	/* Source ProductsPage SEO band: `wire-section border-t border-border
	 * bg-surface` — surface = surface-tint hsl(220 30% 97%), NOT white
	 * (probed rgb(245,247,250) on the live design). */
	background: var(--wp--preset--color--surface-tint, hsl(220, 30%, 97%));
	border-top: 1px solid var(--wp--preset--color--border, hsl(220, 13%, 91%));
}
/* …so its fade ends in surface, not white (source `from-surface`). */
.esteri-products-seo .esteri-fade-toggle {
	--fade-color: var(--wp--preset--color--surface-tint, hsl(220, 30%, 97%));
}

/* The CATEGORY / SUBCATEGORY band is a DIFFERENT band in the source:
 * `wire-section py-10 border-b border-border` — no background at all, so it sits
 * on the page white and its fade is `from-background`. This build reused the
 * ProductsPage class for it, which handed it the grey surface + top border and
 * left the fade fading to a white the band no longer was. Keyed on
 * `esteri-catpage-seo`, which every paired category/subcategory band carries. */
.esteri-products-seo.esteri-catpage-seo {
	background: transparent;
	border-top: 0;
	border-bottom: 1px solid var(--wp--preset--color--border, hsl(220, 13%, 91%));
}
.esteri-products-seo.esteri-catpage-seo .esteri-fade-toggle {
	--fade-color: var(--wp--preset--color--background, #fff);
}
.esteri-products-seo__rail {
	max-width: 64rem; /* max-w-5xl in source. */
	/* Left-align to the content rail like the source; WP's block-layout centring
	   resolves the rail's margins to auto (≈160px each side at desktop), floating
	   it mid-column — which reads as "too narrow". !important beats that layout
	   rule (which isn't !important) without chasing its exact specificity. */
	margin-left: 0 !important;
	margin-right: auto !important;
}
/* `p.` for the margin too: `:root :where(p){margin-block:0}` ties the bare class
 * and wins in the editor, which is what flattened the 16px gap under the eyebrow
 * in the canvas. */
.esteri-products-seo__eyebrow,
p.esteri-products-seo__eyebrow {
	display: block;
	margin: 0 0 1rem;
}
.esteri-products-seo__h2 {
	font-size: 1.5rem;   /* text-2xl */
	font-weight: 700;
	line-height: 2rem;   /* text-2xl paired lh = 32px */
	letter-spacing: -0.02em;
	color: var(--wp--preset--color--foreground, hsl(220, 13%, 18%));
	/* mb-4 (16px) — the ProductCategoryPage / ProductSubcategoryPage value, and
	 * that's most instances of this section. ProductsPage's own h2 is mb-6; it
	 * opts back in below (its prose carries the `--products` variant class).
	 * Probed on a design category page: h2 mb 16 + first-p mt 12 = the 28px gap
	 * WP was rendering as 24 + 0. */
	margin: 0 0 1rem;
}
.esteri-products-seo__rail:has(.esteri-products-seo__prose--products) .esteri-products-seo__h2 {
	margin-bottom: 1.5rem;   /* ProductsPage h2 `mb-6` */
}
@media (min-width: 768px) {
	.esteri-products-seo__h2 { font-size: 1.875rem; line-height: 2.25rem; }   /* md:text-3xl → 36px */
}
.esteri-products-seo__prose {
	color: var(--wp--preset--color--muted-foreground, hsl(220, 9%, 46%));
	font-size: 1rem;
	line-height: 1.625;
}
/* Vertical rhythm = the source's prose: 12px between flow siblings
   (space-y-3), h2/h3 sits 32px below the previous block (measured ink gap on
   the live design: p→h2 32, h2→p 12, p→p 12 — the cont.71 ink-gap oracle
   caught WP at 16/16/20). */
.esteri-products-seo__prose > * + * { margin-top: 0.75rem; }
/* The seoIntro markdown emits its subheadings as <h2> (## ...); without an h2
   rule here they fall through to the default 36px section size. Source renders
   them at text-2xl. Style h2 + h3 identically so either tag matches the design. */
.esteri-products-seo__prose h2,
.esteri-products-seo__prose h3 {
	font-size: 1.25rem;   /* text-xl */
	font-weight: 700;
	line-height: 1.75rem; /* text-xl paired lh = 28px */
	letter-spacing: -0.01em;
	color: var(--wp--preset--color--foreground, hsl(220, 13%, 18%));
}
/* Source prose scale gives h2 and h3 DIFFERENT gaps (ink-gap oracle, cont.71
   + cont.72): H2s appear ONLY on category/subcategory pages (## seoText →
   prose-h2:mt-8 mb-3 = 32/12); H3s ONLY on /tuotteet (### seoIntro → 16+pt-2
   top, 8 bottom, calibrated cont.71). Splitting per level lets each page type
   match its own source system. */
/* Source prose modifier is `prose-h2:text-2xl` with NO md: variant — h2 is
 * 24px/32px at EVERY width (probed on the design at 390/768/1920). The shared
 * 1.25rem base above is only right for h3 (`prose-h3:text-xl`, which DOES
 * step to 24 at md — the media block below). */
.esteri-products-seo__prose h2 { margin-top: 2rem; margin-bottom: 0.75rem; font-size: 1.5rem; line-height: 2rem; }
.esteri-products-seo__prose h3 { margin-top: 1rem; padding-top: 0.5rem; margin-bottom: 0.5rem; }
@media (min-width: 768px) {
	.esteri-products-seo__prose h2,
	.esteri-products-seo__prose h3 { font-size: 1.5rem; line-height: 2rem; }   /* md:text-2xl → 32px */
}
.esteri-products-seo__prose p { margin: 0; }
/* Re-assert the source `space-y-4` (1rem) gap between sibling paragraphs: the
   `p { margin: 0 }` reset above (0,1,1) outranks the owl `> * + *` (0,1,0), so
   without this paragraphs collapsed to a 0 gap on /tuotteet. This tie-specificity
   rule (0,1,1) sits after the reset and wins on source order; first-child p (no
   preceding sibling) is unmatched, so it keeps its 0 top margin. */
.esteri-products-seo__prose > * + p { margin-top: 0.75rem; } /* source space-y-3 = 12px (ink-gap oracle, cont.71) */

/* ── /tuotteet variant: a DIFFERENT source prose system, not a tweak ──────
 * The class above is shared by two source components with different prose
 * configs, and the 12px rhythm belongs to only one of them:
 *
 *   ProductCategoryPage / ProductSubcategoryPage — `prose-p:my-3` (12px),
 *     `prose-h2:mt-8 mb-3`, `prose-h3:mt-6 mb-2`  → the rules above.
 *   ProductsPage (/tuotteet) — NO prose-p modifier: plain `prose prose-base`
 *     + `space-y-4`. Tailwind's space-y sets `margin-top: 16px; margin-bottom: 0`
 *     on every child but the FIRST, which keeps the typography plugin's
 *     `p { margin-bottom: 1.25em }` (20px). So the design measures 20px under
 *     the first paragraph (collapse(20,16)) and 16px everywhere else — including
 *     h3→p, where the plugin's 0.6em also collapses into the 16.
 *
 * WP was rendering the category system's 12px on both, so /tuotteet's long-form
 * ran ~40px short over 11 blocks (2 of the 4 flagged gaps are above the fade
 * cap, i.e. visible without expanding). Measured with the cap lifted on both
 * origins: design 20/16/16/16…, WP 12/16/12/12….
 *
 * Also restore `leading-relaxed` = 1.625 (26px) on the paragraphs: the wrapper
 * sets it, but theme.json's `p` element style (1.6 → 25.6px) wins on the
 * paragraphs themselves, costing 1–2px per block (systemic root-cause #1).
 * refs: tailwind-cheatsheet.md § "The typography plugin's `prose` geometry is
 *       part of the design"; pixel-parity-audit.md § Tolerances. */
.esteri-products-seo__prose--products > * + * { margin-top: 1rem; }
.esteri-products-seo__prose--products > * + p { margin-top: 1rem; }
.esteri-products-seo__prose--products > p:first-child { margin-bottom: 1.25rem; }

/* `leading-relaxed` (1.625 = 26px) on BOTH systems' paragraphs — the wrapper
 * declares it but theme.json's `p` element style (1.6) wins on the paragraphs
 * themselves (systemic root-cause #1). */
.esteri-products-seo__prose p { line-height: 1.625; }

/* The category/subcategory system's FIRST paragraph keeps its `prose-p:my-3`
 * top margin (12px): the prose wrapper is `overflow-hidden`, which opens a new
 * block-formatting context, so that margin can NOT collapse out through the
 * wrapper's top edge the way an ordinary first-child margin would. Together
 * with the h2's own mb-4 the design measures 28px there (probed 16+12); WP had
 * 24+0. /tuotteet's variant genuinely starts at 0 (probed) — space-y-4 zeroes
 * the first child's top margin — so it opts out below.
 * refs: tailwind-cheatsheet.md § "The typography plugin's `prose` geometry is
 *       part of the design"; gotchas.md § Layout (margin collapsing + BFC). */
.esteri-products-seo__prose > p:first-child { margin-top: 0.75rem; }
.esteri-products-seo__prose--products > p:first-child { margin-top: 0; }
/* …and it opts out again when there is no heading above it. The category bands
 * lost their derived "<label> ammattikäyttöön" h2 (2026-08-25, Saku: "if that
 * isnt in lue lisää section dont add it"), so the prose now starts the rail and
 * that 12px — designed as the second half of the h2's mb-4 pairing — would push
 * the copy to 52px under the band's 40px top padding. */
.esteri-products-seo__rail > .esteri-products-seo__prose:first-child > p:first-child { margin-top: 0; }

/* ═══════════════════════════════════════════════════════════════════
 * Product listing (esteri/product-grid block) — ProductCategoryPage
 * Mirrors source/src/pages/ProductCategoryPage.tsx: a subcategory
 * filter-chip row over an engineered spec-card grid. box-sizing on the
 * card subtree is load-bearing (canonical-patterns.md) so the h-72
 * media + padded body don't blow the row heights.
 * ═══════════════════════════════════════════════════════════════════ */

/* ─── Filter chip row ──────────────────────────────────────────────── */
.esteri-product-filter {
	border-bottom: 1px solid var(--wp--preset--color--border, hsl(220, 13%, 91%));
}
.esteri-product-filter__inner {
	padding-top: 1.25rem;    /* py-5 */
	padding-bottom: 1.25rem;
}
.esteri-product-filter__label {
	display: block;
	margin-bottom: 0.75rem;  /* mb-3 */
	line-height: 1.6;   /* source mono label inherits 24px @15px (wire-label's 1 gave 15) */
}
/* Mobile: horizontal-scroll chip rail (source -mx-5 overflow-x-auto). */
.esteri-product-filter__scroll { position: relative; }
.esteri-product-filter__row {
	display: flex;
	flex-wrap: wrap;
	gap: 0.5rem;
}
@media (max-width: 639px) {
	.esteri-product-filter__scroll {
		overflow-x: auto;
		scrollbar-width: none;
		-ms-overflow-style: none;
	}
	.esteri-product-filter__scroll::-webkit-scrollbar { display: none; }
	.esteri-product-filter__row { flex-wrap: nowrap; width: max-content; }
}
.esteri-chip-filter {
	flex-shrink: 0;
	/* Source: `px-4 py-2 sm:px-5 sm:py-2.5` — the mobile step-down was
	 * missing (ported desktop-only; caught by parity-diff @390). */
	padding: 0.5rem 1rem;
	/* Source filter button: `text-sm font-semibold` = 15px / 600, lh 1.5rem (24px)
	 * from the project text-sm override; font inherits Inter (a bare <button>
	 * otherwise falls back to the UA Arial). Was 14px / lh 1.2 / Arial → ~7px
	 * shorter than the source pill. */
	font-family: inherit;
	font-size: 0.9375rem;
	font-weight: 600;
	line-height: 1.6;
	border: 1px solid var(--wp--preset--color--border, hsl(220, 13%, 91%));
	background: var(--wp--preset--color--background, #fff);
	color: var(--wp--preset--color--foreground, hsl(220, 13%, 18%));
	border-radius: var(--radius, 4px);
	white-space: nowrap;
	cursor: pointer;
	transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.esteri-chip-filter:hover {
	/* Source ProductCategoryPage.tsx:115/129 inactive chip: `hover:bg-accent` ONLY — the
	 * label keeps text-foreground and the hairline keeps border-border (hover-sweep). */
	background: var(--wp--preset--color--accent, hsl(74, 87%, 40%));
}
.esteri-chip-filter.is-active {
	background: var(--wp--preset--color--primary, hsl(222, 68%, 33%));
	color: var(--wp--preset--color--primary-foreground, #fff);
	border-color: var(--wp--preset--color--primary, hsl(222, 68%, 33%));
}
@media (min-width: 640px) {
	.esteri-chip-filter { padding: 0.625rem 1.25rem; } /* sm:px-5 sm:py-2.5 */
}
/* Source chips render `Label (count)` as ONE run — same weight + colour
 * (ProductCategoryPage.tsx:118/133). No de-emphasis on the count. */
.esteri-chip-filter__count { opacity: 1; font-weight: inherit; }

/* ─── Listing head (count) ─────────────────────────────────────────── */
.esteri-product-listing__head {
	display: flex;
	align-items: center;
	justify-content: space-between;
	margin-bottom: 2rem;
}
.esteri-product-listing__count {
	font-family: var(--wp--preset--font-family--mono, "Space Mono", ui-monospace, monospace);
	font-size: 0.9375rem;
	line-height: 1.6;   /* source mono count inherits 24px @15px (was 1.55 → 23.25) */
	color: var(--wp--preset--color--muted-foreground, hsl(220, 9%, 46%));
}

/* ─── Grid ─────────────────────────────────────────────────────────── */
.esteri-product-listing__grid {
	display: grid;
	grid-template-columns: minmax(0, 1fr);
	gap: 1rem;
}
@media (min-width: 640px)  { .esteri-product-listing__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (min-width: 1024px) { .esteri-product-listing__grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (min-width: 1280px) { .esteri-product-listing__grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } }

/* ─── Card ─────────────────────────────────────────────────────────── */
.esteri-product-listing-card {
	position: relative;
	display: flex;
	flex-direction: column;
	height: 100%;
	border: 1px solid var(--wp--preset--color--border, hsl(220, 13%, 91%));
	background: var(--wp--preset--color--card, #fff);
	border-radius: var(--radius, 4px);
	overflow: hidden;
	color: inherit;
	text-decoration: none;
	box-sizing: border-box;
	transition: border-color 0.15s ease;
}
.esteri-product-listing-card *,
.esteri-product-listing-card *::before,
.esteri-product-listing-card *::after { box-sizing: border-box; }
.esteri-product-listing-card:hover {
	/* Source listing card (ProductSubcategoryPage.tsx line 195) is a plain
	 * `card-engineered` — border → primary on hover, plus the ::before left rail,
	 * accent sweep, glow and image zoom below. It does NOT translate or cast a
	 * drop shadow; those were invented here and made the listing grid "bounce"
	 * unlike Lovable. Remove them; keep the full-strength primary border. */
	border-color: var(--wp--preset--color--primary, hsl(222, 68%, 33%));
}

/* Engineered top accent line — green segment sweeps full width on hover. */
.esteri-product-listing-card__accent {
	position: absolute;
	top: 0;
	left: 0;
	width: 100%;
	height: 0.25rem;
	background: hsla(220, 13%, 91%, 0.6);
	z-index: 10;
	overflow: hidden;
	display: block;
}
.esteri-product-listing-card__accent-bar {
	display: block;
	width: 2.5rem;
	height: 100%;
	background: var(--wp--preset--color--accent, hsl(74, 87%, 40%));
	transition: width 0.5s ease;
}
.esteri-product-listing-card:hover .esteri-product-listing-card__accent-bar { width: 100%; }

/* Media area — taller light-surface tint (h-72). */
.esteri-product-listing-card__media {
	position: relative;
	height: 18rem; /* h-72 */
	flex-shrink: 0;
	overflow: hidden;
	display: flex;
	align-items: center;
	justify-content: center;
	padding: 1.5rem;
	background: linear-gradient(160deg, var(--wp--preset--color--background, #fff) 0%, var(--wp--preset--color--surface-tint, hsl(220, 20%, 97%)) 100%);
}
.esteri-product-listing-card__glow {
	position: absolute;
	inset: 0;
	opacity: 0;
	pointer-events: none;
	background: radial-gradient(circle at 50% 60%, hsla(222, 68%, 33%, 0.10) 0%, transparent 65%);
	transition: opacity 0.5s ease;
}
.esteri-product-listing-card:hover .esteri-product-listing-card__glow { opacity: 1; }
.esteri-product-listing-card__img {
	transition: transform 0.5s ease;
}
.esteri-product-listing-card:hover .esteri-product-listing-card__img { transform: scale(1.06); }
/* Real photo — contained + centered. */
.esteri-product-listing-card__img--photo {
	position: relative;
	max-width: 100%;
	max-height: 100%;
	height: auto;
	width: auto;
	object-fit: contain;
}
/* Placeholder SVG — full-bleed cover (source isProductPlaceholder branch). */
.esteri-product-listing-card__img--placeholder {
	position: absolute;
	inset: 0;
	width: 100%;
	height: 100%;
	object-fit: cover;
}

/* Made-in-Finland badge (no pesukoneet product flags it; correct for
 * the Finnish-made categories built later). */
.esteri-product-listing-card__finland {
	position: absolute;
	top: 0.75rem;
	right: 0.75rem;
	z-index: 11;
	display: inline-flex;
	align-items: center;
	gap: 0.375rem;
	font-family: var(--wp--preset--font-family--mono, "Space Mono", ui-monospace, monospace);
	font-size: 0.75rem;
	font-weight: 600;
	letter-spacing: 0.05em;
	color: var(--wp--preset--color--primary, hsl(222, 68%, 33%));
	background: var(--wp--preset--color--background, #fff);
	border: 2px solid var(--wp--preset--color--primary, hsl(222, 68%, 33%));
	padding: 0.25rem 0.625rem;
	border-radius: var(--radius, 4px);
	box-shadow: 0 1px 2px hsla(221, 68%, 18%, 0.08);
}
.esteri-product-listing-card__flag { flex: 0 0 auto; border-radius: 1px; }

/* Card body — spec list. */
.esteri-product-listing-card__body {
	position: relative;
	flex: 1 1 auto;
	display: flex;
	flex-direction: column;
	padding: 1rem; /* source p-4 (mobile) */
	background: linear-gradient(160deg, var(--wp--preset--color--muted, hsl(220, 20%, 97%)) 0%, var(--wp--preset--color--card, #fff) 100%);
}
@media (min-width: 640px) { .esteri-product-listing-card__body { padding: 1.25rem; } } /* sm:p-5 */
.esteri-product-listing-card__title {
	font-size: 1.125rem; /* source text-lg (mobile) = 18px */
	font-weight: 700;
	line-height: 1.75rem; /* text-lg paired lh = 28px */
	letter-spacing: -0.01em;
	color: var(--wp--preset--color--foreground, hsl(220, 13%, 18%));
	margin: 0 0 0.75rem;  /* mb-3 (mobile) */
	transition: color 0.2s ease;
}
@media (min-width: 640px) {
	.esteri-product-listing-card__title { font-size: 1rem; line-height: 1.5; margin: 0 0 1rem; } /* sm:text-base 16/24, mb-4 */
}
.esteri-product-listing-card:hover .esteri-product-listing-card__title {
	color: var(--wp--preset--color--primary-deep, hsl(222, 70%, 25%));
}
.esteri-product-listing-card__specs {
	margin: 0;
	border-top: 1px solid var(--wp--preset--color--border, hsl(220, 13%, 91%));
}
.esteri-product-listing-card__spec {
	padding: 0.375rem 0; /* source py-1.5 (mobile) = 6px */
}
@media (min-width: 640px) { .esteri-product-listing-card__spec { padding: 0.625rem 0; } } /* sm:py-2.5 */
.esteri-product-listing-card__spec + .esteri-product-listing-card__spec {
	border-top: 1px solid var(--wp--preset--color--border, hsl(220, 13%, 91%));
}
.esteri-product-listing-card__spec-label {
	font-size: 0.875rem; /* 14px */
	font-family: var(--wp--preset--font-family--mono, "Space Mono", ui-monospace, monospace);
	text-transform: uppercase;
	letter-spacing: 0.15em;
	color: var(--wp--preset--color--muted-foreground, hsl(220, 9%, 46%));
	margin: 0 0 0.25rem;  /* source mb-1 (mobile) = 4px */
	line-height: 1.25;
}
@media (min-width: 640px) { .esteri-product-listing-card__spec-label { margin: 0 0 0.125rem; } } /* sm:mb-0.5 */
.esteri-product-listing-card__spec-value {
	font-size: 0.875rem;
	font-family: var(--wp--preset--font-family--mono, "Space Mono", ui-monospace, monospace);
	font-weight: 700;
	color: var(--wp--preset--color--primary-deep, hsl(222, 70%, 25%));
	line-height: 1.25;
	margin: 0;
}

/* Empty state. Category variant: shown by JS only when a filter yields
 * zero (reset button). Subcategory variant: a static "browse all" link,
 * rendered server-side only when the subcategory has no products. */
.esteri-product-listing__empty {
	border: 1px solid var(--wp--preset--color--border, hsl(220, 13%, 91%));
	padding: 3rem;
	text-align: center;
}
.esteri-product-listing__empty-text {
	font-size: 0.9375rem;
	color: var(--wp--preset--color--muted-foreground, hsl(220, 9%, 46%));
	margin: 0 0 1rem;
}
.esteri-product-listing__empty-reset,
.esteri-product-listing__empty-link {
	font-family: var(--wp--preset--font-family--mono, "Space Mono", ui-monospace, monospace);
	font-size: 0.9375rem;
	text-decoration: underline;
	color: var(--wp--preset--color--foreground, hsl(220, 13%, 18%));
	background: none;
	border: 0;
	cursor: pointer;
}
/* The filter JS toggles the [hidden] attribute on non-matching cards
 * (and on the grid / empty state). The cards / grid carry an explicit
 * `display: flex`/`grid`, which has higher specificity than the UA
 * `[hidden] { display:none }` rule — so without this the attribute is
 * set but the element never visually hides. Idiomatic fix: re-assert
 * [hidden] inside the block with !important. */
.esteri-product-grid [hidden] { display: none !important; }

/* ═══════════════════════════════════════════════════════════════════════
 * ProductDetailPage (`esteri/product-detail`) — /tuotteet/tuote/:id.
 * Mirrors source/src/pages/ProductDetailPage.tsx. brand-green → accent,
 * brand-blue-deep → primary-deep. Carousels reuse .esteri-carousel* +
 * theme.js; this block adds the product-rail card widths + bespoke chrome.
 * ═════════════════════════════════════════════════════════════════════ */

/* Eyebrows: source uses `wire-label block`, which collapses the .wire-label
 * ::before dash (block-level pseudo ignores width) — so detail eyebrows show
 * NO dash. Match it (same call as the ContactPage eyebrows). */
.esteri-pd-section__eyebrow,
.esteri-pd-features__eyebrow,
.esteri-pd-related__eyebrow,
.esteri-pd-placeholder__eyebrow {
	display: block;
	margin-bottom: 0.75rem;
}
.esteri-pd-section__eyebrow::before,
.esteri-pd-features__eyebrow::before,
.esteri-pd-related__eyebrow::before,
.esteri-pd-placeholder__eyebrow::before { display: none; }
/* The 0.75rem above is the source's mb-3, which covers TUOTEKUVAUS /
 * OMINAISUUDET / "PALVELU KOKO ELINKAAREN AJAN". Two sections use mb-2 instead
 * (ProductDetailPage.tsx: TEKNISET TIEDOT, LADATTAVAT) — probed 8px on the
 * design vs 12px here. Per-section, because one shared eyebrow class cannot
 * carry three different source margins. */
.esteri-pd-specs .esteri-pd-section__eyebrow,
.esteri-pd-downloads .esteri-pd-section__eyebrow {
	margin-bottom: 0.5rem;
}

/* h2. prefix (0,1,1): the single-class margin TIES the global-styles flow
 * reset `:root :where(.is-layout-flow) > * { margin-block: 0 }` (0,1,0) and
 * loses on order in the EDITOR canvas — the h2 sat flush against the first
 * h3 there (Saku report 2026-07-30; same mask as theme-json.md § flow reset). */
h2.esteri-pd-section__h2 {
	font-size: clamp(1.875rem, 1.4rem + 1.8vw, 2.25rem);
	font-weight: 700;
	letter-spacing: -0.025em;
	color: var(--wp--preset--color--foreground);
	margin: 0 0 1.5rem;
}
/* The references + lifecycle section heads use `mb-3` (12px) in the source
 * (h2 sits tight above its lede), not the shared 1.5rem — ink-gap oracle
 * cont.72: wp 24/25 vs lv 12/13 on both. */
.esteri-pd-references .esteri-pd-section__h2 { margin-bottom: 0; }        /* lead's own mt 0.75rem = the 12px */
.esteri-pd-lifecycle .esteri-pd-section__h2 { margin-bottom: 0.75rem; }   /* lead has mt 0 */

/* ── Buttons ─────────────────────────────────────────────────────────── */
.esteri-pd-btn {
	display: inline-flex;
	align-items: center;
	gap: 0.5rem;
	padding: 14px 1.75rem;
	border-radius: var(--radius);
	font-weight: 600;
	font-size: 1rem;
	line-height: 1.2;
	text-decoration: none;
	cursor: pointer;
	border: 2px solid transparent;
	transition: background-color 0.2s, color 0.2s, border-color 0.2s, box-shadow 0.2s;
}
.esteri-pd-btn .esteri-pd-icon { width: 16px; height: 16px; }
.esteri-pd-btn--primary {
	background: var(--wp--preset--color--accent);
	color: var(--wp--preset--color--foreground);
}
.esteri-pd-btn--primary:hover {
	background: var(--wp--preset--color--background);
	color: var(--wp--preset--color--foreground);
	box-shadow: inset 0 0 0 2px var(--wp--preset--color--foreground);
}
/* Source hero CTA text is brand-blue-deep on the green fill, lh 24 (not the
 * shared pd-btn foreground/1.2). */
.esteri-pd-hero__cta {
	color: var(--wp--preset--color--primary-deep);
	line-height: 1.5;
}
/* Hero CTA hovers to brand blue + white (source override). */
.esteri-pd-hero__cta:hover {
	background: var(--wp--preset--color--primary);
	color: #fff;
	box-shadow: none;
}
/* Source: `.btn-primary svg { transition: transform 200ms ease-out }` + `:hover svg { translateX(3px) }`
 * (index.css L366-379) — the hero CTA is a .btn-primary, so its <ArrowRight> nudges too. */
.esteri-pd-hero__cta svg { transition: transform 200ms ease-out; }
.esteri-pd-hero__cta:hover svg { transform: translateX(3px); }
.esteri-pd-btn--outline {
	background: transparent;
	color: var(--wp--preset--color--foreground);
	border-color: var(--wp--preset--color--foreground);
}
.esteri-pd-btn--outline:hover {
	background: var(--wp--preset--color--foreground);
	color: var(--wp--preset--color--background);
}

/* ── Sticky bottom CTA bar (sticky-bottom-cta.md) ────────────────────── */
.esteri-pd-sticky {
	position: fixed;
	left: 0; right: 0; bottom: 0;
	z-index: 50;
	background: var(--wp--preset--color--accent);
	border-top: 1px solid rgba(21, 27, 40, 0.1);
	box-shadow: 0 -4px 20px -8px rgba(0, 0, 0, 0.25);
	transform: translateY(100%);
	opacity: 0;
	pointer-events: none;
	/* visibility:hidden removes the bar's <a> from the tab order while
	 * hidden — without it the off-screen link is still focusable and axe
	 * flags aria-hidden-focus (the bar carries aria-hidden="true" at rest).
	 * Delay the hide so the slide-down transition still plays. */
	visibility: hidden;
	transition: transform 0.3s ease, opacity 0.3s ease, visibility 0s linear 0.3s;
}
.esteri-pd-sticky.is-visible {
	transform: translateY(0);
	opacity: 1;
	pointer-events: auto;
	visibility: visible;
	transition: transform 0.3s ease, opacity 0.3s ease, visibility 0s;
}
.esteri-pd-sticky__inner {
	display: flex;
	align-items: center;
	justify-content: space-between;
	gap: 1rem;
	height: 72px;
}
.esteri-pd-sticky__label { display: flex; flex-direction: column; min-width: 0; line-height: 1.2; }
.esteri-pd-sticky__eyebrow {
	font-family: var(--wp--preset--font-family--mono);
	font-size: 0.9375rem;
	text-transform: uppercase;
	letter-spacing: 0.18em;
	color: rgba(21, 27, 40, 0.6);
}
.esteri-pd-sticky__name {
	font-weight: 700;
	letter-spacing: -0.01em;
	color: var(--wp--preset--color--foreground);
	overflow: hidden;
	text-overflow: ellipsis;
	white-space: nowrap;
}
.esteri-pd-sticky__btn {
	display: inline-flex;
	align-items: center;
	gap: 0.5rem;
	flex-shrink: 0;
	height: 40px;
	padding: 0 1.25rem;
	border-radius: var(--radius);
	background: var(--wp--preset--color--foreground);
	color: var(--wp--preset--color--background);
	font-size: 0.9375rem;
	line-height: 24px;   /* source ProductDetailPage sticky bar `h-10 … text-sm font-semibold` pair */
	font-weight: 600;
	text-decoration: none;
	transition: background-color 0.2s;
}
.esteri-pd-sticky__btn:hover { background: var(--wp--preset--color--primary-deep); }
.esteri-pd-sticky__btn .esteri-pd-icon { width: 14px; height: 14px; }

/* ── Hero (gallery + info) ───────────────────────────────────────────── */
.esteri-pd-hero {
	background: var(--wp--preset--color--secondary);
	border-bottom: 1px solid var(--wp--preset--color--border);
	/* Source hero is `py-12 lg:py-20` (48px → 80px); the bare .wire-section
	 * default is 5rem everywhere, which left an oversized empty gap above the
	 * gallery image at mobile (H6). Step it to match. */
	padding-block: 3rem;
}
@media (min-width: 1024px) {
	.esteri-pd-hero { padding-block: 5rem; }
}
.esteri-pd-hero__grid {
	display: grid;
	grid-template-columns: 1fr;
	gap: 2.5rem;
	align-items: start;
}
/* Grid items default to min-width:auto, so the gallery refuses to shrink below
 * the product image's intrinsic width and overruns the 1fr track at 390 (the
 * image rendered 444px wide on a 390 vp → ~95px horizontal overflow). min-width:0
 * lets the track cap at the container so the w-full/h-auto image scales to fit. */
.esteri-pd-hero__grid > * { min-width: 0; }
@media (min-width: 1024px) {
	.esteri-pd-hero__grid { grid-template-columns: 1fr 1fr; gap: 4rem; }
	.esteri-pd-hero__info { position: sticky; top: 6rem; }
}
/* Editor view of the product gallery: esteri/product-gallery is InnerBlocks of
   core/image (front-end render.php builds the main+thumb carousel from the child
   image IDs; the editor canvas instead shows the raw editable images so the
   customer can add / remove / reorder / REPLACE each one). Give that editor
   canvas a tidy wrapped thumbnail row so every image is visible and
   click-to-replace, instead of stacked full-size raw images that read as
   "broken". Editor-only (.esteri-pd-gallery--editor, set in edit()); the live
   .esteri-pd-gallery__main/__thumbs carousel chrome below is unaffected. */
.esteri-pd-gallery--editor {
	display: flex;
	flex-wrap: wrap;
	gap: 0.75rem;
	align-items: flex-start;
}
.esteri-pd-gallery--editor > * { margin: 0; }
/* Editor view mirrors the LIVE gallery (Saku, cont.57: "product editing view
   like the live page"): the FIRST image is the big "main" preview on its own
   row (front-end .esteri-pd-gallery__main), the rest wrap as the thumbnail strip
   below (front-end .esteri-pd-gallery__thumbs). Every image stays click-to-
   replace (InnerBlocks). The click-to-switch is a front-end behaviour; the
   editor shows the same composition. `> *:first-child` is robust to the editor's
   per-block wrapping; the trailing InnerBlocks appender is normalised below. */
.esteri-pd-gallery--editor > *:first-child {
	flex: 1 1 100%;
	width: 100%;
	max-width: 100%;
}
/* Sizes copied from the LIVE rules below, not invented (Saku, cont.82i: "images in
   image carousel are different size in editing and live page"). The editor used a
   22rem-capped `object-fit: contain` main (measured 666×352) and 7.5rem square thumbs
   (120×120) against live's natural-aspect main (640×820) and 5rem thumb frames
   (80×91) — so the same gallery read as a different composition on each side.
     main  → .esteri-pd-gallery__main-img: width auto / max-width 100% / height auto,
             centred. Same no-upscale behaviour, which matters for the 38 products
             whose best original is under 600px.
     thumb → .esteri-pd-gallery__thumb-frame: 4rem, 5rem from 768px up, object-fit
             contain on the img.
   The one composition difference that stays: live ALSO repeats the main image as
   thumb #1 (it is a click-to-switch carousel); the editor shows each image once,
   because each one is a block you edit. */
.esteri-pd-gallery--editor > *:first-child img {
	display: block;
	width: auto;
	max-width: 100%;
	height: auto;
	margin-inline: auto;
	background: var(--wp--preset--color--surface-tint, #f5f7fa);
	border: 1px solid var(--wp--preset--color--border, #e2e6ec);
	border-radius: 4px;
}
.esteri-pd-gallery--editor > *:not(:first-child) {
	flex: 0 0 auto;
	width: 4rem;
}
.esteri-pd-gallery--editor > *:not(:first-child) img {
	width: 100%;
	height: 4rem;
	object-fit: contain;
	background: var(--wp--preset--color--background);
	border: 1px solid var(--wp--preset--color--border, #e2e6ec);
	border-radius: 4px;
}
@media (min-width: 768px) {
	.esteri-pd-gallery--editor > *:not(:first-child) { width: 5rem; }
	.esteri-pd-gallery--editor > *:not(:first-child) img { height: 5rem; }
}
/* The InnerBlocks "+" appender shouldn't be sized as a thumbnail — and it must
   flow with the thumbs, not float: core's horizontal-list CSS absolutely
   positions the appender at the layout's bottom-right (measured position:
   absolute, right:0, bottom:0), which parked the "Lisää kuva" button ON TOP of
   the main image's corner (Saku, 2026-08-12: "lisää kuva button ... overlaps
   with image"). `static` makes it a normal wrapped flex item after the last
   thumbnail; !important because core's rule outranks this class chain. */
.esteri-pd-gallery--editor > .block-list-appender {
	position: static !important;
	width: auto;
	flex: 0 0 auto;
}
/* Product image size is NOT editable in the editor (Saku, cont.57): hide the
   drag-resize handles on gallery images — the size is fixed to the gallery frame
   (main / 7.5rem thumb). Editor-only (.components-resizable-box__handle exists
   only in the editor). image-in-fixed-frame.md § 4. */
.esteri-pd-gallery--editor .components-resizable-box__handle { display: none !important; }
/* The click-to-enlarge rules that used to live here are GONE (Saku, cont.82i:
   "just make it when i add new image it goes under the pääimage"). They pulled the
   SELECTED image into the big slot with `order: -1`, which meant the visual main
   followed SELECTION rather than ORDER — so a newly inserted image (selected on
   insert) jumped to the top and read as the main image, and no author could tell
   which one the live page actually uses. Now position alone decides: image 1 is the
   main, every later one is a thumbnail under it, exactly as render.php builds it.
   That also removes the need for the on-canvas "pääkuva" labels this session briefly
   added — the layout says it. Selecting a thumbnail still highlights it in place for
   replace/remove. */
.esteri-pd-gallery--editor > .is-selected {
	outline: 2px solid var(--wp--preset--color--primary, #1b3d8d);
	outline-offset: 2px;
}
.esteri-pd-gallery__main { overflow: hidden; margin-bottom: 0.75rem; }
/* NEVER upscale the main image past its natural size: 38 real products ship
 * sources under 600px (13 under 400 — e.g. VRS-7730's best original anywhere,
 * esteri.com included, is 300×300), and the old width:100% stretched those
 * ~2.3× into a blur (Saku report 2026-07-30). width:auto + max-width caps
 * large images at the column exactly as before; undersized originals render
 * SHARP at natural size, centered — esteri.com's own presentation for them.
 * The real fix for those 38 is better photography from Esteri (content task,
 * ledgered with the features-copy gap). */
.esteri-pd-gallery__main-img {
	display: block;
	width: auto;
	max-width: 100%;
	height: auto;
	margin-inline: auto;
	transition: transform 0.5s;
}
.esteri-pd-gallery__main:hover .esteri-pd-gallery__main-img { transform: scale(1.02); }
.esteri-pd-gallery__thumbs { display: flex; flex-wrap: wrap; gap: 0.75rem; }
.esteri-pd-gallery__thumb {
	display: flex;
	flex-direction: column;
	align-items: center;
	gap: 0.5rem;
	background: none;
	border: 0;
	padding: 0;
	cursor: pointer;
}
.esteri-pd-gallery__thumb-frame {
	display: flex;
	align-items: center;
	justify-content: center;
	width: 4rem;
	height: 4rem;
	overflow: hidden;
	background: var(--wp--preset--color--background);
	opacity: 0.7;
	transition: opacity 0.2s;
}
@media (min-width: 768px) {
	.esteri-pd-gallery__thumb-frame { width: 5rem; height: 5rem; }
}
.esteri-pd-gallery__thumb-frame img { width: 100%; height: 100%; object-fit: contain; }
.esteri-pd-gallery__thumb:hover .esteri-pd-gallery__thumb-frame,
.esteri-pd-gallery__thumb.is-active .esteri-pd-gallery__thumb-frame { opacity: 1; }
.esteri-pd-gallery__thumb-bar {
	display: block;
	width: 100%;
	height: 3px;
	background: transparent;
	transition: background-color 0.2s;
}
.esteri-pd-gallery__thumb.is-active .esteri-pd-gallery__thumb-bar { background: var(--wp--preset--color--accent); }

.esteri-pd-hero__title {
	font-size: 2.25rem;   /* text-4xl */
	font-weight: 700;
	letter-spacing: -0.035em;
	line-height: 1;   /* source computes 52px @52px (a base h1 rule overrides leading-[1.05]) */
	color: var(--wp--preset--color--foreground);
	margin: 0 0 1rem;
}
@media (min-width: 768px)  { .esteri-pd-hero__title { font-size: 3rem; } }      /* md:text-5xl */
@media (min-width: 1024px) { .esteri-pd-hero__title { font-size: 3.25rem; } }   /* lg:text-[3.25rem] */
.esteri-pd-hero__desc {
	font-size: 1rem;
	line-height: 1.65;
	color: var(--wp--preset--color--muted-foreground);
	margin: 0 0 2rem;
}
/* Multi-¶ descriptions: 1rem between consecutive ¶s (esteri.com rhythm:
   p{margin:0 0 1em} @15px); last ¶ keeps 2rem to keyspecs. Shrink the FIRST
   ¶'s bottom margin (no negative margins — the editor canvas zeroes block
   bottom margins, where a negative top margin renders as overlap). */
.esteri-pd-hero__desc:has(+ .esteri-pd-hero__desc) { margin-bottom: 1rem; }
.esteri-pd-keyspecs {
	display: grid;
	grid-template-columns: 1fr;
	gap: 1.25rem 1.5rem;
	border-top: 1px solid var(--wp--preset--color--border);
	padding-top: 1.5rem;
	margin-bottom: 2rem;
}
@media (min-width: 640px) { .esteri-pd-keyspecs { grid-template-columns: 1fr 1fr; } }
.esteri-pd-keyspec { position: relative; padding-left: 0.75rem; min-width: 0; }
.esteri-pd-keyspec::before {
	content: '';
	position: absolute;
	left: 0; top: 0.25rem; bottom: 0.25rem;
	width: 1px;
	background: color-mix(in srgb, var(--wp--preset--color--primary-deep) 40%, transparent);
}
.esteri-pd-keyspec__label {
	display: block;
	font-family: var(--wp--preset--font-family--mono);
	font-size: 0.9375rem;
	text-transform: uppercase;
	letter-spacing: 0.1em;
	color: var(--wp--preset--color--muted-foreground);
	margin-bottom: 0.375rem;
	overflow-wrap: anywhere;
}
.esteri-pd-keyspec__value {
	display: block;
	font-family: var(--wp--preset--font-family--mono);
	font-size: 0.9375rem;
	font-weight: 700;
	line-height: 1.375;   /* source leading-snug = 20.625px @15px (was 1.3 → 19.5) */
	color: var(--wp--preset--color--primary-deep);
	overflow-wrap: anywhere;
}
/* Editor: since cont.88 each key spec is its own esteri/product-keyspec CHILD
   block in the SAME styled grid (the child's blockProps div carries
   .esteri-pd-keyspec, so the base rules above style it unchanged). Delete/move
   are the shared canvas-reorder chrome + native movers — the cont.82m per-row
   "Poista" button and its right gutter are gone with the meta-grid editor.
   The appender button spans the grid like the old add button did. */
.esteri-pd-keyspecs--editor .esteri-pd-keyspec__label,
.esteri-pd-keyspecs--editor .esteri-pd-keyspec__value { margin-top: 0; }
.esteri-pd-keyspecs--editor > .esteri-add-child,
.esteri-pd-keyspecs--editor > .block-list-appender { grid-column: 1 / -1; justify-self: start; }
/* …but core gives `.block-list-appender` `position: absolute; right: 0; bottom: 0`,
   which lifts the "+ Lisää ominaisuus" button OUT of the grid (the grid-column
   above then no-ops) and parks it on the layout's lower-right corner — i.e. ON the
   last spec box whenever the box count is EVEN. Measured 2026-09-24: #5429 (EN,
   4 boxes) appender 1420,596 158×48 overlapping the 4th box by 7584 px²; #923 (FI,
   5 boxes) landed in the empty 6th cell, 0 overlap — which is why it "only broke
   on the English product" (Saku: "lisää ominaisuus button is in wrong place").
   `static` puts it back in flow as the grid's last, full-width item; !important
   because core's rule outranks this class chain. Same fix as
   .esteri-pd-gallery--editor above. on-canvas-repeater-affordances.md § 4. */
.esteri-pd-keyspecs--editor > .block-list-appender { position: static !important; width: auto; }
/* Source CTA wrapper: `flex flex-col sm:flex-row gap-3` — below 640 the
 * column flex stretches the button full-width; row+auto from 640 up. */
.esteri-pd-hero__actions { display: flex; flex-direction: column; gap: 0.75rem; }
@media (min-width: 640px) { .esteri-pd-hero__actions { flex-direction: row; flex-wrap: wrap; } }
.esteri-pd-hero__actions .esteri-pd-btn { justify-content: center; }
@media (min-width: 640px) { .esteri-pd-hero__actions .esteri-pd-btn { justify-content: flex-start; } }

/* ── Features — one-at-a-time slider ─────────────────────────────────── */
.esteri-pd-features {
	border-bottom: 1px solid var(--wp--preset--color--border);
	padding-block: clamp(3rem, 6vw, 5rem);
	background: linear-gradient(180deg, var(--wp--preset--color--surface-deep) 0%, var(--wp--preset--color--primary-deep) 100%);
}
/* `p.` form alongside the bare class — this eyebrow is a CONTENT paragraph
 * (`<p class="wire-label esteri-pd-features__eyebrow">`), so when the base
 * wire-label rule gained its `p.wire-label` (0,1,1) form (2026-08-11 line-height
 * tie fix) this single-class white override lost to the base blue on the dark
 * band. DB-swept 2026-08-12: the only p+wire-label class combo with a
 * single-class color override — every other eyebrow is a span or sets no color. */
.esteri-pd-features__eyebrow,
p.esteri-pd-features__eyebrow { color: rgba(255, 255, 255, 0.7); }
.esteri-pd-features__h2 {
	font-size: clamp(1.875rem, 1.4rem + 1.8vw, 2.25rem);
	font-weight: 700;
	letter-spacing: -0.025em;
	color: #fff;
	margin: 0 0 3rem;
}
.esteri-pd-feature {
	display: flex;
	flex-direction: column;
	overflow: hidden;
	border-radius: var(--radius);
}
/* The slider shows one slide at a time via the [hidden] attribute. The
 * class-level display:flex (0,1,0) beats the UA [hidden]{display:none}
 * (0,0,1), so re-assert it — without this all four slides stack and the
 * section renders ~4× too tall (the display:flex-vs-[hidden] trap). */
.esteri-pd-feature[hidden] { display: none !important; }
@media (min-width: 1024px) { .esteri-pd-feature { flex-direction: row; } }
.esteri-pd-feature__media {
	overflow: hidden;
	background: var(--wp--preset--color--secondary);
	height: 18rem;
}
/* DOCUMENTED DIVERGENCE from the design — `min-height`, not the source's fixed
 * `height` (cont.87). The design is `lg:h-[420px]` (ProductDetailPage.tsx L343)
 * and we ported it verbatim, which is correct until the text panel beside it
 * needs more than 420 px. Then the row grows to the TEXT's height while the
 * media stays pinned at 420, and the image column ends in a visible step above
 * the bottom of the white card — measured at 1024, where the panel is only
 * 472 px wide: a 300-char description already overflows by 17 px and the
 * longest shipped one (427 ch, KK 180 FC "Ominaisuudet") by 96 px.
 *
 * Same root cause as the `object-fit` divergence below: the design's demo copy
 * was short, so the fixed height never had to stretch, and the assumption
 * expired against Esteri's real Kuvaus prose. `min-height` keeps the 420 px
 * floor — every slide that fits today is pixel-identical — and lets the flex
 * row's default `align-items: stretch` size the media to the content when it
 * does not. The EDITOR mirror already did this (grid `min-height: 420px` +
 * the image spanning `grid-row: 1 / -1`), so this also ends a front-end/editor
 * split.
 *
 * `height: auto` is load-bearing: the base rule above sets `height: 18rem` for
 * the stacked mobile layout, and a flex item with ANY definite height ignores
 * `align-items: stretch`. Leaving it in place makes `min-height` resolve to 420
 * and stretch nothing — the exact no-op this first shipped as.
 * refs: image-in-fixed-frame.md § 3a intent A. */
@media (min-width: 1024px) { .esteri-pd-feature__media { width: 50%; height: auto; min-height: 420px; } }
/* Source slider media (ProductDetailPage.tsx ~344): the img REMOUNTS on every
 * slide change (key={activeFeature}) with `animate-in fade-in` — and the
 * `duration-700` utility stretches BOTH the enter animation and the hover
 * transition to 0.7s (probed on the source: animation "0.7s enter",
 * transition "transform .7s cubic-bezier(.4,0,.2,1)", hover scale 1.04, plus
 * a static to-top-right brand-blue-deep/30 scrim). Our slides toggle via
 * [hidden] → display:none, and a display:none→shown flip RESTARTS CSS
 * animations, so the keyframe replays per slide change exactly like the
 * source's remount. */
.esteri-pd-feature__media { position: relative; }
/* core/image wraps the <img> in a <figure> that the design has no equivalent of
 * (ProductDetailPage.tsx ~343: the img is a DIRECT child of the
 * `h-72 lg:h-[420px] overflow-hidden` frame). The figure's own height is auto,
 * so the img's `height: 100%` below resolves against an indefinite containing
 * block and silently degrades to `auto` — a wide photo then stops SHORT of the
 * 420px frame and leaves a band of `--secondary` under it, a tall one overflows
 * top-anchored. Neither is the designed centre-cover crop, and because the
 * editor mirror styles `.wp-block-image` itself, the editor cropped correctly
 * while the front end did not (Saku 2026-08-13: "the ominaisuudet images are not
 * cropped the same in editor as in live"). Make the wrapper a pass-through so
 * the frame IS the img's containing block.
 *
 * cont.87 — that pass-through is now `position: absolute; inset: 0` rather than
 * `height: 100%`, because the media cell stopped having a fixed height (see the
 * min-height divergence above). An in-flow figure makes the cell's auto height
 * the IMAGE's intrinsic height: the frame then grows to whatever photo the
 * author picked — measured 497 px on a 472×496 image where the design says 420 —
 * which throws away the designed frame entirely and, with `object-fit: contain`,
 * means the letterbox never appears because the box always matches the picture.
 * Taking the figure out of flow is image-in-fixed-frame.md § 3a intent A
 * verbatim ("position-absolute removes the img from in-flow sizing; the cell's
 * height is determined by sibling cells or by min-height"), and it is what the
 * editor mirror already does. The img keeps `width/height: 100%`, so its element
 * box is still exactly the frame. */
.esteri-pd-feature__media > figure.wp-block-image { position: absolute; inset: 0; margin: 0; }
@keyframes esteri-pd-feature-enter { from { opacity: 0; } }
/* DOCUMENTED DIVERGENCE from the design — `contain`, not the source's `cover`
 * (Saku, cont.87: "the ominaisuus images ... are too cropped"; "for the images
 * just so it looks good").
 *
 * The design's `object-cover` is correct FOR THE DESIGN: Lovable's demo slides
 * were all landscape, so cover cropped nothing worth keeping. Esteri's real
 * Kuvaus pictures are 4:3, square and portrait, and 475 of the 795 image slides
 * show the PRODUCT PHOTO — tall white studio cutouts. Retention in this 754×420
 * (1.79:1) frame is pure geometry: kept = r/R for r < R, R/r for r > R. Measured
 * over every live slide, cover keeps <45 % on 437 of 795 and <60 % on 537.
 * Fohn61.jpg is 0.37:1 → 20 %: a horizontal stripe of a white cabinet door,
 * with the product unrecognisable. That is image-in-fixed-frame.md § 5's
 * exclusion — the image IS the content here (each slide's picture illustrates
 * that slide's specific claim), so cropping destroys the information the reader
 * came for; § 4b is the same failure reported from the customer's side.
 *
 * A hybrid (cover inside aspect [1.35, 2.40], contain outside) was measured and
 * REJECTED: it splits 89 of the 123 products' own carousels between full-bleed
 * and letterboxed slides, and NO product is all-cover — so it buys design parity
 * on 148 slides at the cost of the frame changing shape mid-carousel on 72 % of
 * products. A taller 754×560 frame was rejected too: it only lifts a 0.75:1
 * portrait from 42 % to 56 % and drops every true landscape from 100 % to 75 %.
 *
 * Kept deliberately: `width/height: 100%`, so the img's ELEMENT BOX is still the
 * full frame on both sides (image-in-fixed-frame.md § 3a intent C's square-
 * collapse trap needs a flex + max-height frame; this one has definite sizes, so
 * no `width:auto/height:auto` is required and the parity gates' rendered-size
 * diff is unchanged — only which pixels paint). The letterbox bars are the
 * frame's own `--secondary`. Contain never scales an image MORE than cover did,
 * so no image upscales further than it already does today.
 * refs: image-in-fixed-frame.md § 3a intent C + § 5 + § 4b. */
.esteri-pd-feature__media img {
	width: 100%;
	height: 100%;
	object-fit: contain;
	animation: esteri-pd-feature-enter 0.7s ease;
	transition: transform 0.7s cubic-bezier(0.4, 0, 0.2, 1);
}
.esteri-pd-feature__media:hover img { transform: scale(1.04); }
/* Imageless slide (no runtime fallback — Saku 2026-07-30): the media half is
 * omitted server-side and the text panel takes the full row. */
@media (min-width: 1024px) {
	.esteri-pd-feature--textonly .esteri-pd-feature__text { width: 100%; }
}
.esteri-pd-feature__media::after {
	content: '';
	position: absolute;
	inset: 0;
	pointer-events: none;
	background: linear-gradient(to top right, rgba(16, 37, 86, 0.3), transparent, transparent);
}
.esteri-pd-feature__text {
	display: flex;
	flex-direction: column;
	justify-content: center;
	padding: 1.25rem;
	background: var(--wp--preset--color--card);
}
@media (min-width: 640px) { .esteri-pd-feature__text { padding: 2rem; } }
@media (min-width: 1024px) { .esteri-pd-feature__text { width: 50%; padding: 3rem; } }
.esteri-pd-feature__count {
	display: block;
	font-family: var(--wp--preset--font-family--mono);
	font-size: 0.9375rem;
	line-height: 1.6;   /* source mono count inherits 24px @15px (was 1.55 → 23.25) */
	color: var(--wp--preset--color--muted-foreground);
	margin-bottom: 1rem;
}
.esteri-pd-feature__title {
	font-size: 1.25rem;   /* source text-xl (base) = 20px */
	font-weight: 700;
	letter-spacing: -0.02em;
	line-height: 1.75rem; /* text-xl paired lh = 28px (was clamp + 1.2 → 28.8) */
	color: var(--wp--preset--color--foreground);
	margin: 0 0 1rem;
}
@media (min-width: 1024px) {
	.esteri-pd-feature__title { font-size: 1.5rem; line-height: 2rem; }  /* lg:text-2xl → 24/32px */
}
.esteri-pd-feature__desc {
	font-size: 1rem;
	line-height: 1.65;
	color: var(--wp--preset--color--muted-foreground);
	margin: 0 0 1.5rem;
}
.esteri-pd-feature__controls { display: flex; align-items: center; gap: 1rem; margin-top: auto; }
.esteri-pd-feature__btn {
	display: flex;
	align-items: center;
	justify-content: center;
	width: 2.75rem;
	height: 2.75rem;
	border: 2px solid var(--wp--preset--color--primary);
	border-radius: var(--radius);
	background: transparent;
	color: var(--wp--preset--color--primary);
	cursor: pointer;
	transition: background-color 0.15s cubic-bezier(0.4, 0, 0.2, 1), color 0.15s cubic-bezier(0.4, 0, 0.2, 1);   /* source: transition-colors */
}
.esteri-pd-feature__btn:hover { background: var(--wp--preset--color--primary); color: #fff; }
.esteri-pd-feature__progress {
	flex: 1;
	max-width: 200px;
	height: 4px;
	background: var(--wp--preset--color--border);
	border-radius: 9999px;
	overflow: hidden;
}
.esteri-pd-feature__progress-fill { display: block; height: 100%; background: var(--wp--preset--color--accent); border-radius: 9999px; }

/* ── Editor mirror: features slider ──────────────────────────────────────
 * The editor slider renders each slide (esteri/product-feature edit) with the
 * SAME chrome as render.php — count + image + heading + paragraph + controls —
 * as a flat child list (InnerBlocks can't be split into __media/__text wrappers).
 * Lay it out as the LIVE card: image (media) left, text column right with the
 * count on top and the controls at the bottom (natural DOM order: count, image,
 * heading, paragraph, controls). The image spans all grid rows so the card is
 * exactly as tall as its text and the media fills it (object-fit cover). Only the
 * active slide shows — the parent toggles [hidden]. `--editor` exists only in the
 * editor; add_editor_style() scopes this under .editor-styles-wrapper, front end
 * untouched. refs: editor-must-mirror-live (memory); editor-block-registration.md
 * § edit()-DOM-matches-render-DOM. */
.esteri-pd-features__viewport--editor .esteri-pd-feature {
	display: flex;
	flex-direction: column;
	background: var(--wp--preset--color--card);
	border-radius: var(--radius);
	overflow: hidden;
}
.esteri-pd-features__viewport--editor .esteri-pd-feature > * { margin: 0; }
/* Image = media band. position/overflow so the <img> fills its box; a fixed
 * band height at mobile (live __media is 18rem) keeps it from imposing its
 * intrinsic size. */
.esteri-pd-features__viewport--editor .esteri-pd-feature > .wp-block-image {
	order: -1; position: relative; overflow: hidden; height: 18rem;
	/* Live's __media carries this background and it is now VISIBLE as the
	 * letterbox bars either side of a contained image — so the editor needs it
	 * too, or the same slide reads white here and grey on the front end. */
	background: var(--wp--preset--color--secondary);
}
/* `contain` mirrors the front-end frame decision above — see the DOCUMENTED
 * DIVERGENCE comment on `.esteri-pd-feature__media img`. refs: memory
 * editor-must-mirror-live. */
.esteri-pd-features__viewport--editor .esteri-pd-feature > .wp-block-image img { width: 100%; height: 100%; object-fit: contain; display: block; }
.esteri-pd-features__viewport--editor .esteri-pd-feature > :not(.wp-block-image) { padding-inline: 1.5rem; }
.esteri-pd-features__viewport--editor .esteri-pd-feature > .esteri-pd-feature__count { padding-top: 1.5rem; }
.esteri-pd-features__viewport--editor .esteri-pd-feature > .esteri-pd-feature__controls { padding-bottom: 1.5rem; margin-top: 1rem; }
@media (min-width: 1024px) {
	/* Live card: image left | text right, EQUAL fixed height (~420px), with the
	 * controls pinned to the bottom. Explicit rows (count / heading / flexible
	 * paragraph / controls) reproduce that; the paragraph's 1fr row absorbs the
	 * slack so the controls sit at the bottom with a gap above (margin-top:auto
	 * equivalent). The image spans all rows and fills via inset:0 so its intrinsic
	 * height can't blow the row up (that was the 698px-tall-row-1 bug). */
	.esteri-pd-features__viewport--editor .esteri-pd-feature {
		display: grid;
		grid-template-columns: 50% 50%;
		grid-template-rows: auto auto 1fr auto;
		/* Live is a hard 420px; match the number exactly so the media box has the
		 * same aspect (and therefore the same object-fit crop) at a given width.
		 * It stays min-height, not height, because an author typing long copy must
		 * keep seeing it — the front end clips, the editor must not. */
		min-height: 420px;
	}
	.esteri-pd-features__viewport--editor .esteri-pd-feature > .wp-block-image {
		grid-column: 1; grid-row: 1 / -1; height: 100%; min-height: 0;
	}
	.esteri-pd-features__viewport--editor .esteri-pd-feature > .wp-block-image img { position: absolute; inset: 0; }
	.esteri-pd-features__viewport--editor .esteri-pd-feature > :not(.wp-block-image) { grid-column: 2; padding-inline: 3rem; }
	.esteri-pd-features__viewport--editor .esteri-pd-feature > .esteri-pd-feature__count { grid-row: 1; padding-top: 3rem; }
	.esteri-pd-features__viewport--editor .esteri-pd-feature > h3 { grid-row: 2; }
	.esteri-pd-features__viewport--editor .esteri-pd-feature > p { grid-row: 3; }
	.esteri-pd-features__viewport--editor .esteri-pd-feature > .esteri-pd-feature__controls { grid-row: 4; align-self: end; margin-top: 0; padding-bottom: 3rem; }
}

/* ── Long description — collapse / expand ────────────────────────────── */
.esteri-pd-longdesc { max-width: 48rem; }
.esteri-pd-longdesc__clip {
	position: relative;
	max-height: 6rem;
	overflow: hidden;
	transition: max-height 0.3s ease;
}
.esteri-pd-longdesc.is-expanded .esteri-pd-longdesc__clip { max-height: 1000rem; }
.esteri-pd-longdesc__block + .esteri-pd-longdesc__block { margin-top: 2.5rem; }
/* The keyspec migration flattened the longdesc to sibling h3/p blocks (no
 * __block wrappers), which killed the rule above — re-express the source's
 * `space-y-10` section boundary on the flat shape: an h3 that follows a
 * paragraph starts a new section, 2.5rem above (ink-gap oracle cont.72:
 * wp 16 vs lv 46 between longdesc sections). */
.esteri-pd-longdesc__p + .esteri-pd-longdesc__h3 { margin-top: 2.5rem; }
.esteri-pd-longdesc__h3 {
	font-size: 1.125rem;
	font-weight: 700;
	letter-spacing: -0.01em;
	color: var(--wp--preset--color--foreground);
	margin: 0 0 0.75rem;
}
.esteri-pd-longdesc__p {
	font-size: 0.9375rem;
	line-height: 1.65;
	color: var(--wp--preset--color--muted-foreground);
	margin: 0 0 0.75rem;
}
.esteri-pd-longdesc__fade {
	position: absolute;
	left: 0; right: 0; bottom: 0;
	height: 4rem;
	pointer-events: none;
	background: linear-gradient(to top, var(--wp--preset--color--background), transparent);
}
.esteri-pd-longdesc.is-expanded .esteri-pd-longdesc__fade { display: none; }
.esteri-pd-longdesc__toggle {
	display: inline-flex;
	align-items: center;
	gap: 0.625rem;
	margin-top: 1.25rem;
	background: none;
	border: 0;
	padding: 0;
	cursor: pointer;
	font-size: 0.9375rem;
	font-weight: 600;
	line-height: 1.6;   /* source inherits 24px @15px (button UA default was 'normal') */
	color: var(--wp--preset--color--foreground);
	transition: color 0.15s cubic-bezier(0.4, 0, 0.2, 1);   /* source ProductDetailPage "Lue lisää": transition-colors */
}
.esteri-pd-longdesc__toggle:hover { color: var(--wp--preset--color--accent); }
.esteri-pd-longdesc__toggle-icon {
	display: inline-flex;
	align-items: center;
	justify-content: center;
	width: 1.5rem;
	height: 1.5rem;
	border: 2px solid currentColor;
	border-radius: 9999px;
	flex-shrink: 0;
}
.esteri-pd-longdesc__toggle-icon .esteri-pd-icon { width: 12px; height: 12px; stroke-width: 3; }

/* ── Technical specs (accordion) + downloads ─────────────────────────── */
.esteri-pd-specs-section { background: var(--wp--preset--color--card); }
.esteri-pd-specs-grid {
	display: grid;
	grid-template-columns: 1fr;
	gap: 2rem;
	align-items: start;
}
/* Grid children shrink below min-content so long spec values / download labels
 * wrap instead of pushing the column past the viewport (content-stress-sweep). */
.esteri-pd-specs-grid > *,
.esteri-pd-specs, .esteri-pd-downloads { min-width: 0; }
.esteri-pd-spec-row__key { overflow-wrap: anywhere; }
@media (min-width: 1024px) {
	.esteri-pd-specs-grid { grid-template-columns: minmax(0, 2fr) 1px minmax(0, 1fr); gap: 0; }
	.esteri-pd-specs { padding-right: 2.5rem; }
	.esteri-pd-downloads { position: sticky; top: 6rem; padding-left: 2.5rem; }
}
.esteri-pd-specs-divider { display: none; }
@media (min-width: 1024px) { .esteri-pd-specs-divider { display: block; background: var(--wp--preset--color--border); } }
/* The divider is a purely-visual 1px CSS line built as an empty core/group. In
   the editor an empty group renders a block appender / inserter that overflows
   the 1px column and reads as a stray "group selector" (Saku, cont.57: "tekniset
   tiedot has a group selector — remove it"). Hide that editor chrome so the
   divider stays just a line; the group is harmless + valid empty on the front end. */
.editor-styles-wrapper .esteri-pd-specs-divider { overflow: hidden; }
.editor-styles-wrapper .esteri-pd-specs-divider .block-list-appender,
.editor-styles-wrapper .esteri-pd-specs-divider .block-editor-inserter,
.editor-styles-wrapper .esteri-pd-specs-divider .block-editor-block-list__empty-block-inserter,
.editor-styles-wrapper .esteri-pd-specs-divider .block-editor-button-block-appender { display: none !important; }
.esteri-pd-specs__head {
	display: flex;
	flex-direction: column;
	align-items: flex-start;
	gap: 0.75rem;
	margin-bottom: 1.5rem;
}
@media (min-width: 640px) { .esteri-pd-specs__head { flex-direction: row; align-items: center; justify-content: space-between; } }
.esteri-pd-specs__head .esteri-pd-section__h2 { margin: 0; }
.esteri-pd-specs__toggle {
	display: inline-flex;
	align-items: center;
	gap: 0.5rem;
	padding: 0.5rem 0.875rem;
	border: 1px solid var(--wp--preset--color--border);
	background: var(--wp--preset--color--background);
	color: var(--wp--preset--color--foreground);
	font-family: var(--wp--preset--font-family--mono);
	font-size: 0.9375rem;
	line-height: 1.6;   /* source inherits 24px @15px (button UA default was 'normal') */
	text-transform: uppercase;
	letter-spacing: 0.08em;
	cursor: pointer;
	transition: background-color 0.15s, color 0.15s, border-color 0.15s;
}
/* Source ProductDetailPage.tsx:468-472 — closed: `hover:bg-accent hover:text-accent-foreground
 * hover:border-accent`; open (allOpen): `bg-accent text-accent-foreground border-accent` and
 * hovering the OPEN toggle inverts back: `hover:bg-background hover:text-foreground
 * hover:border-foreground/20`. The label colour is accent-foreground (brand navy), not foreground. */
.esteri-pd-specs__toggle:hover { background: var(--wp--preset--color--accent); color: var(--wp--preset--color--accent-foreground, hsl(222, 68%, 20%)); border-color: var(--wp--preset--color--accent); }
.esteri-pd-specs__toggle.is-open { background: var(--wp--preset--color--accent); color: var(--wp--preset--color--accent-foreground, hsl(222, 68%, 20%)); border-color: var(--wp--preset--color--accent); }
.esteri-pd-specs__toggle.is-open:hover { background: var(--wp--preset--color--background); color: var(--wp--preset--color--foreground); border-color: hsl(222 30% 12% / 0.2); }
.esteri-pd-specs__toggle .esteri-pd-icon { width: 12px; height: 12px; }
.esteri-pd-spec-group { border-bottom: 1px solid var(--wp--preset--color--border); }
.esteri-pd-spec-group__summary {
	list-style: none;
	cursor: pointer;
	padding: 0.75rem;
	font-family: var(--wp--preset--font-family--mono);
	font-size: 0.9375rem;
	/* NOT the text-sm pair's 24px: the source AccordionTrigger is `text-sm font-mono
	 * font-bold` but its label span adds `leading-snug` (ProductDetailPage.tsx:485),
	 * and a leading-* class overrides the token's pair — measured 20.625px live. */
	line-height: 1.375;
	font-weight: 700;
	/* Source AccordionTrigger sits inside a shadcn <h3> wrapper (16px) which
	 * inherits the design's global h1–h4 -0.02em → the label glyphs render at
	 * -0.32px. WP's summary has no h3 ancestor, so pin the LENGTH the design
	 * actually inherits (16px × -0.02em), not -0.02em at the summary's 15px. */
	letter-spacing: -0.32px;
	color: var(--wp--preset--color--foreground);
	border-left: 4px solid var(--wp--preset--color--border);
	transition: background-color 0.15s, border-color 0.15s;
	display: flex;
	align-items: center;
	justify-content: space-between;
	gap: 1rem;
}
.esteri-pd-spec-group__summary::-webkit-details-marker { display: none; }
.esteri-pd-spec-group__summary::after {
	/* Source: shadcn AccordionTrigger's lucide ChevronDown `h-4 w-4` —
	 * 16px STROKED chevron (stroke-width 2, round caps). The old 12px
	 * fill-path mask rendered as a tiny solid triangle. */
	content: '';
	flex-shrink: 0;
	width: 1rem; height: 1rem;
	background: currentColor;
	-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E") center / contain no-repeat;
	mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E") center / contain no-repeat;
	transition: transform 0.2s;
}
.esteri-pd-spec-group[open] > .esteri-pd-spec-group__summary::after { transform: rotate(180deg); }
/* Source AccordionTrigger: `hover:bg-surface data-[state=open]:bg-surface
 * data-[state=open]:border-l-brand-green` — open/hover are the LIGHT surface
 * tint with unchanged text, not a dark navy panel. */
.esteri-pd-spec-group__summary:hover,
.esteri-pd-spec-group[open] > .esteri-pd-spec-group__summary {
	background: var(--wp--preset--color--surface-tint);
	border-left-color: var(--wp--preset--color--accent);
}

/* ── Native-block product rebuild: bridges ──
 * The native rebuild replaces the SSR monolith with editable blocks. Two
 * structural deltas need a CSS bridge to stay pixel-identical:
 * 1. The tech-specs accordion is now native `core/details`, which renders a
 *    bare <summary> (no class — `summary` is a source:rich-text attr) inside
 *    `<details class="wp-block-details esteri-pd-spec-group">`. Mirror the
 *    bespoke .esteri-pd-spec-group__summary styling onto that direct <summary>.
 * 2. Key-spec + spec-row label/value were inline <span> in the monolith; as
 *    editable core/paragraph they are <p> with UA block margins — reset them. */
/* Source Accordion root carries `pr-5 sm:pr-0` — a 20px right inset below 640
 * that the hover bg/borders stop at, and that wraps long group labels one
 * line earlier on phones ("7860-P vedenpoisto, poistopumppu": 2 lines @390).
 * details.+2-class specificity: a `.esteri-pd-spec-group.wp-block-details
 * { margin: 0 }` shorthand otherwise zeroes this. */
@media (max-width: 639.98px) {
	details.esteri-pd-spec-group.wp-block-details {
		margin-right: 1.25rem;
	}
}
.esteri-pd-spec-group > summary {
	list-style: none;
	cursor: pointer;
	padding: 0.75rem;
	font-family: var(--wp--preset--font-family--mono);
	font-size: 0.9375rem;
	/* NOT the text-sm pair's 24px: the source AccordionTrigger is `text-sm font-mono
	 * font-bold` but its label span adds `leading-snug` (ProductDetailPage.tsx:485),
	 * and a leading-* class overrides the token's pair — measured 20.625px live. */
	line-height: 1.375;
	font-weight: 700;
	/* Source AccordionTrigger sits inside a shadcn <h3> wrapper (16px) which
	 * inherits the design's global h1–h4 -0.02em → the label glyphs render at
	 * -0.32px. WP's summary has no h3 ancestor, so pin the LENGTH the design
	 * actually inherits (16px × -0.02em), not -0.02em at the summary's 15px. */
	letter-spacing: -0.32px;
	color: var(--wp--preset--color--foreground);
	border-left: 4px solid var(--wp--preset--color--border);
	transition: background-color 0.15s, border-color 0.15s;
	display: flex;
	align-items: center;
	justify-content: space-between;
	gap: 1rem;
}
.esteri-pd-spec-group > summary::-webkit-details-marker { display: none; }
.esteri-pd-spec-group > summary::marker { content: ""; }
.esteri-pd-spec-group > summary::after {
	content: '';
	flex-shrink: 0;
	width: 1rem; height: 1rem;
	background: currentColor;
	-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E") center / contain no-repeat;
	mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E") center / contain no-repeat;
	transition: transform 0.2s;
}
.esteri-pd-spec-group[open] > summary::after { transform: rotate(180deg); }
.esteri-pd-spec-group > summary:hover,
.esteri-pd-spec-group[open] > summary {
	background: var(--wp--preset--color--surface-tint);
	border-left-color: var(--wp--preset--color--accent);
}
.esteri-pd-spec-group.wp-block-details { margin: 0; }
.esteri-pd-keyspec__label, .esteri-pd-keyspec__value,
.esteri-pd-spec-row__key, .esteri-pd-spec-row__value { margin: 0; }

.esteri-pd-spec-row {
	display: grid;
	grid-template-columns: 1fr;
	gap: 0.25rem;
	padding: 0.5rem 0.75rem;
	border-bottom: 1px solid var(--wp--preset--color--border);
	border-left: 4px solid var(--wp--preset--color--border);
	transition: background-color 0.15s, border-color 0.15s;
}
.esteri-pd-spec-row:last-child { border-bottom: 0; }
/* Source spec row: `hover:bg-surface hover:border-l-brand-green`; the key
 * brightens muted→foreground, the value keeps its colour. */
.esteri-pd-spec-row:hover { background: var(--wp--preset--color--surface-tint); border-left-color: var(--wp--preset--color--accent); }
.esteri-pd-spec-row:hover .esteri-pd-spec-row__key { color: var(--wp--preset--color--foreground); }
/* minmax(0,1fr), not 1fr (=minmax(auto,1fr)): a long spec value's min-content
 * would otherwise force the track — and the row — wider than the panel and clip
 * (content-stress-sweep @768). minmax(0,...) lets the track shrink so the value
 * wraps. */
@media (min-width: 640px) { .esteri-pd-spec-row { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 0.75rem; } }
.esteri-pd-spec-row__key { font-size: 0.9375rem; color: var(--wp--preset--color--muted-foreground); word-break: break-word; transition: color 0.15s; }
.esteri-pd-spec-row__value {
	font-family: var(--wp--preset--font-family--mono);
	font-size: 0.9375rem;
	font-weight: 700;
	color: var(--wp--preset--color--foreground);
	word-break: break-word;
}
@media (min-width: 640px) { .esteri-pd-spec-row__value { text-align: right; } }
.esteri-pd-downloads__h3 { font-size: 1.25rem; font-weight: 700; letter-spacing: -0.01em; color: var(--wp--preset--color--foreground); margin: 0 0 1.25rem; }
.esteri-pd-downloads__list { display: flex; flex-direction: column; gap: 0.625rem; }
/* EDITOR ONLY (`--editor` is set in edit(), never saved). Gutenberg gives
   `.block-list-appender` `position: absolute`, which lifts "Lisää tiedosto" out of
   the column and parks it over the last row's lower-right corner — measured on
   #703: row at 1233,2540 425×83, appender at 1544,2574 114×48, i.e. sitting ON the
   row. Saku, tasklist 2026-08-04: "move the lisää tiedosto under the items". Put it
   back in flow as the last item of the column; the 0.625rem is the list's own gap.
   The gallery's appender is deliberately left alone — its absolute slot lands to the
   right of the thumb strip, which is where it belongs there. */
.esteri-pd-downloads__list--editor > .block-list-appender {
	position: static;
	width: 100%;
}
.esteri-pd-downloads__list--editor > .block-list-appender > .esteri-add-child { margin: 0; }
.esteri-pd-download {
	display: flex;
	align-items: center;
	gap: 1rem;
	width: 100%;
	text-align: left;
	padding: 1rem;
	background: var(--wp--preset--color--background);
	border: 1px solid var(--wp--preset--color--border);
	border-left: 4px solid var(--wp--preset--color--border);
	cursor: pointer;
	/* The row renders as <a> when a real file exists — without this the
	 * global link style underlined the whole row (source rows are buttons,
	 * never underlined). */
	text-decoration: none;
	transition: background-color 0.15s, border-color 0.15s;
}
/* Source download row: `hover:bg-surface hover:border-muted-foreground/40
 * hover:border-l-brand-green`; text colours unchanged; the Download arrow
 * nudges right (`-translate-x-1` → 0) and turns brand-green. */
.esteri-pd-download:hover {
	background: var(--wp--preset--color--surface-tint);
	border-color: color-mix(in srgb, var(--wp--preset--color--muted-foreground) 40%, transparent);
	border-left-color: var(--wp--preset--color--accent);
}
.esteri-pd-download:hover .esteri-pd-download__dl { color: var(--wp--preset--color--accent); transform: translateX(0); }
.esteri-pd-download:hover .esteri-pd-download__icon { border-color: color-mix(in srgb, var(--wp--preset--color--accent) 40%, transparent); }
.esteri-pd-download__icon {
	display: flex;
	align-items: center;
	justify-content: center;
	width: 2.75rem; height: 2.75rem;
	flex-shrink: 0;
	/* Source markup says `bg-accent-soft`, but that utility doesn't exist in
	 * the design's Tailwind config (accent has only DEFAULT/foreground) — the
	 * design RENDERS the tile transparent. Match the rendering (same rule as
	 * the undefined hover-shadow precedent, cont.14 §1). */
	background: transparent;
	border: 1px solid var(--wp--preset--color--border);
	color: var(--wp--preset--color--primary-deep);
	transition: border-color 0.15s;
}
.esteri-pd-download__text { min-width: 0; flex: 1; }
.esteri-pd-download__label { display: block; font-size: 0.9375rem; font-weight: 700; line-height: 1.375; color: var(--wp--preset--color--foreground); overflow-wrap: anywhere; }
.esteri-pd-download__type {
	display: block;
	font-family: var(--wp--preset--font-family--mono);
	font-size: 0.9375rem;
	line-height: 1.6;   /* source inherits 24px @15px (was 'normal') */
	text-transform: uppercase;
	letter-spacing: 0.08em;
	color: var(--wp--preset--color--muted-foreground);
	margin-top: 0.25rem;
	/* Same net as the __label above: this line is author-editable RichText
	 * (typeLabel), and a long no-space token overflowed the row into the
	 * whole section (measured 209px clip at 1440, 267px at 390 — Saku,
	 * tasklist 2026-08-07). The EDITOR looked fine, which is how it shipped:
	 * the canvas-wide `overflow-wrap: anywhere` parity rule in functions.php
	 * covers every block in the editor but nothing on the front end. */
	overflow-wrap: anywhere;
}
.esteri-pd-download__dl { margin-left: auto; flex-shrink: 0; color: var(--wp--preset--color--muted-foreground); transform: translateX(-0.25rem); transition: transform 0.2s, color 0.2s; }
.esteri-pd-download__dl .esteri-pd-icon { width: 16px; height: 16px; }

/* ── Product card rails (Accessories + Related) ──────────────────────── */
.esteri-pd-accessories { background: var(--wp--preset--color--card); border-bottom: 1px solid var(--wp--preset--color--border); padding-block: 4rem; }
.esteri-pd-accessories__h2 { font-size: 1.5rem; font-family: var(--wp--preset--font-family--mono); font-weight: 700; color: var(--wp--preset--color--foreground); margin: 0 0 2rem; }
.esteri-pd-rail { container-type: inline-size; }
/* Source scroller carries mr-[calc(50%-50vw)] (RelatedProductsCarousel.tsx:153,
   AccessoriesCarousel.tsx:121): the scroll viewport extends from the rail's
   right edge to the BROWSER-viewport edge, so the next off-rail card "peeks"
   past the container at rest. Card sizing stays on the rail (cqw against
   .esteri-pd-rail), so the 4-up layout is untouched. */
.esteri-pd-rail__viewport { scroll-padding-left: 0; scroll-padding-right: 0; margin-right: calc(50% - 50vw); }
.esteri-pd-rail__track { min-width: max-content; padding-left: 0; padding-right: 0; }
.esteri-pd-rail__track > * { flex: 0 0 85cqw; } /* source [grid-auto-columns:85cqw] — was 80cqw (~18px narrow @390, caught by parity-diff STEP 4d) */
/* Card count follows the DESIGN's viewport breakpoints (Lovable AccessoriesCarousel /
   RelatedProductsCarousel: sm/lg/xl = 2/3/4-up), NOT the rail container width — a
   container query at 640 never trips at a 768 viewport because the rail is only ~608px
   wide there, leaving 1 card where the source shows 2. (@media + cqw width = faithful.) */
@media (min-width: 640px)  { .esteri-pd-rail__track > * { flex-basis: calc((100cqw - 1rem) / 2); } }
@media (min-width: 1024px) { .esteri-pd-rail__track > * { flex-basis: calc((100cqw - 2rem) / 3); } }
@media (min-width: 1280px) { .esteri-pd-rail__track > * { flex-basis: calc((100cqw - 3rem) / 4); } }
.esteri-pd-rail .esteri-carousel-nav { margin-top: 2rem; padding-left: 0; padding-right: 0; }

/* Accessory card */
.esteri-pd-acc-card,
.esteri-pd-rel-card {
	position: relative;
	display: flex;
	flex-direction: column;
	overflow: hidden;
	border-radius: var(--radius);
	border: 1px solid var(--wp--preset--color--border);
	text-decoration: none;
}
/* Source `.card-engineered::before` (index.css L427-446) — a 3px green LEFT bar
 * that wipes down (scaleY 0→1) on hover. It's the base engineered-tile behaviour
 * and is present on BOTH the accessory and related cards in the source; WP had
 * only the TOP accent bar, so the left wipe + the hover border-recolour below
 * were missing → the accessory hover read as "wrong" vs Lovable. On the related
 * card it sits under the existing 4px green left border (L4184) — harmless. */
/* Related card: NO inner wipe (2026-09-18, Saku: "the green bars glitch"). The
 * source stacks it INSIDE the card's 4px left border, which recolours in 150ms
 * while the wipe takes 250ms — on mouse-out the border is already grey while a
 * 3px green sliver still retracts beside it, a visible two-tone flicker. The 4px
 * border recolour IS the left treatment on this card; the accessory card keeps
 * the wipe (its border recolours to navy, so the two never fight). */
.esteri-pd-acc-card::before {
	content: '';
	position: absolute;
	left: 0; top: 0; bottom: 0;
	width: 3px;
	background: var(--wp--preset--color--accent);
	transform: scaleY(0);
	transform-origin: top;
	transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
	z-index: 2;
}
.esteri-pd-acc-card:hover::before { transform: scaleY(1); }
/* Accessory card border recolours gray→navy on hover (source `.card-engineered:hover
 * { border-color: hsl(var(--primary)) }`, index.css L437-439). The related card keeps
 * its 4px green left border (L4184) as its dominant left treatment. */
.esteri-pd-acc-card { transition: border-color 0.15s cubic-bezier(0.4, 0, 0.2, 1); }   /* source .card-engineered: transition-colors duration-150 */
.esteri-pd-acc-card:hover { border-color: var(--wp--preset--color--primary); }
.esteri-pd-acc-card__accent,
.esteri-pd-rel-card__accent {
	position: absolute;
	top: 0; left: 0; right: 0;
	height: 3px;
	background: var(--wp--preset--color--accent);
	transform: scaleX(0);
	transform-origin: left;
	transition: transform 0.3s;
	z-index: 2;
}
.esteri-pd-acc-card:hover .esteri-pd-acc-card__accent,
.esteri-pd-rel-card:hover .esteri-pd-rel-card__accent { transform: scaleX(1); }
.esteri-pd-acc-card__media {
	height: 18rem;
	overflow: hidden;
	display: flex;
	align-items: center;
	justify-content: center;
	position: relative;
	background: linear-gradient(160deg, var(--wp--preset--color--background) 0%, var(--wp--preset--color--card) 100%);
}
/* Source AccessoriesCarousel: real photo contained (never cropped), only the
   line-art placeholder covers the frame — same split as the rel/listing cards. */
.esteri-pd-acc-card__media img { transition: transform 0.5s; }
.esteri-pd-acc-card__img--photo { max-width: 100%; max-height: 100%; width: auto; height: auto; object-fit: contain; }
.esteri-pd-acc-card__img--placeholder { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.esteri-pd-acc-card:hover .esteri-pd-acc-card__media img { transform: scale(1.06); }
.esteri-pd-acc-card__body { padding: 1.25rem; background: linear-gradient(160deg, var(--wp--preset--color--secondary) 0%, var(--wp--preset--color--card) 100%); flex: 1; }
/* Source card h3 is `text-lg sm:text-base` (AccessoriesCarousel.tsx:86) — an
 * INVERSION: 18px/28px below 640 where the card is full-width, 16px/24px from
 * sm up where the rail packs multiple cards. WP had a flat 1rem. */
.esteri-pd-acc-card__title { font-size: 1.125rem; line-height: 1.75rem; font-weight: 700; letter-spacing: -0.01em; color: var(--wp--preset--color--foreground); margin: 0; transition: color 0.2s; }
@media (min-width: 640px) {
	.esteri-pd-acc-card__title { font-size: 1rem; line-height: 1.5rem; }
}
.esteri-pd-acc-card:hover .esteri-pd-acc-card__title { color: var(--wp--preset--color--primary-deep); }
.esteri-pd-acc-grid,
.esteri-pd-rel-grid {
	display: grid;
	grid-template-columns: repeat(1, minmax(0, 1fr));
	gap: 1rem;
}
@media (min-width: 640px)  { .esteri-pd-acc-grid, .esteri-pd-rel-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (min-width: 1024px) { .esteri-pd-acc-grid, .esteri-pd-rel-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (min-width: 1280px) { .esteri-pd-acc-grid, .esteri-pd-rel-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } }

/* ── References teaser — reuses .esteri-references-carousel / .esteri-ref-slide ── */
.esteri-pd-references { background: var(--wp--preset--color--background); }
.esteri-pd-references__head {
	display: flex;
	flex-direction: column;
	gap: 1rem;
	margin-bottom: 2.5rem;
}
@media (min-width: 768px) { .esteri-pd-references__head { flex-direction: row; align-items: flex-end; justify-content: space-between; } }
.esteri-pd-references__lead { font-size: 1rem; color: var(--wp--preset--color--muted-foreground); margin: 0.75rem 0 0; }
.esteri-pd-references__all {
	position: relative;
	display: none;
	align-items: center;
	gap: 0.5rem;
	align-self: flex-start;
	padding-bottom: 0.25rem;
	font-family: var(--wp--preset--font-family--mono);
	font-size: 0.9375rem;   /* source text-sm = 15px (was 14px); 0.18em ls → 2.7px */
	line-height: 1.6;       /* 24px, matches Lovable */
	text-transform: uppercase;
	letter-spacing: 0.18em;
	color: var(--wp--preset--color--foreground);
	text-decoration: none;
	white-space: nowrap;
	transition: color 0.3s;
}
@media (min-width: 768px) { .esteri-pd-references__all { display: inline-flex; } }
.esteri-pd-references__all::before,
.esteri-pd-references__all::after {
	content: '';
	position: absolute;
	left: 0; bottom: 0;
	height: 2px;
	width: 100%;
	transform-origin: left;
}
.esteri-pd-references__all::before { background: var(--wp--preset--color--accent); transform: scaleX(1); transition: transform 0.3s; }
.esteri-pd-references__all::after { background: var(--wp--preset--color--primary-deep); transform: scaleX(0); transition: transform 0.5s; }
.esteri-pd-references__all:hover { color: var(--wp--preset--color--primary-deep); }
.esteri-pd-references__all:hover::after { transform: scaleX(1); }
.esteri-pd-references__all .esteri-pd-icon { width: 16px; height: 16px; }

/* ── Related products — dark surface ─────────────────────────────────── */
.esteri-pd-related {
	background: linear-gradient(140deg, var(--wp--preset--color--surface-deep) 0%, var(--wp--preset--color--primary-deep) 55%, var(--wp--preset--color--primary) 100%);
	padding-block: clamp(3rem, 6vw, 5rem);
}
.esteri-pd-related__eyebrow { color: rgba(255, 255, 255, 0.7); }
.esteri-pd-related__eyebrow::before { display: none; }
.esteri-pd-related__h2 {
	font-size: clamp(1.875rem, 1.4rem + 1.8vw, 2.25rem);
	font-weight: 700;
	letter-spacing: -0.025em;
	color: #fff;
	margin: 0 0 2rem;
}
.esteri-pd-rel-card {
	border-left: 4px solid var(--wp--preset--color--border);
	transition: border-color 0.15s;
}
/* Source RelatedProductsCarousel.tsx:68 / ArticleDetailPage.tsx:218: `card-engineered … border-l-4
 * border-l-border hover:border-l-brand-green transition-colors duration-150`. `.card-engineered:hover`
 * (index.css L440) turns the frame brand-blue; the left utility keeps that edge green. */
.esteri-pd-rel-card { transition: border-color 0.15s cubic-bezier(0.4, 0, 0.2, 1); }
.esteri-pd-rel-card:hover { border-color: var(--wp--preset--color--primary); border-left-color: var(--wp--preset--color--accent); }
.esteri-pd-rel-card__media {
	height: 16rem;
	display: flex;
	align-items: center;
	justify-content: center;
	padding: 1.5rem;
	overflow: hidden;
	background: linear-gradient(160deg, var(--wp--preset--color--background) 0%, var(--wp--preset--color--card) 100%);
}
/* Solution-detail recommended products use a taller frame (source h-72 = 288px)
 * than the product-detail / article RelatedProductsCarousel (source h-64 = 256px,
 * the base above) — scope the taller height so the contained image matches. */
.esteri-sol-products .esteri-pd-rel-card__media { height: 18rem; }
.esteri-pd-rel-card__img { transition: transform 0.5s; }
.esteri-pd-rel-card__img--photo { max-width: 100%; max-height: 100%; width: auto; height: auto; object-fit: contain; }
.esteri-pd-rel-card__img--placeholder { width: 100%; height: 100%; object-fit: cover; }
.esteri-pd-rel-card:hover .esteri-pd-rel-card__img { transform: scale(1.06); }
.esteri-pd-rel-card__body { padding: 1.25rem; background: linear-gradient(160deg, var(--wp--preset--color--secondary) 0%, var(--wp--preset--color--card) 100%); flex: 1; }
.esteri-pd-rel-card__title { font-size: 1rem; font-weight: 700; line-height: 1.5; letter-spacing: -0.01em; color: var(--wp--preset--color--foreground); margin: 0 0 1rem; transition: color 0.2s; }
.esteri-pd-rel-card:hover .esteri-pd-rel-card__title { color: var(--wp--preset--color--primary-deep); }
.esteri-pd-rel-card__specs { margin: 0; border-top: 1px solid var(--wp--preset--color--border); }
.esteri-pd-rel-card__spec { padding: 0.375rem 0; border-bottom: 1px solid var(--wp--preset--color--border); }
@media (min-width: 640px) { .esteri-pd-rel-card__spec { padding-block: 0.625rem; } } /* source RelatedProductsCarousel: py-1.5 sm:py-2.5 (6px → 10px@640) */
.esteri-pd-rel-card__spec:last-child { border-bottom: 0; }
.esteri-pd-rel-card__spec-label {
	display: block;
	font-family: var(--wp--preset--font-family--mono);
	font-size: 0.875rem;
	text-transform: uppercase;
	letter-spacing: 0.15em;
	color: var(--wp--preset--color--muted-foreground);
	margin-bottom: 0.125rem;
	line-height: 1.25;   /* source 17.5px @14px (was 1.2 → 16.8px) */
}
.esteri-pd-rel-card__spec-value {
	font-family: var(--wp--preset--font-family--mono);
	font-size: 0.875rem;
	font-weight: 700;
	color: var(--wp--preset--color--primary-deep);
	line-height: 1.25;   /* source 17.5px @14px (was 1.2 → 16.8px) */
	margin: 0;
}
/* Light carousel nav on the dark related surface. */
.esteri-carousel-nav--light .esteri-carousel-nav__btn { border-color: #fff; color: #fff; }
.esteri-carousel-nav--light .esteri-carousel-nav__btn:hover { background: #fff; color: var(--wp--preset--color--primary); border-color: #fff; }
.esteri-carousel-nav--light .esteri-carousel-nav__btn:disabled:hover { background: transparent; color: #fff; border-color: #fff; }
.esteri-carousel-nav--light .esteri-carousel-nav__progress { background: rgba(255, 255, 255, 0.2); }

/* ── Lifecycle 3-up ──────────────────────────────────────────────────── */
.esteri-pd-lifecycle { background: var(--wp--preset--color--background); }
/* 1.625, not 1.65: the source lede is `leading-relaxed` (26px @16px, probed on
 * the design); 1.65 gave 26.4px and cost 0.4px per line. */
.esteri-pd-lifecycle__lead { font-size: 1rem; line-height: 1.625; color: var(--wp--preset--color--muted-foreground); max-width: 42rem; margin: 0 0 3rem; }
.esteri-pd-lifecycle__grid {
	display: grid;
	grid-template-columns: 1fr;
	gap: 2rem;
	border-top: 1px solid var(--wp--preset--color--border);
	padding-top: 2.5rem;
}
@media (min-width: 768px) {
	.esteri-pd-lifecycle__grid { grid-template-columns: repeat(3, 1fr); gap: 0; }
	.esteri-pd-lifecycle__item { padding-inline: 2rem; border-left: 1px solid var(--wp--preset--color--border); }
	.esteri-pd-lifecycle__item:first-child { padding-left: 0; border-left: 0; }
	.esteri-pd-lifecycle__item:last-child { padding-right: 0; }
}
.esteri-pd-lifecycle__item { display: flex; flex-direction: column; text-decoration: none; }
/* `display: flex` so the wrapper's box is EXACTLY the 28px icon: as an inline
 * span it created a line box (28px svg + descender slack = 34.8px measured),
 * pushing the card title 6.8px down — the design renders the <svg> itself with
 * `mb-5`, no wrapper. Same inline-strut mechanism as the section eyebrows.
 * refs: pixel-parity-audit.md § "What to measure" (spacing-parity flagged
 *       "Asennus"/"Huolto" +7.6px on tuote-esteri-7860). */
.esteri-pd-lifecycle__icon { display: flex; color: var(--wp--preset--color--primary-deep); margin-bottom: 1.25rem; transition: transform 0.2s; }
.esteri-pd-lifecycle__icon .esteri-pd-icon { width: 28px; height: 28px; stroke-width: 1.5; }
.esteri-pd-lifecycle__item:hover .esteri-pd-lifecycle__icon { transform: translateY(-2px); }
.esteri-pd-lifecycle__title { font-size: 1.25rem; font-weight: 700; line-height: 1.25; color: var(--wp--preset--color--foreground); margin: 0 0 0.75rem; transition: color 0.2s; }
.esteri-pd-lifecycle__item:hover .esteri-pd-lifecycle__title { color: var(--wp--preset--color--primary-deep); }
.esteri-pd-lifecycle__desc { font-size: 1rem; line-height: 1.65; color: var(--wp--preset--color--muted-foreground); margin: 0 0 1.25rem; flex: 1; }
.esteri-pd-lifecycle__cta {
	position: relative;
	display: inline-flex;
	align-items: center;
	gap: 0.5rem;
	align-self: flex-start;
	padding-bottom: 0.5rem;
	font-family: var(--wp--preset--font-family--mono);
	font-size: 0.9375rem;   /* source text-sm = 15px (was 14px); 0.18em ls → 2.7px */
	line-height: 1.6;
	text-transform: uppercase;
	letter-spacing: 0.18em;
	color: var(--wp--preset--color--foreground);
	transition: color 0.3s;
}
.esteri-pd-lifecycle__cta::before,
.esteri-pd-lifecycle__cta::after {
	content: '';
	position: absolute;
	left: 0; bottom: 0;
	height: 2px;
	width: 100%;
	transform-origin: left;
}
.esteri-pd-lifecycle__cta::before { background: var(--wp--preset--color--accent); transform: scaleX(1); transition: transform 0.3s; }
.esteri-pd-lifecycle__cta::after { background: var(--wp--preset--color--primary-deep); transform: scaleX(0); transition: transform 0.5s; }
.esteri-pd-lifecycle__item:hover .esteri-pd-lifecycle__cta { color: var(--wp--preset--color--primary-deep); }
.esteri-pd-lifecycle__item:hover .esteri-pd-lifecycle__cta::after { transform: scaleX(1); }
.esteri-pd-lifecycle__cta .esteri-pd-icon { width: 16px; height: 16px; transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); }   /* source: transition-transform duration-300 */
/* ProductDetailPage.tsx:595 `<ArrowUpRight … group-hover:translate-x-1 group-hover:-translate-y-1 transition-transform>` */
.esteri-pd-lifecycle__item:hover .esteri-pd-lifecycle__cta .esteri-pd-icon { transform: translate(4px, -4px); }

/* ── Back nav ────────────────────────────────────────────────────────── */
.esteri-pd-backnav { border-top: 1px solid var(--wp--preset--color--border); padding-block: 1.5rem; }
.esteri-pd-backnav__inner { display: flex; flex-wrap: wrap; gap: 1rem; }
.esteri-pd-backnav__link {
	display: inline-flex;
	align-items: center;
	gap: 0.5rem;
	font-family: var(--wp--preset--font-family--mono);
	font-size: 0.9375rem;
	line-height: 1.6;   /* source inherits 24px @15px (was 1.55 → 23.25) */
	color: var(--wp--preset--color--foreground);
	text-decoration: none;
}
.esteri-pd-backnav__link:hover { text-decoration: underline; }
.esteri-pd-backnav__link--muted { color: var(--wp--preset--color--muted-foreground); }
.esteri-pd-backnav__link .esteri-pd-icon { width: 12px; height: 12px; }

/* ── Placeholder (every product except esteri-7860) ──────────────────── */
.esteri-pd-placeholder__inner { max-width: 48rem; }
.esteri-pd-placeholder__title {
	font-size: 1.875rem;   /* text-3xl */
	font-weight: 700;
	letter-spacing: -0.025em;
	line-height: 1.05;
	color: var(--wp--preset--color--foreground);
	margin: 1rem 0 1.25rem;
}
@media (min-width: 768px)  { .esteri-pd-placeholder__title { font-size: 2.25rem; } }   /* md:text-4xl */
@media (min-width: 1024px) { .esteri-pd-placeholder__title { font-size: 3rem; } }      /* lg:text-5xl */
.esteri-pd-placeholder__lead { font-size: 1.0625rem; line-height: 1.7; color: var(--wp--preset--color--muted-foreground); margin: 0 0 2rem; }
.esteri-pd-placeholder__actions { display: flex; flex-wrap: wrap; gap: 0.75rem; }

/* Adjacent-section spacing: the body block runs sections back-to-back; the
 * sticky-bar lives at the body root and is fixed, so a body padding-bottom
 * keeps the last section clear of the bar on short viewports. */
body.single-tuote.has-sticky-banner { padding-bottom: 0; }

/* ════════════════════════════════════════════════════════════════════
   SolutionsPage /ratkaisut — process steps + use-case grid.
   Source: source/src/pages/SolutionsPage.tsx. Tokens via theme.json.
   ════════════════════════════════════════════════════════════════════ */

/* ── Process steps — light card band, 5 numbered steps ── */
.esteri-process { background: var(--wp--preset--color--card); }
.esteri-process__title {
	font-size: 1.875rem;   /* text-3xl */
	font-weight: 700;
	line-height: 1.1;
	max-width: 42rem;                       /* max-w-2xl */
	margin: 0 0 0.75rem;
	color: var(--wp--preset--color--foreground);
}
@media (min-width: 768px) {
	.esteri-process__title { font-size: 2.25rem; }   /* md:text-4xl */
}
.esteri-process__lede {
	font-size: 1rem;
	line-height: 1.5;                       /* source text-base = 24px (was 1.55 → 24.8) */
	max-width: 42rem;
	margin: 0 0 3rem;                       /* mb-12 */
	color: var(--wp--preset--color--muted-foreground);
}
/* The .wire-container is layout:constrained — WP's rule
   `.is-layout-constrained > :where(:not(.align*)) { margin: 0 auto !important }`
   CENTERS any max-width child, which would center the title + lede.
   Source keeps them left-aligned (max-w-2xl, left rail). Re-pin left with a
   specificity-winning override (0,2,0 beats the WP :where() 0,1,0 even with
   its !important). The eyebrow escapes this because is-style-wire-label is
   inline-flex; the steps grid escapes it because it has no max-width. */
.esteri-process .esteri-process__title,
.esteri-process .esteri-process__lede {
	margin-left: 0 !important;
	margin-right: auto !important;
}
/* Editor↔live parity for the section's vertical rhythm. WP's flow-layout rule
 * `:root :where(.is-layout-flow) > * { margin-block: 0 }` TIES the theme's (0,1,0)
 * bottom-margins above (title 0.75rem, lede 3rem) + the step number (0.75rem). On
 * the front end theme.css loads last so the theme value wins; in the editor
 * add_editor_style loads BEFORE core's layout styles, so the layout rule wins and
 * ZEROES these margins — the canvas collapses num→label, title→lede and (worst)
 * the 3rem lede→steps gap, so the block reads cramped vs. live. Re-assert at
 * (0,2,0) so the theme value wins on BOTH surfaces regardless of load order.
 * refs: review-gate.md § "editor must look like live" (memory editor-must-mirror-
 * live). */
.esteri-process .esteri-process__title { margin-bottom: 0.75rem; }
.esteri-process .esteri-process__lede { margin-bottom: 3rem; }
.esteri-process-step .esteri-process-step__num { margin-bottom: 0.75rem; }
.esteri-process-steps {
	list-style: none;
	margin: 0;
	padding: 0;
	display: grid;
	grid-template-columns: repeat(2, minmax(0, 1fr));     /* grid-cols-2 */
	column-gap: 2rem;
	row-gap: 2.5rem;
}
@media (min-width: 768px) {   /* source md:grid-cols-5 = 768, not 782 — at 768–781 WP showed 2 cols vs Lovable's 1 row */
	.esteri-process-steps { grid-template-columns: repeat(5, minmax(0, 1fr)); }   /* md:grid-cols-5 */
}
/* The grid container is now a core/group (is-layout-flow); kill WP's injected
   margin-block-start on the step children so it never doubles the grid row-gap
   (zero-injected-margin guard; .class beats the core :where() rule). */
.esteri-process-steps > * { margin-block-start: 0; }
.esteri-process-step {
	position: relative;
	padding-top: 1.25rem;                   /* pt-5, room for the top bar */
}
.esteri-process-step::before {             /* w-10 h-[2px] bg-brand-green */
	content: "";
	position: absolute;
	top: 0;
	left: 0;
	width: 2.5rem;
	height: 2px;
	background: var(--wp--preset--color--accent);
}
.esteri-process-step__num {
	display: block;
	font-size: 3rem;   /* text-5xl */
	font-weight: 700;
	letter-spacing: -0.04em;
	line-height: 1;
	margin: 0 0 0.75rem;                    /* mb-3 — margin:0 0 … neutralises the core/paragraph <p> default + WP flow-injected margin-block-start (class beats :where) */
	color: var(--wp--preset--color--foreground);
}
@media (min-width: 768px) {
	.esteri-process-step__num { font-size: 3.75rem; }   /* md:text-6xl */
}
/* Number is a core/paragraph "01"; the green trailing dot is CSS (was a nested
   <span class="esteri-process-step__dot">.</span> in the old core/html block). */
.esteri-process-step__num::after { content: "."; color: var(--wp--preset--color--accent); }
.esteri-process-step__label {
	display: block;
	margin: 0;                              /* core/paragraph <p> — kill default + WP flow margin-block-start */
	font-family: 'Space Mono', monospace;
	font-size: 0.9375rem;                   /* text-sm = 15px (was a 14px mis-port) */
	font-weight: 500;
	letter-spacing: 0.15em;
	text-transform: uppercase;
	line-height: 1.375;                     /* leading-snug */
	color: var(--wp--preset--color--muted-foreground);
}

/* ── Use-cases — dark-surface section head ── */
.esteri-usecases__head { margin-bottom: 2.5rem; }   /* mb-10 */
.esteri-usecases__title {
	font-size: 1.875rem;   /* text-3xl */
	font-weight: 700;
	line-height: 1.1;
	margin: 0;
	color: var(--wp--preset--color--primary-foreground);
}
@media (min-width: 768px) {
	.esteri-usecases__title { font-size: 2.25rem; }   /* md:text-4xl */
}
.esteri-usecases__lede {
	font-size: 1rem;
	line-height: 1.5;   /* source `text-base` (16/24) */
	margin: 0.75rem 0 0;
	color: hsl(0 0% 100% / 0.6);            /* primary-foreground/60 */
}

/* ── Solution grid + cards ── */
.esteri-sol-grid {
	display: grid;
	grid-template-columns: repeat(2, minmax(0, 1fr));     /* grid-cols-2 */
	gap: 0.625rem;                          /* gap-2.5 */
}
@media (min-width: 640px) { .esteri-sol-grid { gap: 0.75rem; } }   /* sm:gap-3 */
@media (min-width: 1024px) {
	.esteri-sol-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1rem; }  /* lg:grid-cols-4 lg:gap-4 */
}

/* Card — 4:5 image cover. position:relative anchors the bottom body + overlays
   (grids-listings § "position:relative on EVERY tile variant"). Whole-card <a>
   matches the gated .esteri-tile precedent. */
a.esteri-sol-card,
.esteri-sol-card {
	position: relative;
	/* Ratio floor + in-flow body, exactly as .esteri-tile — see the ::before note
	   there for why `aspect-ratio` on the card itself can never grow. */
	display: grid;
	grid-template-columns: minmax(0, 1fr);
	container-type: inline-size;
	align-self: start;
	overflow: hidden;
	border-radius: var(--radius);
	background: var(--wp--preset--color--foreground);
	color: var(--wp--preset--color--primary-foreground);
	text-decoration: none;
}
.esteri-sol-card::before {
	content: '';
	grid-area: 1 / 1;
	width: 100%;
	aspect-ratio: 4 / 5;
	pointer-events: none;
}
.esteri-sol-card__img {
	position: absolute;
	inset: 0;
	width: 100%;
	height: 100%;
	object-fit: cover;
	transition: transform 0.5s ease-out;
}
.esteri-sol-card:hover .esteri-sol-card__img { transform: scale(1.06); }
.esteri-sol-card__overlay {                /* always-on bottom scrim (foreground gradient) */
	position: absolute;
	inset: 0;
	pointer-events: none;
	/* Same length-based rewrite as .esteri-tile__scrim: --card-grow is 0px on any
	   card still inside its 4:5 box, where 81.25cqw / 125cqw are the source's 35 %
	   and 0 % stops read from the bottom — so this resolves to the gradient above
	   it replaced. Past the floor the wash climbs with the growth. */
	/* --card-floor / --ph-* as in .esteri-tile__scrim: the border-box 4:5 height, so
	   the self-padding CTA placeholder variant reads its floor correctly too. */
	--card-inset: calc(var(--ph-pad, 0px) + var(--ph-bord, 0px));
	--card-floor: calc(125cqw + 2.5 * var(--card-inset));
	--card-grow: max(0px, 100% + 2 * var(--ph-bord, 0px) - var(--card-floor));
	background: linear-gradient(0deg,
		hsl(222 30% 12% / 0.92) 0,
		hsl(222 30% 12% / 0.92) min(calc(var(--card-grow) * 4), 100% - 4rem),
		hsl(222 30% 12% / 0.25) min(calc(var(--card-grow) * 4 + 0.65 * var(--card-floor)), 100% - 1rem),
		hsl(222 30% 12% / 0)    min(calc(var(--card-grow) * 4 + var(--card-floor)), 100%));
}
.esteri-sol-card__overlay-hover {          /* primary-deep tint on hover (lg in source) */
	position: absolute;
	inset: 0;
	pointer-events: none;
	opacity: 0;
	transition: opacity 0.3s ease-out;
	background: linear-gradient(180deg, hsl(222 68% 20% / 0) 30%, hsl(222 68% 20% / 0.65) 100%);
}
@media (min-width: 1024px) {
	.esteri-sol-card:hover .esteri-sol-card__overlay-hover { opacity: 1; }
}
.esteri-sol-card__arrow {                  /* green ArrowUpRight badge, hover-in (lg) */
	position: absolute;
	top: 0.625rem;
	right: 0.625rem;
	z-index: 3;
	display: none;
	align-items: center;
	justify-content: center;
	width: 1.75rem;
	height: 1.75rem;
	color: var(--wp--preset--color--foreground);
	background: var(--wp--preset--color--accent);
	border-radius: var(--radius);
}
@media (min-width: 1024px) {
	.esteri-sol-card__arrow {
		top: 0.75rem;
		right: 0.75rem;
		width: 2rem;
		height: 2rem;
		display: flex;
		opacity: 0;
		transform: translateX(0.5rem);
		transition: opacity 0.2s ease-out, transform 0.2s ease-out;
	}
	.esteri-sol-card:hover .esteri-sol-card__arrow { opacity: 1; transform: translateX(0); }
}
.esteri-sol-card__body {
	/* In flow in the same cell as the ratio floor, so a long Kortin kuvaus grows
	   the card instead of climbing over its own title. Rows are [bar, title, desc]
	   — solution-grid/render.php is the only renderer of this markup and emits
	   exactly those three, desc last and optional. The row animates to 1fr (the
	   text's own height) at lg, where the reveal lives. */
	grid-area: 1 / 1;
	align-self: end;
	position: relative;
	z-index: 2;
	padding: 0.75rem;                       /* p-3 */
	display: grid;
	grid-template-rows: auto auto 0fr;
	transition: grid-template-rows 0.3s ease-out;
}
@media (min-width: 640px)  { .esteri-sol-card__body { padding: 1rem; } }     /* sm:p-4 */
@media (min-width: 1024px) { .esteri-sol-card__body { padding: 1.25rem; } }  /* lg:p-5 */
.esteri-sol-card__bar {                    /* green accent bar, scales x3 on hover (lg) */
	display: block;
	width: 1.5rem;                          /* w-6 */
	height: 2px;
	margin-bottom: 0.5rem;                  /* mb-2 */
	background: var(--wp--preset--color--accent);
	transform-origin: left;
	transform: scaleX(1);
	transition: transform 0.3s ease-out;
}
@media (min-width: 1024px) {
	.esteri-sol-card__bar { width: 2rem; margin-bottom: 0.75rem; }   /* lg:w-8 lg:mb-3 */
	.esteri-sol-card:hover .esteri-sol-card__bar { transform: scaleX(3); }
}
.esteri-sol-card__title {
	margin: 0 0 0.25rem;
	font-size: 1rem;                        /* text-base */
	font-weight: 700;
	line-height: 1.25;                      /* probed 16/20 → 18/28 → 20/28 like .esteri-tile__title */
	color: var(--wp--preset--color--primary-foreground);
}
@media (min-width: 640px)  { .esteri-sol-card__title { font-size: 1.125rem; line-height: 1.5556; } } /* sm:text-lg */
@media (min-width: 1024px) { .esteri-sol-card__title { font-size: 1.25rem; line-height: 1.4; } }  /* lg:text-xl */
.esteri-sol-card__desc {
	margin: 0;
	font-size: 1rem;
	line-height: 1.375;                     /* leading-snug */
	color: hsl(0 0% 100% / 0.75);           /* primary-foreground/75 */
	display: none;                          /* hidden by default (source: hidden lg:block) */
}
/* Image-card descriptions hover-reveal (source: lg:max-h-0 opacity-0
   group-hover:max-h-24 opacity-100). Scoped to __body so the placeholder
   foot desc (always shown at lg) is unaffected.
   Gated on `hover: hover`, NOT the source's lg width: the width form killed the
   reveal in a narrowed desktop window (same report as the cat-grid rule above).
   A touch device has no hover, so it still shows nothing — parity kept where it
   is observable. */
@media (hover: hover) {
	.esteri-sol-card__body .esteri-sol-card__desc {
		/* No clamp: the 0fr row on __body hides it, and overflow:hidden +
		   min-height:0 are what let a grid item collapse into that row. The
		   source's 6rem cut this caption at ~4 lines. */
		display: block;
		min-height: 0;
		max-height: none;
		opacity: 0;
		overflow: hidden;
		transition: opacity 0.3s ease-out;
	}
	.esteri-sol-card:hover .esteri-sol-card__body { grid-template-rows: auto auto 1fr; }
	.esteri-sol-card:hover .esteri-sol-card__body .esteri-sol-card__desc {
		opacity: 1;
	}
}

/* Dashed placeholder card — no image; foot desc shown at rest (lg). */
.esteri-sol-card--placeholder {
	/* Grid, not flex, so it gets the same growth floor as the image cards: the base
	   ::before spacer spans all three rows (below), the eyebrow pins to row 1 and
	   the foot to row 3, and the 1fr row in between reproduces the old
	   `justify-content: space-between`. Height is therefore max(4:5, eyebrow +
	   foot) — a long CTA text grows the card instead of being clipped by it. */
	display: grid;
	grid-template-columns: minmax(0, 1fr);
	grid-template-rows: auto 1fr auto;
	aspect-ratio: auto;
	--ph-pad: 0.75rem;                      /* the spacer cancels both — see ::before */
	--ph-bord: 2px;
	padding: var(--ph-pad);
	/* Explicit surface-deep, NOT transparent: this tile lives in a wp:html block
	   whose editor preview renders in an isolated (white-bodied) sandbox iframe
	   that does NOT include the parent .has-surface-deep-bg section. A transparent
	   tile is therefore white-on-white (invisible title/eyebrow) in the editor.
	   surface-deep == the section's own bg, so the front-end is pixel-identical.
	   (review-gate.md § Step 3.0a — editor≠front-end styling from un-applied context.) */
	background: var(--wp--preset--color--surface-deep, hsl(222 50% 8%));
	border: var(--ph-bord) dashed hsl(0 0% 100% / 0.2);
	transition: border-color 0.2s ease-out, background-color 0.2s ease-out;
}
/* The floor spans every row; the two in-flow children (solution-grid/render.php
   emits exactly these) take the outer rows. The negative margin makes the spacer's
   box the card's BORDER box — this card pads itself, so a plain `width: 100%`
   spacer would be 4:5 of the content box and 11 px short. */
.esteri-sol-card--placeholder::before {
	grid-row: 1 / -1;
	width: auto;
	margin: calc(-1 * (var(--ph-pad) + var(--ph-bord)));
}
.esteri-sol-card--placeholder > .esteri-sol-card__ph-eyebrow { grid-area: 1 / 1; }
.esteri-sol-card--placeholder > .esteri-sol-card__ph-foot    { grid-area: 3 / 1; min-height: 0; }
@media (min-width: 640px)  { .esteri-sol-card--placeholder { --ph-pad: 1rem; } }
@media (min-width: 1024px) { .esteri-sol-card--placeholder { --ph-pad: 1.25rem; } }
.esteri-sol-card--placeholder:hover {
	border-color: var(--wp--preset--color--accent);
	background: hsl(222 68% 20% / 0.4);     /* primary-deep/40 */
}
.esteri-sol-card__ph-eyebrow {
	font-family: 'Space Mono', monospace;
	font-size: 0.9375rem;   /* source `text-sm` = 15px */
	line-height: 1.6;
	letter-spacing: 0.2em;
	text-transform: uppercase;
	color: hsl(0 0% 100% / 0.6);
	transition: color 0.2s ease-out;
}
.esteri-sol-card--placeholder:hover .esteri-sol-card__ph-eyebrow { color: var(--wp--preset--color--accent); }
@media (min-width: 1024px) {
	.esteri-sol-card__ph-foot .esteri-sol-card__desc--shown { display: block; }   /* hidden lg:block */
}
/* Source placeholder desc is /65, the image cards /75. */
.esteri-sol-card--placeholder .esteri-sol-card__desc { color: hsl(0 0% 100% / 0.65); }

/* Optional CTA-card image (ctaImageId): the dashed placeholder becomes a real
   image card — drop the dashed border, show the image (base __img/__overlay
   rules position it), and lift the eyebrow/foot above the scrim. Text stays
   white, already readable over the overlay. (Saku: add an image to the
   "Et löydä omaa toimialaa?" card and similar.) */
.esteri-sol-card--placeholder.esteri-sol-card--has-image {
	--ph-bord: 0px;
	border: none;
	background: var(--wp--preset--color--foreground);
}
.esteri-sol-card--placeholder.esteri-sol-card--has-image .esteri-sol-card__ph-eyebrow,
.esteri-sol-card--placeholder.esteri-sol-card--has-image .esteri-sol-card__ph-foot {
	position: relative;
	z-index: 1;
}
.esteri-sol-card--placeholder.esteri-sol-card--has-image .esteri-sol-card__ph-eyebrow { color: hsl(0 0% 100% / 0.85); }

/* ════════════════════════════════════════════════════════════════════════
   SolutionDetailPage (/ratkaisut/<slug>) — source/src/pages/SolutionDetailPage.tsx
   Hero = esteri/page-hero (bold). Challenge/approach = core/columns. Recommended
   products + references = picker blocks. Cross-sell = auto-query carousel. Cards
   reuse .esteri-tile / .esteri-pd-rel-card / .esteri-ref-slide / .esteri-carousel.
   ════════════════════════════════════════════════════════════════════════ */

/* ── Picker empty-state card (editor canvas only) — picker-blocks.md §5b. ── */
.esteri-editor-placeholder {
	display: flex;
	flex-direction: column;
	align-items: center;
	justify-content: center;
	text-align: center;
	gap: 0.5rem;
	min-height: 6.5rem;
	margin-block: 1.5rem;
	padding: 1.5rem;
	border: 1.5px dashed var(--wp--preset--color--border);
	background: var(--wp--preset--color--card);
	color: var(--wp--preset--color--muted-foreground);
	border-radius: var(--radius);
	cursor: pointer;
	transition: border-color 0.15s ease;
}
.esteri-editor-placeholder:hover { border-color: var(--wp--preset--color--foreground); }
.esteri-editor-placeholder__label {
	font-family: var(--wp--preset--font-family--mono);
	font-size: 0.75rem;
	font-weight: 700;
	letter-spacing: 0.08em;
	text-transform: uppercase;
	color: var(--wp--preset--color--foreground);
}
.esteri-editor-placeholder__hint { font-size: 0.875rem; line-height: 1.45; max-width: 32rem; }

/* ── Challenge / Approach — 2-col bordered card on a light bg-card band. ── */
.esteri-sol-prose-band { background: var(--wp--preset--color--card); }
.esteri-sol-prose.wp-block-columns {
	gap: 1px;                                   /* source gap-px — the bg-border shows as a hairline divider */
	background: var(--wp--preset--color--border);
	border: 1px solid var(--wp--preset--color--border);
	border-radius: var(--radius);
	overflow: hidden;
	margin: 0;
}
.esteri-sol-prose.wp-block-columns > .wp-block-column {
	background: var(--wp--preset--color--card);
	padding: 2rem;
	margin: 0;
}
@media (min-width: 1024px) { .esteri-sol-prose.wp-block-columns > .wp-block-column { padding: 3rem; } }
.esteri-sol-prose__eyebrow {
	display: inline-flex;
	align-items: center;
	gap: 0.75rem;
	margin: 0 0 1.25rem;
	font-family: var(--wp--preset--font-family--mono);
	font-size: 0.9375rem;
	font-weight: 700;
	letter-spacing: 0.18em;
	text-transform: uppercase;
	color: var(--wp--preset--color--foreground);
	line-height: 1.6;   /* source mono eyebrow inherits 24px (was 1.2 → 18px) */
}
.esteri-sol-prose__eyebrow::before {
	content: '';
	flex: 0 0 36px;
	width: 36px;
	height: 36px;
	background: center / contain no-repeat;
}
.esteri-sol-prose__eyebrow--haaste::before {
	background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='36' height='36' viewBox='0 0 24 24' fill='none' stroke='%2397BF0D' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m8 3 4 8 5-5 5 15H2L8 3z'/%3E%3C/svg%3E");
}
.esteri-sol-prose__eyebrow--approach::before {
	background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='36' height='36' viewBox='0 0 24 24' fill='none' stroke='%2397BF0D' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5'/%3E%3Cpath d='M9 18h6'/%3E%3Cpath d='M10 22h4'/%3E%3C/svg%3E");
}
/* Generic, editor-pickable eyebrow icons (REQ: tekstiosion ikoni vaihdettavaksi).
   `--icon-*` is the system the Inspector "Ikoni" picker writes (sol-prose-icon-picker.js);
   `--haaste`/`--approach` above stay as backward-compatible aliases so live pages are
   byte-identical until their icon is actually changed. `--icon-haaste`/`--icon-approach`
   reuse the same two glyphs. Same data-URI technique + green (#97BF0D) stroke. */
.esteri-sol-prose__eyebrow--icon-haaste::before {
	background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='36' height='36' viewBox='0 0 24 24' fill='none' stroke='%2397BF0D' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m8 3 4 8 5-5 5 15H2L8 3z'/%3E%3C/svg%3E");
}
.esteri-sol-prose__eyebrow--icon-approach::before {
	background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='36' height='36' viewBox='0 0 24 24' fill='none' stroke='%2397BF0D' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5'/%3E%3Cpath d='M9 18h6'/%3E%3Cpath d='M10 22h4'/%3E%3C/svg%3E");
}
.esteri-sol-prose__eyebrow--icon-check::before {
	background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='36' height='36' viewBox='0 0 24 24' fill='none' stroke='%2397BF0D' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M22 11.08V12a10 10 0 1 1-5.93-9.14'/%3E%3Cpolyline points='22 4 12 14.01 9 11.01'/%3E%3C/svg%3E");
}
.esteri-sol-prose__eyebrow--icon-shield::before {
	background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='36' height='36' viewBox='0 0 24 24' fill='none' stroke='%2397BF0D' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z'/%3E%3C/svg%3E");
}
.esteri-sol-prose__eyebrow--icon-wrench::before {
	background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='36' height='36' viewBox='0 0 24 24' fill='none' stroke='%2397BF0D' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z'/%3E%3C/svg%3E");
}
.esteri-sol-prose__eyebrow--icon-gauge::before {
	background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='36' height='36' viewBox='0 0 24 24' fill='none' stroke='%2397BF0D' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m12 14 4-4'/%3E%3Cpath d='M3.34 19a10 10 0 1 1 17.32 0'/%3E%3C/svg%3E");
}
.esteri-sol-prose__eyebrow--icon-sparkles::before {
	background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='36' height='36' viewBox='0 0 24 24' fill='none' stroke='%2397BF0D' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m12 3-1.9 5.8a2 2 0 0 1-1.3 1.3L3 12l5.8 1.9a2 2 0 0 1 1.3 1.3L12 21l1.9-5.8a2 2 0 0 1 1.3-1.3L21 12l-5.8-1.9a2 2 0 0 1-1.3-1.3z'/%3E%3C/svg%3E");
}
.esteri-sol-prose__eyebrow--icon-leaf::before {
	background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='36' height='36' viewBox='0 0 24 24' fill='none' stroke='%2397BF0D' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10Z'/%3E%3Cpath d='M2 21c0-3 1.85-5.36 5.08-6'/%3E%3C/svg%3E");
}
.esteri-sol-prose__body { margin: 0; font-size: 1rem; line-height: 1.65; color: var(--wp--preset--color--muted-foreground); }

/* ── Recommended products — dark surface-deep band (two pickers). ── */
.esteri-sol-products { background: var(--wp--preset--color--surface-deep); border-bottom: 0; }
/* Recommended-category tiles: source steps the title `text-base sm:text-lg
 * lg:text-xl` (16/18/20) — the shared .esteri-tile__title 18px overflowed
 * "Maksujärjestelmät" past the tile edge at 390. */
/* line-height too: these dark tiles also carry .esteri-tile__title--dark, whose
 * @1024 line-height:1.5556 (0,1,0) would win → 31.1px at 20px. Source steps
 * text-base/lg/xl = 24/28/28px, so pin the paired lh here (0,2,0). */
.esteri-sol-products .esteri-tile__title { font-size: 1rem; line-height: 1.5; }
@media (min-width: 640px)  { .esteri-sol-products .esteri-tile__title { font-size: 1.125rem; line-height: 1.5556; } }
@media (min-width: 1024px) { .esteri-sol-products .esteri-tile__title { font-size: 1.25rem; line-height: 1.4; } }
.esteri-sol-products__head {
	display: flex;
	align-items: flex-end;
	justify-content: space-between;
	flex-wrap: wrap;
	gap: 1rem;
	margin-bottom: 2.5rem;
}
.esteri-sol-products__eyebrow,
.esteri-sol-products__sub-eyebrow {
	display: block;
	font-family: var(--wp--preset--font-family--mono);
	font-size: 0.9375rem;
	letter-spacing: 0.2em;
	text-transform: uppercase;
	color: hsla(0, 0%, 100%, 0.7);
}
.esteri-sol-products__eyebrow { margin-bottom: 0.75rem; line-height: 1.6; }
.esteri-sol-products__sub-eyebrow { margin-bottom: 0.5rem; line-height: 1.6; }
.esteri-sol-products__title {
	margin: 0;
	font-size: 2.25rem;
	font-weight: 700;
	line-height: 1.1;
	color: var(--wp--preset--color--primary-foreground);
}
.esteri-sol-products__lede {
	margin: 0.75rem 0 0;
	font-size: 1rem;
	line-height: 1.5;   /* source text-base = 24px (was 1.55 → 24.8px) */
	color: hsla(0, 0%, 100%, 0.6);
}
.esteri-sol-products__cats { margin-bottom: 4rem; }
.esteri-sol-products__examples-head {
	display: flex;
	align-items: flex-end;
	justify-content: space-between;
	flex-wrap: wrap;
	gap: 1.5rem;
	margin-bottom: 1.5rem;
}
/* Source recommended grid is `sm:grid-cols-2 lg:grid-cols-4` — 4 cols at 1024.
 * The shared .esteri-pd-rel-grid only reaches 4 cols at 1280 (3 cols 1024–1279),
 * which widened the cards (image 240 vs 163) in that band. Match source here. */
@media (min-width: 1024px) {
	.esteri-sol-products__examples.esteri-pd-rel-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
.esteri-sol-products__sub-title {
	margin: 0;
	font-size: 1.875rem;
	font-weight: 700;
	letter-spacing: -0.02em;
	color: var(--wp--preset--color--primary-foreground);
}
/* Empty-state editor preview: ghost/skeleton cards in the real grids so the
   block reads as "Suositellut laitekokonaisuudet / Sopivia tuotteita" before
   any picks are made (editor-only — the block self-hides on the front end).
   On the dark surface-deep band, so skeletons are faint white. */
.esteri-sol-products.is-esteri-ghost { padding-block: 3rem; }
.esteri-sol-products__ghost-hint {
	margin: 0 0 1.5rem;
	font-size: 0.9375rem;
	line-height: 1.5;
	color: hsla(0, 0%, 100%, 0.75);
}
.esteri-sol-products__ghost {
	display: flex;
	flex-direction: column;
	gap: 0.75rem;
	min-height: 220px;
	padding: 1.25rem;
	border: 1px dashed hsla(0, 0%, 100%, 0.35);
	border-radius: var(--radius, 4px);
	background: hsla(0, 0%, 100%, 0.04);
}
.esteri-sol-products__ghost-media {
	display: block;
	width: 100%;
	height: 96px;
	border-radius: var(--radius, 4px);
	background: hsla(0, 0%, 100%, 0.08);
}
.esteri-sol-products__ghost-line {
	display: block;
	width: 45%;
	height: 12px;
	border-radius: 999px;
	background: hsla(0, 0%, 100%, 0.12);
}
.esteri-sol-products__ghost-line--title { width: 70%; height: 16px; }
.esteri-sol-products__ghost-tag {
	margin-top: auto;
	align-self: flex-start;
	font-family: var(--wp--preset--font-family--mono);
	font-size: 0.6875rem;
	letter-spacing: 0.16em;
	text-transform: uppercase;
	color: hsla(0, 0%, 100%, 0.6);
	border: 1px solid hsla(0, 0%, 100%, 0.25);
	border-radius: 999px;
	padding: 0.25rem 0.6rem;
}
/* "Kaikki tuotteet" link on the dark band — white text, green underline, → green on hover. */
.esteri-sol-products__all {
	display: inline-flex;
	align-items: center;
	gap: 0.5rem;
	padding-bottom: 0.25rem;
	font-family: var(--wp--preset--font-family--mono);
	font-size: 0.9375rem;   /* source text-sm = 15px (was 14px); 0.18em ls → 2.7px */
	line-height: 1.6;       /* 24px, matches Lovable */
	text-transform: uppercase;
	letter-spacing: 0.18em;
	color: var(--wp--preset--color--primary-foreground);
	text-decoration: none;
	white-space: nowrap;
	border-bottom: 2px solid var(--wp--preset--color--accent);
	transition: color 0.2s;
}
.esteri-sol-products__all:hover { color: var(--wp--preset--color--accent); }

/* Shared arrow glyph for the section "all" links. */
.esteri-sol-products__all-arrow,
.esteri-sol-references__all-arrow,
.esteri-sol-crosssell__all-arrow {
	flex: 0 0 16px;
	width: 16px;
	height: 16px;
	background-color: currentColor;
	-webkit-mask: var(--esteri-arrow-up-right) center / contain no-repeat;
	        mask: var(--esteri-arrow-up-right) center / contain no-repeat;
}

/* ── Benefits — gradient-cta dark band + 2-col check list. ── */
.esteri-sol-benefits.wire-section { border-bottom: 0; }
.esteri-sol-benefits__eyebrow { margin: 0 0 0.75rem; }
/* Source: `wire-label-light block mb-3` — the `block` collapses the ::before
 * dash (same mechanism as the global .is-style-wire-label suppression above),
 * so this eyebrow is dash-less in the design. The -light class keeps its dash
 * globally for the home hero; suppress per-instance here. */
.esteri-sol-benefits__eyebrow::before { display: none; }
.esteri-sol-benefits__title {
	margin: 0 0 0.75rem;
	max-width: 42rem;
	font-size: 2.25rem;
	font-weight: 700;
	line-height: 1.1;
	letter-spacing: -0.025em;
	color: var(--wp--preset--color--primary-foreground);
}
.esteri-sol-benefits__lede {
	margin: 0 0 3rem;
	max-width: 42rem;
	font-size: 1rem;
	line-height: 1.5;   /* source text-base = 24px (was 1.6 → 25.6px) */
	color: hsla(0, 0%, 100%, 0.65);
}
/* Left-pin the max-width heading + lede: a layout:constrained ancestor (esp.
   in the editor canvas, where the load order lets WP's rule win) applies
   `margin:0 auto !important` to any max-width child, centering them. (0,2,0)
   beats WP's `:where()` (0,1,0). Same trap as the SolutionsPage process band. */
.esteri-sol-benefits .esteri-sol-benefits__title,
.esteri-sol-benefits .esteri-sol-benefits__lede { margin-left: 0 !important; }
.esteri-sol-benefits__list.wp-block-list {
	list-style: none;
	margin: 0;
	padding: 0;
	display: grid;
	grid-template-columns: 1fr;
	gap: 0 3rem;
}
@media (min-width: 768px) { .esteri-sol-benefits__list.wp-block-list { grid-template-columns: 1fr 1fr; } }
.esteri-sol-benefits__list.wp-block-list > li {
	position: relative;
	display: flex;
	align-items: flex-start;
	gap: 1rem;
	padding-block: 1.25rem;
	font-size: 1rem;
	line-height: 1.6;
	color: hsla(0, 0%, 100%, 0.85);
	/* Let the grid cell shrink to its track + wrap/break long benefit text
	   instead of forcing the column wider (Saku: "edut-lista text doesn't wrap").
	   min-width:0 defeats the grid/flex min-content floor; overflow-wrap:anywhere
	   lets even an unbroken compound word wrap within the column. */
	min-width: 0;
	overflow-wrap: anywhere;
	word-break: break-word;
}
/* The benefit text sits in an anonymous flex item after the ::before check icon;
   give it room to grow + wrap rather than overflow. */
.esteri-sol-benefits__list.wp-block-list > li > * { min-width: 0; }
.esteri-sol-benefits__list.wp-block-list > li::before {
	content: '';
	flex: 0 0 1.75rem;
	width: 1.75rem;
	height: 1.75rem;
	margin-top: 0.125rem;
	border-radius: var(--radius);
	background-color: hsla(74, 87%, 40%, 0.15);
	background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%2397BF0D' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E");
	background-position: center;
	background-repeat: no-repeat;
}

/* ── Section heads for references + cross-sell (light bands). ── */
.esteri-sol-references { background: var(--wp--preset--color--background); }
.esteri-sol-references__head,
.esteri-solutions__head,
.esteri-sol-crosssell__head {
	display: flex;
	flex-direction: column;
	gap: 1rem;
	margin-bottom: 2.5rem;
}
/* The /tuotteet solutions-carousel block emits the head eyebrow as a `<p
 * class="is-style-wire-label">` and the title as a bare `<h2
 * class="wp-block-heading">`, both of which inherit the theme's default
 * paragraph/heading block margins (eyebrow 15/15, h2 ~30/30). That inflated the
 * head to ~170px vs the source's 103px (eyebrow `block mb-3` = 12px, h2 no
 * margin, lede `mt-3` = 12px). Reset them to the source rhythm. (The crosssell
 * head uses dedicated `__eyebrow`/`__title`/`__lede` classes that already zero
 * their margins, so this is scoped to `.esteri-solutions__head`.) */
.esteri-solutions__head .is-style-wire-label { margin: 0 0 0.75rem; }   /* source eyebrow `block mb-3` */
.esteri-solutions__head h2 { margin: 0; }                               /* source h2: no own margin */
/* Same section-head convention for the products-categories head — its bare
   wp-block-heading otherwise inherits a ~30px default top margin, so the
   eyebrow→title gap read 45px vs the source's 12px (cont.71 ink-gap oracle). */
.esteri-products-categories > .wire-container > .is-style-wire-label { margin: 0 0 0.75rem; }
.esteri-products-categories > .wire-container > h2 { margin-top: 0; }
@media (min-width: 768px) {
	/* Source head is `flex items-end justify-between flex-wrap gap-4` (and the
	 * shared SectionHeader). `flex-wrap` is load-bearing: at the ~768–960 band
	 * the view-all link must wrap onto its own line below-left (justify-between
	 * with a single wrapped item → flex-start = the gutter), NOT force the
	 * heading text to shrink while pinned right. Without it the link stays
	 * inline-right at every width — "not as responsive as Lovable". */
	.esteri-sol-references__head,
	.esteri-solutions__head,
	.esteri-sol-crosssell__head { flex-direction: row; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; }
}
.esteri-sol-references__title,
.esteri-sol-crosssell__title {
	margin: 0;
	font-size: 2.25rem;
	font-weight: 700;
	line-height: 1.1;
	color: var(--wp--preset--color--foreground);
	/* Flex child in a justify-between head: min-width:0 lets a long title shrink
	 * below its min-content so it wraps instead of forcing the head (and page)
	 * wider than the viewport (content-stress-sweep @768). */
	min-width: 0;
}
/* Same min-width:0 shrink-guard for the other flex section heads' headings. */
.esteri-solutions__head h2,
.esteri-solutions__head > *,
.esteri-sol-references__head > *,
.esteri-sol-crosssell__head > * { min-width: 0; }
.esteri-sol-references__lede,
.esteri-sol-crosssell__lede {
	margin: 0.75rem 0 0;
	font-size: 1rem;
	line-height: 1.5;   /* source text-base = 24px (was 1.55 → 24.8px) */
	color: var(--wp--preset--color--muted-foreground);
}
/* ── Mobile typography parity — SolutionDetailPage section headings ──────────
 * The source uses responsive Tailwind sizes that step DOWN below the breakpoint
 * (text-3xl md:text-4xl, text-2xl md:text-3xl, text-lg sm:text-base); the WP
 * rules above are flat at the DESKTOP size, so mobile text rendered too large
 * (h2 36 vs 30, sub 30 vs 24) or too small (example-card 16 vs 18). Restore the
 * source's mobile font-size + line-height (measured with
 * `parity-typography --width=390`). Placed after the base rules so it wins; only
 * applies below the breakpoint, so the ≥768/≥640 desktop gate is untouched.
 * refs: SolutionDetailPage.tsx (text-3xl/2xl/lg classes) + parity-line-height-and-engine-fixes. */
@media (max-width: 767.98px) {
	/* text-3xl → 30px / lh 36px */
	.esteri-sol-products__title,
	.esteri-sol-benefits__title,
	.esteri-sol-references__title,
	.esteri-sol-crosssell__title { font-size: 1.875rem; line-height: 2.25rem; }
	/* text-2xl → 24px / lh 32px */
	.esteri-sol-products__sub-title { font-size: 1.5rem; line-height: 2rem; }
	/* recommended-category tiles use leading-tight (1.25) in the source at every
	 * size; the WP tile rule holds a fixed ~24–28px lh for card-height stability,
	 * which reads too loose vs Lovable on mobile. Match source lh here (font-size
	 * unchanged, so the ≥640 18px / <640 16px steps still apply). */
	.esteri-sol-products__cats .esteri-tile__title { line-height: 1.25; }
}
@media (max-width: 639.98px) {
	/* text-lg → 18px / lh 28px — scoped to the solution examples grid only (the
	 * SAME .esteri-pd-rel-card__title is 16px on tuote pages, which already
	 * matches its source, so this must NOT touch the product-detail rails). */
	.esteri-sol-products__examples .esteri-pd-rel-card__title { font-size: 1.125rem; line-height: 1.75rem; }
}
/* "Katso kaikki" / "Kaikki ratkaisut" wire-link — green base underline, navy sweep on hover. */
.esteri-sol-references__all,
.esteri-sol-crosssell__all {
	position: relative;
	display: inline-flex;
	align-items: center;
	gap: 0.5rem;
	align-self: flex-start;
	padding-bottom: 0.25rem;
	font-family: var(--wp--preset--font-family--mono);
	font-size: 0.9375rem;   /* source text-sm = 15px (was 14px); 0.18em ls → 2.7px */
	line-height: 1.6;       /* 24px, matches Lovable */
	text-transform: uppercase;
	letter-spacing: 0.18em;
	color: var(--wp--preset--color--foreground);
	text-decoration: none;
	white-space: nowrap;
	transition: color 0.3s;
}
.esteri-sol-references__all::before,
.esteri-sol-references__all::after,
.esteri-sol-crosssell__all::before,
.esteri-sol-crosssell__all::after {
	content: '';
	position: absolute;
	left: 0;
	bottom: 0;
	height: 2px;
	width: 100%;
	transform-origin: left;
}
.esteri-sol-references__all::before,
.esteri-sol-crosssell__all::before { background: var(--wp--preset--color--accent); transform: scaleX(1); transition: transform 0.3s; }
.esteri-sol-references__all::after,
.esteri-sol-crosssell__all::after { background: var(--wp--preset--color--primary-deep); transform: scaleX(0); transition: transform 0.5s; }
.esteri-sol-references__all:hover,
.esteri-sol-crosssell__all:hover { color: var(--wp--preset--color--primary-deep); }
.esteri-sol-references__all:hover::after,
.esteri-sol-crosssell__all:hover::after { transform: scaleX(1); }

/* Cross-sell reuses .esteri-solutions / .esteri-carousel / .esteri-solution-tile;
   only the head needs styling (above). The carousel + nav are gated chrome. */
.esteri-sol-crosssell.wire-section { border-bottom: 0; }

/* ── Leadoo "Pyydä tarjous" frame (esteri/leadoo-bot) — source LeadooBot.tsx. ── */
.esteri-leadoo {
	background: var(--wp--preset--color--card);
	border-top: 4px solid var(--wp--preset--color--primary);
	border-bottom: 0;
}
.esteri-leadoo__inner { max-width: 48rem; margin-inline: auto; }
.esteri-leadoo__head { text-align: center; margin-bottom: 2.5rem; }
.esteri-leadoo__badge {
	display: inline-flex;
	align-items: center;
	justify-content: center;
	width: 3.5rem;
	height: 3.5rem;
	margin-bottom: 1rem;
	border: 2px dashed hsla(222, 68%, 33%, 0.4);
	background: hsla(222, 68%, 33%, 0.05);
	color: hsla(222, 68%, 33%, 0.6);
}
.esteri-leadoo__h2 {
	margin: 0 0 0.5rem;
	font-family: var(--wp--preset--font-family--mono);
	font-size: 1.5rem;
	font-weight: 700;
	color: var(--wp--preset--color--foreground);
}
.esteri-leadoo__lead {
	max-width: 28rem;
	margin: 0 auto;
	font-size: 0.9375rem;
	line-height: 24px;   /* source LeadooBot.tsx:24/26 `text-sm` pair — 1.55 was the body value */
	color: var(--wp--preset--color--muted-foreground);
}
/* Source uses muted-foreground/70 + /60 here; bumped to the full muted token
   so axe Mode A color-contrast passes (same call as the news-card fix). */
.esteri-leadoo__product { display: block; margin-top: 0.25rem; color: var(--wp--preset--color--muted-foreground); }
.esteri-leadoo__box {
	display: flex;
	flex-direction: column;
	gap: 1.5rem;
	padding: 1.5rem;
	border: 2px dashed var(--wp--preset--color--border);
	background: var(--wp--preset--color--background);
}
.esteri-leadoo__box-label {
	margin: 0;
	font-family: var(--wp--preset--font-family--mono);
	font-size: 0.9375rem;
	letter-spacing: 0.1em;
	text-transform: uppercase;
	text-align: center;
	color: var(--wp--preset--color--muted-foreground);
}
.esteri-leadoo__steps { display: grid; grid-template-columns: 1fr; gap: 0.75rem; }
@media (min-width: 640px) { .esteri-leadoo__steps { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
.esteri-leadoo__step {
	padding: 1rem;
	border: 1px solid var(--wp--preset--color--border);
	text-align: center;
}
.esteri-leadoo__step .esteri-leadoo-icon { display: block; margin: 0 auto 0.5rem; color: hsla(222, 10%, 42%, 0.4); }
.esteri-leadoo__step-label {
	display: block;
	font-family: var(--wp--preset--font-family--mono);
	font-size: 0.9375rem;
	font-weight: 700;
	color: var(--wp--preset--color--foreground);
}
.esteri-leadoo__step-desc { display: block; font-size: 0.9375rem; color: var(--wp--preset--color--muted-foreground); }
.esteri-leadoo__fields { padding: 1.25rem; border: 1px solid var(--wp--preset--color--border); }
.esteri-leadoo__fields-label {
	margin: 0 0 0.75rem;
	font-family: var(--wp--preset--font-family--mono);
	font-size: 0.9375rem;
	font-weight: 700;
	color: var(--wp--preset--color--foreground);
}
.esteri-leadoo__fields-grid { display: grid; grid-template-columns: 1fr; gap: 0.75rem; }
@media (min-width: 640px) { .esteri-leadoo__fields-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.esteri-leadoo__field {
	display: flex;
	align-items: center;
	gap: 0.625rem;
	font-size: 0.9375rem;
	color: var(--wp--preset--color--muted-foreground);
}
.esteri-leadoo__field .esteri-leadoo-icon { flex: 0 0 auto; color: hsla(222, 10%, 42%, 0.4); }
.esteri-leadoo__foot {
	margin: 0;
	text-align: center;
	font-family: var(--wp--preset--font-family--mono);
	font-size: 0.9375rem;
	color: var(--wp--preset--color--muted-foreground);
}
/* Live-bot states — set by assets/leadoo-bot.js once a bot code is configured.
   The closed (product-page) variant ships `hidden`; the CTA removes it. The mount
   node reserves height while Leadoo's script renders so the scroll-to target does
   not jump under the visitor, then relaxes once content is in. */
.esteri-leadoo[hidden] { display: none; }
.esteri-leadoo__bot { min-height: 20rem; }
.esteri-leadoo.is-loaded .esteri-leadoo__bot,
.esteri-leadoo.is-failed .esteri-leadoo__bot { min-height: 0; }
.esteri-leadoo.is-loading .esteri-leadoo__bot { border: 2px dashed var(--wp--preset--color--border); }
.esteri-leadoo__fallback {
	margin: 1rem 0 0;
	text-align: center;
	font-size: 0.9375rem;
	line-height: 24px;
	color: var(--wp--preset--color--muted-foreground);
}
.esteri-leadoo__fallback a { color: var(--wp--preset--color--primary); text-decoration: underline; text-underline-offset: 0.15em; }
/* Editor-only: the configured-bot note (bot never loads on the canvas) and the
   closed-variant state chip. */
.esteri-leadoo__editor-note {
	margin: 0;
	text-align: center;
	font-size: 0.9375rem;
	line-height: 24px;
	color: var(--wp--preset--color--muted-foreground);
}
.esteri-leadoo__editor-note code { font-family: var(--wp--preset--font-family--mono); font-size: 0.875em; }
.esteri-leadoo__editor-note--settings { margin-top: 12px; font-size: 0.8125rem; line-height: 20px; }
.esteri-leadoo__state {
	display: inline-block;
	margin-top: 0.75rem;
	padding: 0.25rem 0.625rem;
	border: 1px solid var(--wp--preset--color--border);
	font-family: var(--wp--preset--font-family--mono);
	font-size: 0.8125rem;
	letter-spacing: 0.05em;
	text-transform: uppercase;
	color: var(--wp--preset--color--muted-foreground);
}




/* ═══════════════════════════════════════════════════════════════════════
   ArticlesPage (/ajankohtaista) — esteri/article-listing block
   Faithful port of source/src/pages/ArticlesPage.tsx. Reuses .esteri-chip
   (+ .esteri-chip-dark) for the grid card chips and .esteri-chip-filter for
   the category chips; adds the card-engineered card, dark featured card,
   filter-row layout, pagination and empty state.
   ═══════════════════════════════════════════════════════════════════════ */

/* Engineered card — left accent bar sweeps in on hover (source index.css). */
.card-engineered {
	position: relative;
	border: 1px solid var(--wp--preset--color--border);
	background: var(--wp--preset--color--background);
	transition: border-color 0.15s ease;
}
.card-engineered::before {
	content: '';
	position: absolute;
	left: 0;
	top: 0;
	bottom: 0;
	width: 3px;
	background: var(--wp--preset--color--accent);
	transform: scaleY(0);
	transform-origin: top;
	transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
	z-index: 2;
}
.card-engineered:hover { border-color: var(--wp--preset--color--primary); }
.card-engineered:hover::before { transform: scaleY(1); }

/* Engineered LEFT accent bar for the bespoke product-card grids.
 * Source applies `.card-engineered` to BOTH the /tuotteet category cards
 * (ProductsPage.tsx) and the category-listing cards (ProductCategoryPage.tsx),
 * so each carries the source's left-edge 3px green bar that sweeps in
 * (scaleY from top) on hover — alongside the markup top-accent line.
 * The WP build factored these into bespoke `.esteri-product-card` /
 * `.esteri-product-listing-card` classes (top accent only) and dropped the
 * left bar. Restore it here as one shared rule, mirroring `.card-engineered`
 * ::before verbatim, so both product-list/category-card pages regain the
 * left-edge sweep. The card classes are already position:relative;
 * overflow:hidden so the bar is contained; z-index:2 sits above the image
 * but below the top accent strip (z-index:10), matching source. */
.esteri-product-card::before,
.esteri-product-listing-card::before {
	content: '';
	position: absolute;
	left: 0;
	top: 0;
	bottom: 0;
	width: 3px;
	background: var(--wp--preset--color--accent, hsl(74, 87%, 40%));
	transform: scaleY(0);
	transform-origin: top;
	transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
	z-index: 2;
}
.esteri-product-card:hover::before,
.esteri-product-listing-card:hover::before { transform: scaleY(1); }

/* The [hidden] attribute must beat the cards' display:flex/grid. */
.esteri-article-listing [hidden] { display: none !important; }

/* ── Category filter strip ─────────────────────────────────────────────── */
.esteri-article-filter { background: var(--wp--preset--color--background); }
.esteri-article-filter__inner { position: relative; padding-top: 1.75rem; padding-bottom: 1.75rem; }
@media (min-width: 640px) {
	.esteri-article-filter__inner { padding-top: 2.25rem; padding-bottom: 2.25rem; }
}
/* The __scroll wrapper is the constrained scroll viewport (fills the
   wire-container width); the __row sits at max-content inside it and scrolls
   horizontally on mobile. Putting overflow-x on the row itself while it is
   width:max-content does NOT create a scroll viewport — the row sizes to its
   content and overflows the 390 viewport, inflating the whole document. */
.esteri-article-filter__scroll {
	overflow-x: auto;
	overflow-y: hidden;
	scrollbar-width: none;
	-ms-overflow-style: none;
}
/* Source: the mobile chip row is full-bleed (`-mx-5` wrapper + `px-5` on the
 * scroller) — chips scroll to the true viewport edge instead of clipping at
 * the container edge. wire-edge == the source's 1.25rem at this band. */
@media (max-width: 639.98px) {
	.esteri-article-filter__scroll {
		margin-inline: calc(-1 * var(--wire-edge));
		padding-inline: var(--wire-edge);
	}
}
.esteri-article-filter__scroll::-webkit-scrollbar { display: none; }
.esteri-article-filter__row {
	display: flex;
	gap: 0.5rem;
	width: max-content;
}
/* Anchored to __inner (position:relative), NOT the scroller — a fade inside
 * the scroll box travels with the content and lands mid-row after a swipe
 * (the reported "some items faded" bug). right:0 of the wire-container's
 * padding box == the viewport edge once the scroller bleeds full-width. */
.esteri-article-filter__fade {
	position: absolute;
	top: 0;
	right: 0;
	height: 100%;
	width: 3rem;
	background: linear-gradient(to left, hsl(0 0% 100%) 0%, hsl(0 0% 100% / 0.8) 50%, hsl(0 0% 100% / 0) 100%);
	pointer-events: none;
	z-index: 10;
}
@media (min-width: 640px) {
	.esteri-article-filter__scroll { overflow: visible; }
	.esteri-article-filter__row { flex-wrap: wrap; width: auto; }
	.esteri-article-filter__fade { display: none; }
}

/* ── Listing section (source: wire-section !pt-2 sm:!pt-4 !pb-10 sm:!pb-20) ─ */
.esteri-article-section { padding-top: 0.5rem; padding-bottom: 2.5rem; }
@media (min-width: 640px) {
	.esteri-article-section { padding-top: 1rem; padding-bottom: 5rem; }
}

/* ── Featured / pinned card (dark brand-gradient, 2-col) ───────────────── */
.esteri-article-featured {
	display: grid;
	grid-template-columns: 1fr;
	margin-bottom: 2rem;
	background: var(--gradient-cta);
	border-radius: var(--radius);
	overflow: hidden;
	text-decoration: none;
}
@media (min-width: 768px) {
	.esteri-article-featured { grid-template-columns: 1fr 1fr; }
}
.esteri-article-featured__media {
	position: relative;
	aspect-ratio: 3 / 2;
	overflow: hidden;
	background: var(--wp--preset--color--muted);
}
.esteri-article-featured__img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.5s ease; }
.esteri-article-featured:hover .esteri-article-featured__img { transform: scale(1.04); }
.esteri-article-featured__arrow,
.esteri-article-card__arrow {
	position: absolute;
	bottom: 0.75rem;
	right: 0.75rem;
	z-index: 10;
	width: 2.25rem;
	height: 2.25rem;
	display: inline-flex;
	align-items: center;
	justify-content: center;
	background: var(--wp--preset--color--accent);
	color: var(--wp--preset--color--foreground);
	border-radius: var(--radius);
	transform: translateX(0.5rem);
	opacity: 0;
	transition: transform 0.2s ease, opacity 0.2s ease;
}
.esteri-article-featured:hover .esteri-article-featured__arrow,
.esteri-article-card:hover .esteri-article-card__arrow {
	transform: translateX(0);
	opacity: 1;
}
.esteri-article-featured__body {
	padding: 1.5rem;
	display: flex;
	flex-direction: column;
	justify-content: center;
}
@media (min-width: 640px) { .esteri-article-featured__body { padding: 2rem; } }
@media (min-width: 768px) { .esteri-article-featured__body { padding: 2.5rem; } }
.esteri-article-featured__meta { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; }
.esteri-article-featured__chip {
	display: inline-flex;
	align-items: center;
	gap: 0.375rem;
	padding: 0.25rem 0.625rem;
	background: hsl(0 0% 100% / 0.15);
	backdrop-filter: blur(4px);
	font-family: 'Space Mono', monospace;
	/* Source featured chip is `text-sm` mono (15px / 400 / lh 24) — was 13px. */
	font-size: 0.9375rem;
	line-height: 1.6;
	letter-spacing: 0.15em;
	text-transform: uppercase;
	color: var(--wp--preset--color--primary-foreground);
	border-radius: var(--radius);
}
.esteri-article-featured__chip--date {
	background: hsl(0 0% 100% / 0.1);
	letter-spacing: 0.12em;
	color: hsl(0 0% 100% / 0.85);
}
.esteri-article-featured__dot { width: 4px; height: 4px; border-radius: 50%; background: var(--wp--preset--color--accent); }
.esteri-article-featured__title {
	font-size: 1.5rem;
	font-weight: 700;
	line-height: 1.2;
	letter-spacing: -0.025em;
	color: var(--wp--preset--color--primary-foreground);
	margin: 0 0 1rem;
	/* NO clamp: the source renders the full title (ArticlesPage.tsx featured
	   h2 has no line-clamp) — the old 3-line clamp cut the real 4-line
	   "Pelastuslaitosten pesulaopas…" title at 390 (Saku report 2026-07-30).
	   Hostile-token safety stays with the global overflow-wrap:anywhere net. */
}
@media (min-width: 768px) { .esteri-article-featured__title { font-size: 1.875rem; } }
.esteri-article-featured:hover .esteri-article-featured__title { text-decoration: underline; }
.esteri-article-featured__excerpt {
	font-size: 1rem;
	line-height: 1.625;
	color: hsl(0 0% 100% / 0.75);
	margin: 0;
	/* NO clamp — source shows the whole curated excerpt (5 lines @390).
	   Excerpt LENGTH is bounded editorially: esteri_article_lede() caps the
	   auto-lede at the design data's 22-word max. */
}

/* ── Article grid + card ───────────────────────────────────────────────── */
.esteri-article-listing__grid { display: grid; grid-template-columns: 1fr; gap: 1rem; }
@media (min-width: 640px) { .esteri-article-listing__grid { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 1024px) { .esteri-article-listing__grid { grid-template-columns: repeat(3, 1fr); } }
.esteri-article-card {
	display: flex;
	flex-direction: column;
	border-radius: var(--radius);
	overflow: hidden;
	text-decoration: none;
}
.esteri-article-card__media {
	position: relative;
	aspect-ratio: 3 / 2;
	overflow: hidden;
	background: var(--wp--preset--color--muted);
}
.esteri-article-card__img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.5s ease; }
.esteri-article-card:hover .esteri-article-card__img { transform: scale(1.04); }
.esteri-article-card__body { padding: 1.25rem; display: flex; flex-direction: column; flex: 1; }
.esteri-article-card__title {
	font-size: 1.125rem;
	font-weight: 700;
	line-height: 1.375;
	letter-spacing: -0.01em;
	color: var(--wp--preset--color--foreground);
	margin: 0 0 0.5rem;
	transition: color 0.15s ease;
	/* NO clamp: source card h3 renders the full title (ArticlesPage.tsx). The
	   grid's align-items:stretch keeps rows equal-height with uneven titles,
	   exactly like the source; a pasted URL wraps via the global
	   overflow-wrap:anywhere net instead of needing a clamp. */
}
.esteri-article-card:hover .esteri-article-card__title { color: var(--wp--preset--color--primary-deep); }
.esteri-article-card__excerpt {
	font-size: 0.9375rem;
	line-height: 1.625;
	color: var(--wp--preset--color--muted-foreground);
	margin: 0 0 1rem;   /* source excerpt mb-4 — was 0, cards ran 16px short */
	flex: 1;
	/* NO clamp — the source card <p> shows the whole curated excerpt
	   (ArticlesPage.tsx has no line-clamp anywhere; the 3-line clamp cut real
	   seeded excerpts mid-sentence — Saku report 2026-07-30). Length control
	   is editorial: esteri_article_lede() caps auto-ledes at 22 words, the
	   design data's max. Unbroken tokens wrap via the overflow-wrap net. */
}

/* ── Pagination ────────────────────────────────────────────────────────── */
.esteri-article-pagination {
	margin-top: 3.5rem;
	padding-top: 2rem;
	border-top: 1px solid var(--wp--preset--color--border);
	display: flex;
	align-items: center;
	justify-content: center;
	gap: 0.5rem;
	flex-wrap: wrap;
}
@media (min-width: 640px) { .esteri-article-pagination { gap: 0.75rem; } }
.esteri-article-pagination__nav {
	display: inline-flex;
	align-items: center;
	gap: 0.5rem;
	padding: 0.5rem 1rem;
	font-size: 0.9375rem;
	font-weight: 600;
	border: 1px solid var(--wp--preset--color--border);
	background: var(--wp--preset--color--background);
	color: var(--wp--preset--color--foreground);
	border-radius: var(--radius);
	cursor: pointer;
	transition: background-color 0.15s ease;
}
@media (min-width: 640px) { .esteri-article-pagination__nav { padding: 0.625rem 1.25rem; } }
.esteri-article-pagination__nav:hover:not(:disabled) { background: var(--wp--preset--color--accent); }
.esteri-article-pagination__nav:disabled { opacity: 0.3; cursor: not-allowed; }
.esteri-article-pagination__nav-label { display: none; }
@media (min-width: 640px) { .esteri-article-pagination__nav-label { display: inline; } }
.esteri-article-pagination__pages { display: flex; align-items: center; gap: 0.25rem; }
.esteri-article-pagination__page {
	width: 2.5rem;
	height: 2.5rem;
	font-size: 0.9375rem;
	font-weight: 600;
	border: 1px solid var(--wp--preset--color--border);
	background: var(--wp--preset--color--background);
	color: var(--wp--preset--color--foreground);
	border-radius: var(--radius);
	display: inline-flex;
	align-items: center;
	justify-content: center;
	cursor: pointer;
	transition: background-color 0.15s ease;
}
.esteri-article-pagination__page:hover { background: var(--wp--preset--color--accent); }
.esteri-article-pagination__page.is-active {
	background: var(--wp--preset--color--primary);
	color: var(--wp--preset--color--primary-foreground);
	border-color: var(--wp--preset--color--primary);
}

/* ── Empty state ───────────────────────────────────────────────────────── */
.esteri-article-empty { border: 1px solid var(--wp--preset--color--border); padding: 3rem; text-align: center; }
.esteri-article-empty__text { font-size: 0.9375rem; color: var(--wp--preset--color--muted-foreground); margin: 0 0 1rem; }
.esteri-article-empty__reset {
	font-size: 0.9375rem;
	font-family: 'Space Mono', monospace;
	text-decoration: underline;
	color: var(--wp--preset--color--foreground);
	background: none;
	border: none;
	cursor: pointer;
}

/* ═══════════════════════════════════════════════════════════════════════
   ArticleDetailPage (/ajankohtaista/:slug)
   Faithful port of source/src/pages/ArticleDetailPage.tsx. Chrome (hero,
   TOC, share, sidebar, related) = dynamic blocks composed into
   single-artikkeli.html; the long-form prose = native core blocks in
   post_content, styled via .esteri-article-prose. The related strip reuses
   .esteri-article-card + .card-engineered; the inline product recs reuse
   .esteri-pd-rel-card (capped at 3 cols). Loaded into the editor iframe too
   (add_editor_style) so the post-content prose previews correctly.
   ═══════════════════════════════════════════════════════════════════════ */

/* ── Author avatar (shared: hero byline + sidebar author card) ──────────── */
.esteri-article-avatar { display: inline-flex; flex-shrink: 0; border-radius: 9999px; overflow: hidden; }
.esteri-article-avatar--photo { object-fit: cover; }
.esteri-article-avatar--initial {
	align-items: center;
	justify-content: center;
	background: var(--wp--preset--color--accent);
}
.esteri-article-avatar--initial > span {
	font-family: 'Space Mono', monospace;
	font-weight: 700;
	font-size: 0.9375rem;
	color: var(--wp--preset--color--foreground);
	line-height: 1;
}

/* ── Hero band (chip + date / title / excerpt / byline) ─────────────────── */
.esteri-article-hero { padding-top: 3rem; padding-bottom: 1rem; }
@media (min-width: 768px) { .esteri-article-hero { padding-top: 5rem; padding-bottom: 1.5rem; } }
.esteri-article-hero__grid { display: block; }
@media (min-width: 1024px) {
	/* Source grid lg:grid-cols-[1fr_280px] gap-16 — the hero text shares the
	   body's main-column width; the 280px slot stays empty so the hero
	   left-aligns with the prose + featured image below. */
	.esteri-article-hero__grid { display: grid; grid-template-columns: 1fr 280px; gap: 4rem; }
}
.esteri-article-hero__main { min-width: 0; }
.esteri-article-hero__meta { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1.5rem; flex-wrap: wrap; }
.esteri-article-hero__chip {
	display: inline-flex;
	align-items: center;
	padding: 0.375rem 0.75rem;
	background: var(--wp--preset--color--accent);
	color: var(--wp--preset--color--primary-deep);
	font-family: 'Space Mono', monospace;
	font-size: 0.9375rem;
	line-height: 1.6;
	letter-spacing: 0.14em;
	text-transform: uppercase;
	border-radius: var(--radius);
	text-decoration: none;
	transition: background-color 0.15s ease;
}
.esteri-article-hero__chip:hover { background: hsl(74 87% 40% / 0.85); }
.esteri-article-hero__date {
	font-family: 'Space Mono', monospace;
	font-size: 0.9375rem;
	line-height: 1.6;
	color: var(--wp--preset--color--muted-foreground);
}
.esteri-article-hero__title {
	font-size: 2.25rem;
	font-weight: 700;
	line-height: 1;   /* source computes 52px @52px (base h1 rule overrides leading-[1.05]) */
	letter-spacing: -0.035em;
	color: var(--wp--preset--color--foreground);
	margin: 0 0 1.5rem;
}
@media (min-width: 768px)  { .esteri-article-hero__title { font-size: 3rem; } }
@media (min-width: 1024px) { .esteri-article-hero__title { font-size: 3.25rem; } }
.esteri-article-hero__excerpt {
	font-size: 1.125rem;
	line-height: 1.625;
	color: var(--wp--preset--color--muted-foreground);
	margin: 0;
}
.esteri-article-hero__byline { margin-top: 2rem; display: flex; align-items: center; gap: 0.75rem; }
.esteri-article-hero__byline-meta { display: flex; flex-direction: column; }
.esteri-article-hero__byline-name {
	font-family: 'Space Mono', monospace;
	font-weight: 700;
	font-size: 0.9375rem;
	line-height: 1.6;
	color: var(--wp--preset--color--foreground);
}
.esteri-article-hero__byline-role {
	font-family: 'Space Mono', monospace;
	font-size: 0.9375rem;
	line-height: 1.6;
	color: var(--wp--preset--color--muted-foreground);
}

/* ── Body 2-col layout (1fr + 280px sidebar @1024) ──────────────────────── */
.esteri-article-body { padding-top: 1.5rem; padding-bottom: 5rem; }
@media (min-width: 768px) { .esteri-article-body { padding-top: 2rem; } }
.esteri-article-layout { display: block; }
@media (min-width: 1024px) {
	/* No align-items override: the grid row must STRETCH so the <aside> grid
	   item fills the (tall) main column's height. The sticky lives on the
	   inner __sticky child, which only travels if its parent (the aside) is
	   taller than it — `align-items: start` collapsed the aside to its own
	   content height and killed the sticky scroll-follow (matches source, which
	   leaves the grid at the default `stretch`). */
	.esteri-article-layout { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: 4rem; }
}
.esteri-article-main { min-width: 0; }

/* ── Section / composite patterns dropped INTO the article body ─────────────
   Home/landing patterns (Ajankohtaista grid, product-categories, hero, CTAs)
   can be inserted into an article. They ship a full-bleed `.wire-container`
   page gutter + `align:full`, sized for edge-to-edge page placement; inside the
   narrower 1fr article column that double-insets them (they look "not full
   width" and misalign with the prose). Make them fill the column edge-to-edge
   (aligned with the paragraphs) and stop an author-made flex/group wrapper from
   shrinking a full-width child (e.g. the CTA panel) below the column width.
   Scoped to .esteri-article-prose so the homepage/landing placements are
   unaffected. responsive-testing.md / canonical-patterns.md § align:full. */
.esteri-article-prose .alignfull,
.esteri-article-prose .alignwide { margin-left: 0; margin-right: 0; max-width: 100%; width: 100%; box-sizing: border-box;
	/* A bit of margin on both sides so a section pattern (tuotteet/referenssit/
	   ajankohtaista/CTA) dropped into the narrow article column isn't flush to
	   the prose edge. Gutter sits on the outer full-bleed wrapper so it applies
	   uniformly to the rail head AND any out-of-rail carousel child; the
	   section background still fills the column (padding is inside border-box). */
	padding-inline: clamp(1rem, 4vw, 1.5rem); }
/* wire-container stays gutter-less so it doesn't double up inside the padded wrapper. */
.esteri-article-prose .wire-container { width: 100%; max-width: none; padding-left: 0; padding-right: 0; margin-left: 0; margin-right: 0; }
.esteri-article-prose .esteri-article-cta { width: 100%; flex: 1 1 100%; box-sizing: border-box; }
/* ── Unified Loppu-CTA (esteri/cta-final) inside a real article ─────────────
   The single Loppu-CTA replaces the retired inline article CTA (esteri/
   article-cta). Its align:full is already neutralised to the prose column by the
   .esteri-article-prose .alignfull rule above, so here we only drop the
   full-bleed band rhythm + section border and render it as the SAME constrained
   gradient card the inline CTA was (rounded, moderate padding). Scoped to
   body.single-artikkeli so composed article-template *pages* keep cta-final as a
   full-bleed band (the .page-template-single-artikkeli > .alignfull rules below
   still bleed it). Its heading/lede/button colours come from the shared
   .esteri-cta.is-style-esteri-cta-gradient rules; the H2 is kept out of the TOC
   by esteri_toc_h2_list() skipping .esteri-cta blocks. */
body.single-artikkeli .esteri-article-prose .esteri-cta.wire-section {
	margin-block: 2.5rem;
	padding: 2rem;
	border-bottom: 0;
	border-radius: var(--radius);
}
@media (min-width: 768px) {
	body.single-artikkeli .esteri-article-prose .esteri-cta.wire-section { padding: 2.5rem; }
}
/* TYPE: the article's end-CTA is one size step SMALLER than the shared CtaBlock
   every other page uses, and the port had applied the shared scale here.
   ArticleDetailPage.tsx:284-290 — h3 `text-2xl md:text-3xl … mb-3
   tracking-[-0.02em]` + lede `text-base text-primary-foreground/75 mb-8 max-w-lg`;
   shared/CtaBlock.tsx:46-52 — h2 `text-3xl md:text-4xl leading-[1.1] mb-4` + lede
   `mb-10 max-w-xl`. Scoped to the real-article context so the SAME cta-final
   pattern keeps the shared scale on Pages (and on an Artikkelisivu-template page,
   which bleeds full-width). Mirrored onto .is-root-container in functions.php's
   block_editor_settings_all artikkeli branch — a body-scoped rule alone silently
   no-ops in the editor canvas. */
body.single-artikkeli .esteri-article-prose .esteri-cta .esteri-cta__title {
	font-size: 1.5rem;        /* text-2xl */
	line-height: 2rem;        /* text-2xl's paired 2rem, not CtaBlock's leading-[1.1] */
	letter-spacing: -0.02em;  /* tracking-[-0.02em] */
	margin-bottom: 0.75rem;   /* mb-3 */
}
@media (min-width: 768px) {
	body.single-artikkeli .esteri-article-prose .esteri-cta .esteri-cta__title {
		font-size: 1.875rem;  /* md:text-3xl */
		line-height: 2.25rem; /* text-3xl's paired 2.25rem */
	}
}
/* The <768 populator-inline-size clamp (theme.css ~L241) is !important, so it
 * beat this scope's 1.5rem and pinned the article CTA at 30px on phones —
 * the design's ArticleDetailPage CTA is text-2xl (24px) below md. Counter it
 * at the same weight, same band. */
@media (max-width: 767.98px) {
	body.single-artikkeli .esteri-article-prose .esteri-cta .esteri-cta__title {
		font-size: 1.5rem !important;
	}
}
body.single-artikkeli .esteri-article-prose .esteri-cta.is-style-esteri-cta-gradient .esteri-cta__lede {
	color: hsla(0, 0%, 100%, 0.75);   /* text-primary-foreground/75 (was /0.7) */
	max-width: 32rem;                 /* max-w-lg */
	margin-bottom: 2rem;              /* mb-8 */
}
/* A real artikkeli stays reading-column + sidebar — full-width heroes/section
   patterns belong on a Page, but if a HERO is dropped in, Saku wants the designed
   text-LEFT / image-RIGHT split kept INSIDE the reading column (cont.58 — NOT
   stacked, which cont.57 had wrongly assumed correct). The column is narrow
   (~700px, so each 50/50 half is ~340px), so the only thing the split needs is
   the hero TYPE scaled down to fit a half without ugly mid-word breaks; the
   side-by-side layout itself is the source default at ≥1024 viewport (the global
   .esteri-hero-front__grid stack at <1024 still kicks in on real mobile). SCOPED
   to body.single-artikkeli so the Artikkelisivu *template* page (full-bleed
   heroes) and normal pages keep their full-size heroes. bespoke-hero-composite
   -block.md § hero split, responsive-testing.md § container-keyed scale. */
/* The hero-front panel's huge padding-left (max(...,calc((100vw-1508px)/2…)),
   which aligns the FULL-WIDTH front-page hero to the 1508 rail) is catastrophic
   inside a ~500px article column: at a wide viewport it grows past the panel
   width, squeezing the text to ~60px so every letter wraps. Reset it to a normal
   gutter + drop the 640px min-height here. */
body.single-artikkeli .esteri-article-prose .esteri-hero-front__panel {
	padding-block: 2rem; padding-inline: 1.5rem; min-height: 0;
}
body.single-artikkeli .esteri-article-prose .esteri-hero-front__panel > *,
body.single-artikkeli .esteri-article-prose .esteri-hero-front__panel > h1 { max-width: 100%; }
body.single-artikkeli .esteri-article-prose .esteri-hero-front__panel > h1,
body.single-artikkeli .esteri-article-prose .esteri-page-hero__title {
	font-size: 1.625rem; line-height: 1.15; overflow-wrap: break-word; word-break: normal;
}
body.single-artikkeli .esteri-article-prose .esteri-hero-front__lead,
body.single-artikkeli .esteri-article-prose .esteri-page-hero__lead {
	font-size: 0.9375rem; line-height: 1.55;
}
/* page-hero's wide ≥1024 panel padding (max-w-1508 rail offset) is meaningless
   inside the column — reset it so the text isn't pushed off its ~340px half. */
body.single-artikkeli .esteri-article-prose .esteri-page-hero__panel {
	padding-block: 2rem; padding-inline: 1.5rem; min-height: 0;
}
body.single-artikkeli .esteri-article-prose .esteri-page-hero__media { min-height: 14rem; }

/* ── Composed PAGES: rhythm between dropped patterns ─────────────────────────
   A page built by dropping section patterns + content blocks (the customer's
   "aatestisivu") had full-bleed bands sitting FLUSH (0 gap) while interspersed
   blocks (a quote / image) got only the default block-gap — reading as "patterns
   too close" (Saku, cont.57, "sitaatti is a bit close"). Give every consecutive
   top-level block in the page content a clear, uniform gap so a composed page
   has predictable rhythm. Bands carry their own .wire-section padding on top of
   this, so they breathe. Scoped OFF the front page (body.home — its sections are
   tuned in front-page.html) and off real articles (.esteri-article-prose keeps
   prose rhythm). canonical-patterns.md § align:full. */
body.page:not(.home) .entry-content > * + * { margin-block-start: 3.5rem; }
/* …but a full-bleed band following another full-bleed band stays FLUSH.
   The rhythm rule above is for interspersed FLOW blocks (quote/image between
   bands — the cont.57 "sitaatti too close" case); consecutive .wire-section
   bands own their vertical rhythm (padding-block + hairline border) and the
   source renders them touching (0 gap). Without this exemption every designed
   band page (huolto, ratkaisut, tuotteet, …) gained a 56px stripe between
   bands that Lovable doesn't have — found by the cont.71 inter-band rect
   sweep (gaps 56,56,56 vs 0,0,0; height Δ up to +584px slid under the
   height-delta tolerance). pixel-parity-audit.md § failure-modes: "Gap
   between alignfull sections (expected 0)". Covers hero→band too (the
   page-hero wrapper is alignfull). */
body.page:not(.home) .entry-content > .alignfull + .alignfull { margin-block-start: 0; }
/* …and LOOSE content (a customer typing a heading + text on a fresh page) needs
   the section edge too, or it sits FLUSH against the header and the footer:
   probed 0px above the h2 and 0px below the last paragraph at 1440 AND 390
   (Saku 2026-08-03: "when im creating new page and just add new text the text
   doesnt set inside the container" — horizontally it was already exact, x=206
   w=1508 at 1920, identical to the header/footer rails; what was missing was the
   VERTICAL band). 5rem = the `.wire-section` padding every designed band uses,
   so a plain-text page reads as one of the site's sections.
   `:not(.alignfull)` keeps every pattern-built page untouched — their first and
   last children are always full-bleed bands that own their own padding
   (verified on tuotteet/yhteystiedot/ratkaisut/palvelut).
   refs: canonical-patterns.md § align:full; responsive-testing.md
         § "Customer-entered content". */
body.page:not(.home) .entry-content > *:not(.alignfull):first-child { margin-block-start: 5rem; }
body.page:not(.home) .entry-content > *:not(.alignfull):last-child { margin-block-end: 5rem; }

/* ── Gutters for CUSTOMER-placed core layout blocks ──────────────────────────
   `theme.json` sets `styles.spacing.blockGap: 0` — correct for the designed
   bands (each pattern owns its spacing, and a global gap would push them all
   apart), but it also means a core layout block a CUSTOMER drops on a page has
   NO gap at all. Measured on a fresh page: two buttons touching (x 48 +159.7 →
   next at 207.7, gap 0) and two images in a 2-col row touching at x=720 on
   desktop AND touching vertically once the columns stack at 390 (Saku
   2026-08-03: "two buttons should be able to set next to each other … make sure
   if theres two images next to each other they will work in mobile view").
   `gap` covers both axes, so the stacked-mobile case is the same fix.

   Button rows: the 0.75rem default lives in theme.json
   (styles.blocks."core/buttons".spacing.blockGap), NOT here. A theme.css
   `.wp-block-buttons { gap: 0.75rem }` rule (0,1,0) printed AFTER
   core-block-supports-inline-css silently BEAT every author-set gap on the
   front end — the editor injects per-instance rules after theme styles, so the
   canvas showed the author's pick and live showed 12px (Saku 2026-08-05: "if
   theres two buttons next to each other the gap is not same in editor view as
   live page"). The theme.json block default prints in global-styles-inline-css
   BEFORE per-instance container rules, so the author's gap wins on both
   surfaces and rows without one keep the design's gap-3 (probed 12px).
   Designed rows stay pinned by their scoped rules (.esteri-final-cta …, 0,2,0).

   Columns: scoped to DIRECT children of the content root, i.e. the ones a
   customer placed. Every pattern nests its columns inside a `wire-section`
   group, so pattern internals (which are tuned per section) are untouched.
   2rem = `gap-8`, the design's usual two-column gutter and core's own default.
   KNOWN LIMIT (same bug class as the buttons rule above): this (0,3,0) rule
   also beats an author-set column gap on live. The theme.json fix used for
   buttons can't be copied here — a core/columns blockGap default would reach
   the 14 pattern-internal columns that rely on the global 0 — so fixing it
   needs pattern pinning + a content migration; deferred until it is reported
   or scheduled.
   refs: canonical-patterns.md § align:full; wp-block-themes (theme.json
         blockGap vs per-block gap).
   Ratkaisu pages take the same gutter (Saku 2026-11-05: on a phone the stacked
   video touched the "Ota yhteyttä" button above it, /ratkaisut/hevostallit/). */
body.page:not(.home) .entry-content > .wp-block-columns,
.single-ratkaisu .entry-content > .wp-block-columns { gap: 2rem; }

/* A customer-placed ROW (core/group with a flex layout) must WRAP on phones.
   Gutenberg ships Rows with "Allow to wrap to multiple lines" OFF
   (`flexWrap:"nowrap"`), so two images dropped into a Row stay in one line at
   every width: on Saku's test page at 390 that squeezed a 183.6px image next to
   a 166.4px cover, touching, with no way for the author to see why (Saku
   2026-08-03: "it has two images next to each other and they will not wrap in
   mobile view"). Below the design's `md` breakpoint we let them wrap, and they
   get the same 2rem gutter as columns (blockGap is 0 site-wide — see the note
   above). Direct children of the content root only, so nothing inside a pattern
   is touched; no designed pattern uses a flex group anyway (only button rows,
   which are `flexWrap:"wrap"` already and are meant to sit side by side).
   Specificity (0,4,1) beats core's injected `.wp-container-core-group-is-layout-*`
   (0,1,0) without `!important`. */
body.page:not(.home) .entry-content > .wp-block-group.is-layout-flex { gap: 2rem; }
@media (max-width: 767.98px) {
	body.page:not(.home) .entry-content > .wp-block-group.is-layout-flex { flex-wrap: wrap; }
}

/* ── Page leipäteksti — bare flow text typed straight onto a page ───────────
   Body text authored as plain paragraphs/headings/lists at the TOP LEVEL of a
   page (no pattern, no group) inherited the template's 1508px contentSize —
   1300px+ text lines, far wider than any other column on the page (Saku,
   cont.83: "the leipäteksti should look like huolto-ja-tuki/takuu page" /
   "its too wide ... wider than other content in page"). Give it the takuu
   page's audited prose treatment: the 48rem rail pinned LEFT — Saku's final
   ruling after seeing both ("just make the text like in that page"), and the
   Lovable source's own geometry (WarrantyPage.tsx `max-w-3xl`, no mx-auto).
   The !importants beat WP's constrained-layout child rule's
   `margin-inline: auto !important` — same trick as
   .esteri-warranty-terms__rail above. Type + rhythm values verbatim from
   § warranty-terms. Direct children only, so nothing inside a pattern or
   group is touched; the built pages carry zero bare root-level text blocks
   (scanned cont.83), so this only defines the default for future
   customer-authored prose pages.
   Mirrored into the page editor canvas in functions.php (memory
   editor-must-mirror-live) — keep the two in sync. */
body.page:not(.home) .entry-content > :is(p, h1, h2, h3, h4, h5, h6, ul, ol, .wp-block-quote) {
	max-width: 48rem;
	/* Pin to the CONTAINER's text edge, not the viewport padding edge. The
	   takuu rail lives inside a centered .wire-container (max 1508 + --wire-edge
	   inset), so a plain margin-left:0 matched it only below 1508px viewports —
	   on a wide monitor the prose hugged the far-left screen edge while every
	   band's text started ~500px in (Saku, cont.83: "its not correctly aligned",
	   ~2560px screenshot). Same calc shape as the entry-content figure align
	   rules; + --wire-edge because TEXT aligns to the container's padded edge,
	   not its box. Below 1508+edges the max() bottoms out at 0 = the old pin. */
	margin-left: max(0px, calc((100% - var(--wp--style--global--wide-size, 1508px)) / 2 + var(--wire-edge, 3.5rem))) !important;
	margin-right: auto !important;
}
body.page:not(.home) .entry-content > p {
	font-size: 1rem;
	line-height: 1.625;
	color: var(--wp--preset--color--muted-foreground);
}
/* Cookiebot's cookie declaration (Custom HTML block on the Evästeet page). cd.js
   injects its own <div> wrapper at the entry-content root, which the prose rule
   above never matches, so it ran the full content width and, past 1508px, sat
   --wire-edge left of the page title (Saku 2026-10-27: "sisältö ei vieläkään ole
   niinkuin muut sisällöt"). Same edge as the prose rail; its intro text gets the
   48rem rail and prose colour, the cookie tables keep the container width. */
body.page:not(.home) .entry-content > div:has(> .CookieDeclaration) {
	max-width: calc(var(--wp--style--global--wide-size, 1508px) - 2 * var(--wire-edge, 3.5rem)) !important;
	margin-left: max(0px, calc((100% - var(--wp--style--global--wide-size, 1508px)) / 2 + var(--wire-edge, 3.5rem))) !important;
	margin-right: auto !important;
}
body.page:not(.home) .entry-content .CookieDeclaration > :not(.CookieDeclarationType) { max-width: 48rem; }
body.page:not(.home) .entry-content .CookieDeclaration > p {
	line-height: 1.625;
	color: var(--wp--preset--color--muted-foreground);
}
body.page:not(.home) .entry-content > :is(h2, h3, h4) {
	font-weight: 700;
	color: var(--wp--preset--color--foreground);
}
body.page:not(.home) .entry-content > h2 { font-size: 1.5rem; line-height: 1.333; }
/* h3 = the takuu "Rakennetakuuseen kuuluvat osat" part label (1rem/1.5 bold,
   tight 0.25rem to its own text, prose-plugin 1.667em from the previous part)
   — the reference page's only h3 use. */
body.page:not(.home) .entry-content > h3 { font-size: 1rem; line-height: 1.5; }
body.page:not(.home) .entry-content > :is(ul, ol) {
	list-style: disc;
	padding-left: 1.5rem;
}
body.page:not(.home) .entry-content > :is(ul, ol) li {
	font-size: 1rem;
	line-height: 1.625;
	color: var(--wp--preset--color--muted-foreground);
	margin: 0 0 0.75rem 0;
	/* prose li inline-start pad — same value+reason as the takuu list */
	padding-left: 0.444444em;
}
body.page:not(.home) .entry-content > :is(ul, ol) li::marker { color: rgb(209, 213, 219); }
/* Prose-tight rhythm: consecutive flow-text blocks read as ONE text, not
   3.5rem-stacked sections — overrides the page `* + *` rhythm above
   ((0,3,3) beats its (0,3,1)). Headings keep takuu's 2.5rem lead-in. */
body.page:not(.home) .entry-content > :is(p, h2, h3, h4, h5, h6, ul, ol) + :is(p, ul, ol) { margin-block-start: 1rem; }
body.page:not(.home) .entry-content > :is(p, ul, ol, h2, h3, h4, h5, h6) + :is(h2, h3, h4, h5, h6) { margin-block-start: 2.5rem; }
/* h3 part-label rhythm — takuu reference values; same specificity as the two
   generic pair rules above, so LATER in file wins. Label hugs its own text
   (0.25rem), parts sit 1.667em apart (the prose-plugin collapse the takuu
   rail reproduces), and a section h2 keeps its 1.5rem to the first label. */
body.page:not(.home) .entry-content > h3 + p { margin-block-start: 0.25rem; }
body.page:not(.home) .entry-content > :is(p, ul, ol) + h3 { margin-block-start: 1.6666667rem; }
body.page:not(.home) .entry-content > h2 + h3 { margin-block-start: 1.5rem; }

/* ── core/list styles: "Mustat/Harmaat luettelomerkit" ──────────────────────
   Registered in functions.php (register_block_style). The design's two list
   looks, each a pickable style: the article prose's BLACK 6px dot and the
   takuu page's GRAY disc + muted text (Saku, cont.83: "in article we have
   black bulletpoint list and then other page has gray so add those as
   styles"). A style carries the COLOUR pair (marker + item text); inside a
   context that has its own list geometry (article dot / page disc) the
   geometry stays and only the colours swap. Neither style sets ul margin —
   the flow rhythm is always the context's. Editor mirror lives in
   functions.php block_editor_settings_all (page + artikkeli branches);
   keep the two in sync. */
ul.is-style-esteri-gray-bullets {
	list-style: disc;
	padding-left: 1.5rem;
}
ul.is-style-esteri-gray-bullets > li {
	font-size: 1rem;
	line-height: 1.625;
	color: var(--wp--preset--color--muted-foreground, hsl(220, 9%, 46%));
	margin: 0 0 0.75rem 0;
	padding-left: 0.444444em;
}
ul.is-style-esteri-gray-bullets > li::marker { color: rgb(209, 213, 219); }
/* Inside article prose the list keeps the ARTICLE geometry (its own ::before
   dot, list-style none, article li metrics) — the style there only recolours
   the dot + mutes the text. Restated at higher specificity than both the
   article rules and the generic block-style rules above, so the outcome does
   not depend on file order. */
.esteri-article-prose > ul.is-style-esteri-gray-bullets { list-style: none; padding-left: 1rem; }
.esteri-article-prose > ul.is-style-esteri-gray-bullets > li { color: var(--wp--preset--color--muted-foreground, hsl(220, 9%, 46%)); }
.esteri-article-prose > ul.is-style-esteri-gray-bullets > li::before { background: rgb(209, 213, 219); }

/* "Mustat luettelomerkit" — the article look, standalone (mirror image of the
   gray style above). In ARTICLE prose these values are identical to the prose
   defaults, so picking it there is a truthful no-op; the offer exists for
   pages and any other context. */
ul.is-style-esteri-black-bullets {
	list-style: none;
	padding-left: 1rem;
}
ul.is-style-esteri-black-bullets > li {
	position: relative;
	font-size: 0.9375rem;
	line-height: 1.625;
	color: hsl(222 30% 12% / 0.8);
	padding-left: 1.125rem;
	margin: 0 0 0.5rem 0;
}
ul.is-style-esteri-black-bullets > li::before {
	content: '';
	position: absolute;
	left: 0;
	top: 0.6em;
	width: 6px;
	height: 6px;
	border-radius: 9999px;
	background: var(--wp--preset--color--foreground);
}
/* On a PAGE the list keeps the PAGE geometry (disc marker, 1rem text, page
   indents — those rules out-specify the standalone ones above on their own);
   the style recolours the marker to foreground + darkens the text. The one
   rule specificity does NOT already settle is the standalone ::before dot,
   which nothing on the page paints over — suppress it or a page list would
   show a disc AND a dot. */
body.page:not(.home) .entry-content > ul.is-style-esteri-black-bullets > li { color: hsl(222 30% 12% / 0.8); }
body.page:not(.home) .entry-content > ul.is-style-esteri-black-bullets > li::marker { color: var(--wp--preset--color--foreground); }
body.page:not(.home) .entry-content > ul.is-style-esteri-black-bullets > li::before { content: none; }

/* ── Deleted-media fallback in heroes (esteri/page-hero + esteri/hero-video) —
   the bundled washer glyph renders CONTAINED on the surface tint, never
   cover-stretched (a stretched glyph reads as a bug, which is exactly what
   this fallback exists to avoid — Saku, cont.83). */
.esteri-page-hero__img--placeholder,
.esteri-hero-video__el--placeholder {
	object-fit: contain;
	background: hsl(220, 20%, 96%);
	padding: 3rem;
	box-sizing: border-box;
}

/* ── PAGE built on the Artikkelisivu (single-artikkeli) template ────────────
   A *page* using the article template (body.page-template-single-artikkeli —
   NOT a real artikkeli post) is used to compose full-width SECTION patterns
   (tuotteet / referenssit / ratkaisut / CTA / hero). Inside the narrow 1fr
   reading column those sections looked squished + misaligned (Saku: "patterns
   not correct looking in article sivupohja"). On these pages we drop the
   reading-column grid + the article-only chrome (sidebar / related rail) so the
   sections render edge-to-edge exactly like on a normal page, and re-cap the
   inner .wire-container at the standard rail. Real artikkeli posts
   (body.single-artikkeli) are untouched — they keep the prose column + sidebar. */
.page-template-single-artikkeli .esteri-article-layout { display: block; }
.page-template-single-artikkeli .esteri-article-sidebar,
.page-template-single-artikkeli .esteri-article-related { display: none; }
/* Section patterns break out to the full viewport (the column is now centred,
   so calc(50% - 50vw) is exact). */
.page-template-single-artikkeli .esteri-article-prose > .alignfull {
	width: 100vw; max-width: 100vw;
	margin-left: calc(50% - 50vw); margin-right: calc(50% - 50vw);
	padding-inline: 0;
}
.page-template-single-artikkeli .esteri-article-prose > .alignwide {
	width: 100%; max-width: 100%; padding-inline: 0;
}
/* The wire-container inside a full-bleed section re-caps + re-gutters like a
   normal page section (overrides the contained-in-article rule above). */
.page-template-single-artikkeli .esteri-article-prose .wire-container {
	max-width: 1508px; margin-inline: auto;
	padding-left: var(--wire-edge); padding-right: var(--wire-edge);
}
/* A little breathing room between consecutive full-width section patterns so a
   composed page doesn't read as one flush wall of sections (Saku: "patterns
   should have a bit of space between them"). */
.page-template-single-artikkeli .esteri-article-prose > .alignfull + .alignfull,
.page-template-single-artikkeli .esteri-article-prose > .alignwide + .alignwide,
.page-template-single-artikkeli .esteri-article-prose > .alignfull + .alignwide,
.page-template-single-artikkeli .esteri-article-prose > .alignwide + .alignfull { margin-block-start: 2.5rem; }

/* ── Featured image (3:2, full-bleed on mobile, rounded ≥768) ───────────── */
.esteri-article-figure {
	margin: 0 calc(-1 * var(--wire-edge)) 2.5rem;
	overflow: hidden;
	background: var(--wp--preset--color--muted);
}
.esteri-article-figure img,
.esteri-article-figure .wp-post-image {
	display: block;
	width: 100%;
	height: 100%;
	aspect-ratio: 3 / 2;
	object-fit: cover;
}
@media (min-width: 768px) {
	.esteri-article-figure { margin-left: 0; margin-right: 0; border-radius: var(--radius); }
}
/* Hero crop (per-article esteri_hero_focus). A non-3:2 image is cropped to the
   fixed-size 3:2 frame by object-fit:cover; the panel's align buttons pick which
   edge stays in view. Class is stamped on the figure front-end (render_block
   filter) and on the block wrapper in the editor (article-settings-panel.js) —
   descendant selector so both match. center = default object-position (no class). */
.esteri-hero-focus-left img   { object-position: left center; }
.esteri-hero-focus-right img  { object-position: right center; }
.esteri-hero-focus-top img    { object-position: center top; }
.esteri-hero-focus-bottom img { object-position: center bottom; }
/* Editor === live: force the SAME 3:2 crop on the featured-image preview in the
   block-editor canvas, so the customer sees the real crop (not the full image).
   theme.css is loaded in the editor via add_editor_style, but the editor's
   featured-image wrapper can leave the img uncropped without this explicit rule. */
.editor-styles-wrapper .esteri-article-figure img,
.editor-styles-wrapper .wp-block-post-featured-image img {
	aspect-ratio: 3 / 2;
	object-fit: cover;
	width: 100%;
	height: 100%;
}

/* ── Long-form prose (post_content) ─────────────────────────────────────── */
.esteri-article-prose > p {
	font-size: 1rem;
	line-height: 1.625;
	color: hsl(222 30% 12% / 0.8);
	margin: 0 0 1.5rem;
}
.esteri-article-prose > h2,
.esteri-article-prose > h3,
.esteri-article-prose > h4 {
	font-weight: 700;
	color: var(--wp--preset--color--foreground);
	letter-spacing: -0.02em;
	margin: 2.5rem 0 1rem;
	scroll-margin-top: 6rem;
}
.esteri-article-prose > h2 { font-size: 1.5rem; line-height: 2rem; }
@media (min-width: 768px) { .esteri-article-prose > h2 { font-size: 1.875rem; line-height: 2.25rem; } }
.esteri-article-prose > h3 { font-size: 1.25rem; }
.esteri-article-prose > h4 { font-size: 1.125rem; }

/* List — source bullets are foreground dots; items text-sm/foreground-80.
 * Source (ArticleDetailPage "list" block): ul `my-6 space-y-2 pl-4` (16px),
 * li flex with a 6px dot + `gap-3` (12px) → text starts 34px from the prose
 * edge. The old 20px + 24px indent put it at 44px — a 10px narrower text
 * column that wrapped one line long at phone widths ("1600 kier/min…" li:
 * 3 lines @360 vs the design's 2). */
.esteri-article-prose > ul {
	margin: 1.5rem 0;
	padding-left: 1rem;
	list-style: none;
}
.esteri-article-prose > ul > li {
	position: relative;
	font-size: 0.9375rem;
	line-height: 1.625;
	color: hsl(222 30% 12% / 0.8);
	padding-left: 1.125rem;
	margin-bottom: 0.5rem;
}
.esteri-article-prose > ul > li::before {
	content: '';
	position: absolute;
	left: 0;
	top: 0.6em;
	width: 6px;
	height: 6px;
	border-radius: 9999px;
	background: var(--wp--preset--color--foreground);
}

/* Inline image (core/image figure) — cropped to a uniform 3:2 frame, matching the
   Lovable design (ArticleDetailPage `aspect-[3/2] overflow-hidden object-cover`).
   Because the crop can hide the subject (e.g. a face in a corner), the per-image
   "Kuvan rajaus" sidebar control (article-settings-panel.js) writes an
   `esteri-img-focus-<edge>` class that shifts object-position so the important part
   stays in view — same idea as the hero focus. image-in-fixed-frame.md § 4. */
/* Locked size: every inline article image is the full-column 3:2 frame. Force
   the figure to full width + beat any user-set width/resize (the "Kuvan rajaus"
   crop control still works — it only shifts object-position inside the frame).
   image-in-fixed-frame.md § 4 (size is fixed; only the crop region is editable). */
.esteri-article-prose > figure.wp-block-image { margin: 2rem 0; display: block; width: 100% !important; max-width: 100% !important; }
/* A TOP-LEVEL content image's FIGURE takes the prose measure; the IMAGE inside it keeps
   whatever size the author chose (Kuvan koko / drag). cont.57 had locked size "on ANY
   page" and the editor mirror hid the resize handles with it, but the lock was only ever
   half-real — the figure was pinned, never the img, so a `sizeSlug` change resized the
   picture anyway — and Saku reversed the intent in cont.82j ("make sure the image size
   can be changed"). Handles are back for pages; the two real locks are narrower and both
   design-parity: the article prose column above (uniform 3:2 crop) and the tuote canvas.
   Direct-child scope leaves gallery / section-pattern images alone.
   image-in-fixed-frame.md § 4. */
/* …except when the author has FLOATED the image (Vasen/Oikea). A floated figure has
   to shrink-wrap its image or the float is invisible: at `width:100%` it spans the
   column and the image inside it still sits wherever text-align puts it, which is why
   `alignleft` measured pixel-identical to no alignment (imgLeft 560 for both, cont.82j
   — Saku: "if i now add image and make it smaller its center of the screen"). Center
   and unaligned keep the full-width figure; only left/right opt out.

   The cap is the TEXT MEASURE, not 100%. `max-width:100%` let the figure fill the
   whole padding box while every paragraph around it stays capped at wideSize, so a
   left-aligned image hung outside the text column: measured 10px out in the editor
   canvas and ~150px out live at 1920 (Saku, cont.82j: "now its too far left"). Same
   box as the prose ⇒ the image lines up with the first character of the text at every
   width. Below the cap this is a no-op (the container is already narrower). */
.entry-content > figure.wp-block-image:not(.alignleft):not(.alignright) {
	width: 100% !important;
	max-width: var(--wp--style--global--wide-size, 1508px) !important;
	margin-inline: auto;
}
/* Video gets the image figure's geometry (Saku, cont.82i: "the image pattern should be
   able to handle videos aswell"). core/video shipped with no styling of its own here, so
   an inserted video rendered at whatever intrinsic size the file has, unaligned with the
   prose it sits in. Same rail width + centring + radius as a content image; the aspect
   ratio stays the file's own (no crop — there is no focal point to protect). */
.entry-content > figure.wp-block-video { width: 100% !important; max-width: 100% !important; }
:where(.entry-content, .esteri-article-prose) figure.wp-block-video video {
	display: block;
	width: 100%;
	height: auto;
	background: var(--wp--preset--color--muted);
	border-radius: var(--radius);
}
/* A video in a layout column is capped in HEIGHT: at the column's full width a
   portrait phone clip (9:16) stood 1340px tall at 1920, taller than the screen
   (Saku 2026-11-05, /ratkaisut/hevostallit/). width/height auto + both maxima
   keep the file's aspect ratio; the clip centres in its column. No .entry-content
   in the selector, so the editor canvas gets the same box. */
.wp-block-column > figure.wp-block-video video {
	display: block;
	width: auto;
	max-width: 100%;
	max-height: min(80vh, 45rem);
	margin-inline: auto;
}

/* Ratkaisu: blocks the author places straight in the page (a core/columns with
   text + video, a spacer) sat on the post-content's 1508px cap, which has no
   inner edge — at 1920 their text started 56px left of every section's text
   (Saku 2026-11-05, /ratkaisut/taloyhtiot/). Same box as a .wire-container's
   content, like the Santtu search rule; below ~1600px the global padding already
   matches. A BAND keeps its own width: alignfull / .wire-section, or a block
   that holds one (hevostallit has its Haaste band inside a one-column columns). */
.single-ratkaisu .entry-content > :where(:not(.alignfull, .alignleft, .alignright, .wire-section, :has(.alignfull, .wire-section))) {
	max-width: calc(1508px - 2 * var(--wire-edge, 3.5rem));
}
/* …and the band's vertical edge: a loose block next to a band gets the 5rem every
   .wire-section pads itself with, as on composed pages — the text + video columns
   sat flush against the products band above and the Leadoo band below (Saku
   2026-11-05: "the video needs spacing on pc"). A spacer next to a band stays the
   author's own gap. The gap below a loose block is the next band's top margin
   (:has() can't nest), exactly as the editor mirror in functions.php does it. */
.single-ratkaisu .entry-content > :is(.alignfull, .wire-section, :has(.alignfull, .wire-section)) + :not(.alignfull, .wire-section, :has(.alignfull, .wire-section), .wp-block-spacer),
.single-ratkaisu .entry-content > :not(.alignfull, .wire-section, :has(.alignfull, .wire-section), .wp-block-spacer) + :is(.alignfull, .wire-section, :has(.alignfull, .wire-section)) {
	margin-block-start: 5rem;
}
.single-ratkaisu .entry-content > :not(.alignfull, .wire-section, :has(.alignfull, .wire-section), .wp-block-spacer):last-child {
	margin-block-end: 5rem;
}

/* A floated video (Oikea/Vasen) fills a phone's column, so the text flows on
   below it — with no gap, the heading touched the video / cookie notice (Saku
   2026-11-05, /ratkaisut/taloyhtiot/ on mobile). The float's own bottom margin
   is what moves the next line down. Wider, the text sits beside it. */
@media (max-width: 639.98px) {
	.wp-block-embed:is(.alignleft, .alignright) { margin-bottom: 2rem; }
}

/* Video embed the visitor's cookie choice blocks (sl-esteri-core
   includes/embed-consent.php). Cookiebot hides the iframe; the notice takes the
   player's exact box (the aspect-ratio wrapper, or the iframe's own width/height
   inline when the embed has no aspect class), so the text the author laid out
   beside the video keeps its place. Styled as one of the site's
   dark brand panels — the hero gradient, white text, an accent-ringed play mark
   and the green primary button with its white hover (Saku 2026-11-05: "the
   notification can be more theme looking"). */
.esteri-embed-consent {
	display: flex;
	flex-direction: column;
	align-items: center;
	justify-content: center;
	gap: 1.25rem;
	width: 100%;
	padding: 2rem 1.5rem;
	border-radius: var(--radius);
	background: var(--gradient-hero);
	color: hsl(0 0% 100%);
	text-align: center;
}
.wp-has-aspect-ratio .esteri-embed-consent { position: absolute; inset: 0; }
.esteri-embed-consent[hidden] { display: none; }
.esteri-embed-consent__icon {
	display: inline-flex;
	align-items: center;
	justify-content: center;
	width: 3.5rem;
	height: 3.5rem;
	border: 2px solid var(--wp--preset--color--accent);
	border-radius: 9999px;
	color: var(--wp--preset--color--accent);
}
.esteri-embed-consent__icon svg { width: 1.5rem; height: 1.5rem; margin-left: 3px; /* optical centre of the triangle */ }
.esteri-embed-consent__text {
	margin: 0;
	max-width: 20rem;
	font-size: 0.9375rem;
	line-height: 1.6;
	color: hsl(0 0% 100% / 0.8);
}
.esteri-embed-consent__btn {
	padding: 0.75rem 1.25rem;
	font: inherit;
	font-size: 0.9375rem;
	font-weight: 600;
	line-height: 1.5rem;
	color: var(--wp--preset--color--primary-deep);
	background: var(--wp--preset--color--accent);
	border: 0;
	border-radius: var(--radius);
	cursor: pointer;
	transition: background-color 0.15s ease-out, color 0.15s ease-out;
}
.esteri-embed-consent__btn:hover,
.esteri-embed-consent__btn:focus-visible {
	background: var(--wp--preset--color--background);
	color: var(--wp--preset--color--foreground);
}
.esteri-embed-consent__btn:focus-visible { outline: 2px solid var(--wp--preset--color--accent); outline-offset: 2px; }

/* ── Cookiebot consent banner — the old esteri.com look + the Esteri logo ────
 *
 * Saku 2026-11-09: "use that old one and add esteri's logo there", layout "the
 * one the cookiebot settings says". So:
 *   - Layout is Cookiebot's own, from the account (today the bottom bar, which
 *     opens the centred panel on "Näytä tiedot"). Nothing here moves or
 *     re-lays-out the dialog.
 *   - The look is the old Joomla site's banner CSS, carried over as it was
 *     (taken from the static export's <head>, ~/work/Esteri-Staattinenversio):
 *     Droid Sans, 18/14px copy, 12px pill buttons, green "Salli kaikki".
 *     Its `!important`s stay: Cookiebot styles itself through
 *     document.adoptedStyleSheets, which cascade after every <link>/<style>.
 *   - Added: the Esteri logo, and one fix (marked below) for the panel's
 *     buttons, which the old rules pushed off-screen at ≥1280px.
 *
 * Droid Sans (Apache 2.0) is the old site's self-hosted copy. Declared here,
 * not in theme.json, so it never shows up as an editor font; a browser fetches
 * it only when the banner's text uses it. */
@font-face {
	font-family: "Droid Sans";
	font-style: normal;
	font-weight: 400;
	font-display: swap;
	src: url("fonts/DroidSans-400.woff") format("woff");
}
@font-face {
	font-family: "Droid Sans";
	font-style: normal;
	font-weight: 700;
	font-display: swap;
	src: url("fonts/DroidSans-700.woff") format("woff");
}

/* — the old site's rules, unchanged — */
#CybotCookiebotDialog,
#CybotCookiebotDialog * {
	box-sizing: border-box !important;
	font-family: "Droid Sans", Arial, "Helvetica Neue", Helvetica, sans-serif !important;
}
#CybotCookiebotDialogBody,
#CybotCookiebotDialogBodyButtons {
	padding: 20px 10px !important;
}
#CybotCookiebotDialogBodyContent {
	padding: 0 !important;
}
#CybotCookiebotDialogBodyButtons {
	margin: 0 !important;
}
#CybotCookiebotDialogBodyContentTitle {
	font-size: 18px !important;
	margin: 0 0 15px 0 !important;
}
#CybotCookiebotDialogBodyContentText {
	font-size: 14px !important;
	line-height: 1.4 !important;
	margin: 0 0 20px 0 !important;
}
.CybotCookiebotDialogBodyButton {
	font-size: 12px !important;
	max-width: 100% !important;
	min-width: 100% !important;
	margin: 0 !important;
	padding: 7px 13px !important;
	border: 1px solid transparent !important;
	border-radius: 25px !important;
	transition: background-color .2s ease-in-out;
}
#CybotCookiebotDialogBodyButtonAccept {
	background-color: #95be0e !important;
}
#CybotCookiebotDialogBodyButtonDecline {
	margin: 0 0 10px 0 !important;
}
#CybotCookiebotDialogDetailBody,
#CybotCookiebotDialogFooter {
	padding: 0 10px !important;
}
@media screen and (min-width: 768px) {
	.CybotCookiebotDialogBodyButton {
		max-width: calc(100% - 30px) !important;
		min-width: calc(100% - 30px) !important;
	}
	#CybotCookiebotDialogBodyButtonDecline {
		margin: 10px 0 !important;
	}
}
@media screen and (max-width: 1279px) {
	#CybotCookiebotDialogFooter #CybotCookiebotDialogBodyButtonsWrapper {
		justify-content: space-between;
	}
	#CybotCookiebotDialogBodyButtonDecline.CybotCookiebotDialogBodyButton,
	#CybotCookiebotDialogBodyButtonAccept.CybotCookiebotDialogBodyButton {
		max-width: calc(50% - 30px) !important;
		min-width: calc(50% - 30px) !important;
		margin: 0 10px !important;
		height: 40px !important;
	}
}
@media screen and (max-width: 600px) {
	#CybotCookiebotDialogBodyButtonDecline.CybotCookiebotDialogBodyButton,
	#CybotCookiebotDialogBodyButtonAccept.CybotCookiebotDialogBodyButton {
		max-width: calc(100% - 30px) !important;
		min-width: calc(100% - 30px) !important;
		margin: 10px 0 !important;
	}
}

/* — FIX: the "Näytä tiedot" panel at ≥1280px. —
 * The old full-width buttons were written for the bar, where Cookiebot stacks
 * them in a column. The panel (no `CybotEdge` class) sets them side by side in
 * a row, so two 100%-wide pills pushed "Vain välttämättömät" off the left edge.
 * The panel gets the old site's own side-by-side rule from ≤1279px instead. */
@media screen and (min-width: 1280px) {
	#CybotCookiebotDialog:not(.CybotEdge) #CybotCookiebotDialogFooter #CybotCookiebotDialogBodyButtonsWrapper {
		justify-content: space-between;
	}
	#CybotCookiebotDialog:not(.CybotEdge) #CybotCookiebotDialogBodyButtonDecline.CybotCookiebotDialogBodyButton,
	#CybotCookiebotDialog:not(.CybotEdge) #CybotCookiebotDialogBodyButtonAccept.CybotCookiebotDialogBodyButton {
		max-width: calc(50% - 30px) !important;
		min-width: calc(50% - 30px) !important;
		margin: 0 10px !important;
		height: 40px !important;
	}
}

/* — ADDED: the Esteri logo — painted into the slot Cookiebot reserves for the
 * site's logo. `--esteri-cookiebot-logo` is printed by esteri_cookiebot()
 * (functions.php) from the same image and size the header uses. The vendor's
 * <img> in that slot is a `data:,` placeholder; were a logo ever uploaded in the
 * Cookiebot account it would sit on top of this one, so it stays hidden.
 * Cookiebot's own mark stays — it is the vendor's attribution. Specificity: the
 * root id twice, one id more than Cookiebot's own slot rules. */
#CybotCookiebotDialog#CybotCookiebotDialog #CybotCookiebotDialogPoweredbyLink {
	min-height: 40px;
	background: var(--esteri-cookiebot-logo, none) left center / 160px auto no-repeat;
}
#CybotCookiebotDialog#CybotCookiebotDialog #CybotCookiebotDialogPoweredbyImage {
	display: none !important;
}
/* Bar ≥1280 (Cookiebot's own breakpoint for it): the left column — logo
 * centred, Cookiebot's mark below. Cookiebot gives the slot half the 210px
 * column (`width: 50%`), which cropped a 160px logo; here it takes the column. */
@media (min-width: 1280px) {
	#CybotCookiebotDialog#CybotCookiebotDialog.CybotEdge #CybotCookiebotDialogPoweredbyLink {
		width: 100%;
		background-position: center;
	}
}
/* Phones: a smaller logo, in proportion to the 390px row. */
@media (max-width: 600px) {
	#CybotCookiebotDialog#CybotCookiebotDialog #CybotCookiebotDialogPoweredbyLink {
		min-height: 32px;
		background-size: 128px auto;
	}
}

/* A table with a green background gets the dark text the site puts on green
   everywhere (accent-foreground, the green buttons' text). Inside a bold page
   hero it inherited the panel's white, unreadable on green (Saku 2026-11-05,
   /palvelut/suunnittelu-ja-mitoitus/). A text colour the author picked wins. */
.wp-block-table table:is(.has-accent-background-color, .has-accent-soft-background-color):not(.has-text-color) {
	color: var(--wp--preset--color--accent-foreground);
}
/* A standalone content image on a PAGE follows the AUTHOR'S alignment, and nothing
   else. Centring used to be forced on every top-level page image (cont.58: "the
   image pattern should be centered"), which had two costs: the alignment toolbar was
   completely inert — `alignleft` measured pixel-identical to no alignment on both
   origins — and an image the author merely made SMALLER jumped to the middle of the
   column, reported twice (Saku, cont.82j: "if i add image and make it smaller its
   center of the screen"). His later call wins over cont.58.

   So: no alignment = flow position (flush left, which is what core does and what the
   editor already showed); `aligncenter` centres inside the full-width figure;
   `alignleft`/`alignright` float and therefore opt out of the width lock above.
   Articles force the img to 100% width (the .esteri-article-prose rule above), so
   none of this is visible there; scoped off real articles to be safe.
   image-in-fixed-frame.md § 4. Keep in step with the editor mirror in functions.php. */
body:not(.single-artikkeli) .entry-content > figure.wp-block-image.aligncenter { text-align: center; }
body:not(.single-artikkeli) .entry-content > figure.wp-block-image.aligncenter > img { display: inline-block; }
/* A FLOATED figure shrink-wraps, so it can't take the measure as a max-width — give it
   the measure's gutter as a margin instead. Without this, left/right images floated to
   the padding box while the prose stayed inside the 1508 measure: 150px outside the
   text column at 1920 (aligned already below the cap, where max() collapses to 0). */
body:not(.single-artikkeli) .entry-content > figure.wp-block-image.alignleft {
	margin-left: max(0px, calc((100% - var(--wp--style--global--wide-size, 1508px)) / 2));
}
body:not(.single-artikkeli) .entry-content > figure.wp-block-image.alignright {
	margin-right: max(0px, calc((100% - var(--wp--style--global--wide-size, 1508px)) / 2));
}
/* Inline article images keep the design's uniform 3:2 frame (Lovable
   ArticleDetailPage `aspect-[3/2] object-cover`); the per-image "Kuvan rajaus"
   control (article-settings-panel.js) shifts object-position within it.
   Alignment is intentionally NOT offered for these images (Saku) — every
   article image is the same full-width 3:2 box. image-in-fixed-frame.md § 4. */
.esteri-article-prose > figure.wp-block-image img {
	display: block;
	width: 100%;
	aspect-ratio: 3 / 2;
	object-fit: cover;
	background: var(--wp--preset--color--muted);
	border-radius: var(--radius);
}
/* Crop focus for inline images — which edge of the 3:2 crop stays visible.
   center = default object-position (no class). Used both front-end (render_block
   filter) and in the editor canvas (BlockListBlock HOC adds the same class);
   theme.css is an editor style so these rules apply on both sides. */
.esteri-img-focus-left img   { object-position: left center; }
.esteri-img-focus-right img  { object-position: right center; }
.esteri-img-focus-top img    { object-position: center top; }
.esteri-img-focus-bottom img { object-position: center bottom; }
.esteri-article-prose > figure.wp-block-image figcaption {
	margin-top: 0.75rem;
	font-family: 'Space Mono', monospace;
	font-size: 0.9375rem;
	line-height: 24px;   /* source figcaption `mt-3 text-sm font-mono` pair (measured 24px) */
	color: var(--wp--preset--color--muted-foreground);
	text-align: center;
}

/* Quote — secondary panel, green left border + decorative quote glyph top-right.
   Applies to EVERY core/quote in content, not only the pattern's
   `.is-style-esteri-article-quote` (Saku, cont.82i: "quote is not same as sitaatti
   pattern"). A plain Gutenberg Quote block had NO styling of its own — the design's
   look lived entirely behind a class the Sitaatti pattern hardcoded, and which was
   never even registered as a block style, so an author could neither get it on a
   quote they inserted themselves nor pick it from the Styles panel. One appearance for
   quotes site-wide; the class stays in the selectors so existing content is untouched
   (it is now a no-op alias). Scoped to content roots so a quote inside a designed
   pattern/band keeps whatever that band specifies. */
:where(.entry-content, .esteri-article-prose) .wp-block-quote,
.is-style-esteri-article-quote {
	position: relative;
	padding: 1.75rem 1.5rem;
	background: hsl(220 20% 96% / 0.6);
	border-left: 2px solid var(--wp--preset--color--accent);
	border-radius: var(--radius);
}
@media (min-width: 768px) {
	:where(.entry-content, .esteri-article-prose) .wp-block-quote,
	.is-style-esteri-article-quote { padding: 2rem; }
}
/* Own vertical margin ONLY in article prose, which has no `* + *` rhythm of its own.
   On a page the content rhythm (3.5rem) is the spacing and a 3rem margin just collapsed
   under it — declaring it there made the editor canvas and live disagree on which
   element owned the gap, with no visual difference either way. */
.esteri-article-prose .wp-block-quote,
.esteri-article-prose .is-style-esteri-article-quote { margin: 3rem 0; }
:where(.entry-content, .esteri-article-prose) .wp-block-quote::before,
.is-style-esteri-article-quote::before {
	content: '';
	position: absolute;
	top: -1rem;
	right: 1.25rem;
	width: 40px;
	height: 40px;
	background: var(--wp--preset--color--accent);
	-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z'/%3E%3Cpath d='M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z'/%3E%3C/svg%3E") center / contain no-repeat;
	mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z'/%3E%3Cpath d='M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z'/%3E%3C/svg%3E") center / contain no-repeat;
}
:where(.entry-content, .esteri-article-prose) .wp-block-quote p,
.is-style-esteri-article-quote p {
	font-size: 1.125rem;
	line-height: 1.375;
	font-weight: 500;
	letter-spacing: -0.01em;
	color: var(--wp--preset--color--foreground);
	padding-right: 2.5rem;
	margin: 0;
}
@media (min-width: 768px) {
	:where(.entry-content, .esteri-article-prose) .wp-block-quote p,
	.is-style-esteri-article-quote p { font-size: 1.25rem; }
}
:where(.entry-content, .esteri-article-prose) .wp-block-quote cite,
.is-style-esteri-article-quote cite {
	display: flex;
	align-items: center;
	gap: 0.75rem;
	margin-top: 1.25rem;
	font-family: 'Space Mono', monospace;
	font-size: 0.75rem;
	font-style: normal;
	letter-spacing: 0.05em;
	text-transform: uppercase;
	color: var(--wp--preset--color--muted-foreground);
}
:where(.entry-content, .esteri-article-prose) .wp-block-quote cite::before,
.is-style-esteri-article-quote cite::before {
	content: '';
	width: 1.5rem;
	height: 1px;
	background: hsl(222 10% 42% / 0.4);
}

/* ── Inline CTA panel (core/group.esteri-article-cta) ───────────────────── */
.esteri-article-cta {
	margin: 2.5rem 0;
	padding: 2rem;
	text-align: center;
	border-radius: var(--radius);
}
@media (min-width: 768px) { .esteri-article-cta { padding: 2.5rem; } }
/* Source in-article CTA button row is `flex flex-wrap justify-center gap-3`
 * (ArticleDetailPage). The pattern declares no blockGap, so the buttons sat
 * flush (0 gap) — pin the 0.75rem gap to match. */
.esteri-article-cta .wp-block-buttons { margin-top: 0; gap: 0.75rem; }

/* ── Inline product recommendation (esteri/article-products) ────────────── */
.esteri-article-products { margin: 2.5rem 0; }
.esteri-article-products__title {
	font-size: 1.5rem;
	font-weight: 700;
	letter-spacing: -0.02em;
	color: var(--wp--preset--color--foreground);
	margin: 0 0 1.5rem;
}
@media (min-width: 768px) { .esteri-article-products__title { font-size: 1.875rem; } }
.esteri-article-products__grid { gap: 1rem; }
/* Source caps at lg:grid-cols-3 (≤3 picks); override .esteri-pd-rel-grid's
   4-col 1280 step so 3 picks stay 3 equal columns. */
@media (min-width: 1280px) {
	.esteri-article-products__grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}

/* ── TOC: mobile <details> + sidebar nav ────────────────────────────────── */
.esteri-article-toc--mobile { display: block; margin-bottom: 2rem; border-top: 1px solid hsl(222 13% 88% / 0.6); border-bottom: 1px solid hsl(222 13% 88% / 0.6); }
@media (min-width: 1024px) { .esteri-article-toc--mobile { display: none; } }
.esteri-article-toc__summary {
	display: flex;
	align-items: center;
	justify-content: space-between;
	gap: 0.75rem;
	padding: 0.75rem 0;
	cursor: pointer;
	list-style: none;
}
.esteri-article-toc__summary::-webkit-details-marker { display: none; }
.esteri-article-toc__label {
	font-family: 'Space Mono', monospace;
	font-size: 0.75rem;
	/* Match the Lovable mobile-TOC label: lh 16px (1.3333) + 0.05em tracking.
	 * WP inherited the 1.55 body lh (18.6px) and used 0.1em — both drifted from
	 * source on mobile. This element is only shown <1024px, so no desktop impact. */
	line-height: 1.3333;
	letter-spacing: 0.05em;
	text-transform: uppercase;
	color: var(--wp--preset--color--muted-foreground);
}
.esteri-article-toc__chevron { display: inline-flex; color: var(--wp--preset--color--muted-foreground); transition: transform 0.2s ease; }
.esteri-article-toc--mobile[open] .esteri-article-toc__chevron { transform: rotate(90deg); }
/* Source TOC spacing = nav `space-y-2` (0.5rem) ONLY; links carry NO vertical
   padding (a per-link 0.25rem pad doubled the visible item gap vs Lovable). The
   mobile <details> nav adds source `pb-4`; the desktop sidebar nav does not. */
.esteri-article-toc__nav { display: flex; flex-direction: column; gap: 0.5rem; }
.esteri-article-toc--mobile .esteri-article-toc__nav { padding-bottom: 1rem; }
/* Article sidebar/share mono labels: source inherits 24px @15px (WP body
 * 1.55 gave 23.25). One combined rule (0,1,0) beats the inherited value. */
.esteri-article-toc__link,
.esteri-article-share__label,
.esteri-article-sidebar__heading,
.esteri-article-sidebar__follow-title,
.esteri-article-sidebar__follow-lede,
.esteri-article-related__all { line-height: 1.6; }
.esteri-article-toc__link {
	display: block;
	padding: 0;
	font-family: 'Space Mono', monospace;
	font-size: 0.9375rem;
	color: var(--wp--preset--color--muted-foreground);
	text-decoration: none;
	transition: color 0.15s ease;
	/* A long H2 heading (or an unbroken word/URL in it) must wrap inside the
	   fixed 280px sidebar card and the mobile width — never overflow the card or
	   force a horizontal scrollbar. */
	overflow-wrap: break-word;
	word-break: break-word;
	hyphens: auto;
}
/* H3 subheadings sit indented under their H2 (spec: TOC from H2 + H3). */
.esteri-article-toc__link--h3 { padding-left: 0.875rem; font-size: 0.875rem; }
/* Scroll-spy — the reader's current section, set by article-toc.js via an
   IntersectionObserver. Space Mono's 700 shares the 400 advance, so no reflow. */
.esteri-article-toc__link.is-active { color: var(--wp--preset--color--primary); font-weight: 700; }
/* After .is-active on purpose: the source's `hover:text-brand-green` utility also beats the active
 * colour, so the ACTIVE crumb greens on hover too (hover-sweep read it as DEAD while it sat before). */
.esteri-article-toc__link:hover { color: var(--wp--preset--color--accent); }

/* ── Share row (label + 4 round buttons) ────────────────────────────────── */
.esteri-article-share {
	display: flex;
	flex-wrap: nowrap;
	align-items: center;
	gap: 0.5rem;
	margin-top: 3rem;
	padding-top: 2rem;
	border-top: 1px solid var(--wp--preset--color--border);
}
@media (min-width: 640px) { .esteri-article-share { gap: 0.75rem; } }
.esteri-article-share__label {
	font-family: 'Space Mono', monospace;
	font-size: 0.9375rem;
	color: var(--wp--preset--color--muted-foreground);
	margin-right: 0.25rem;
}
.esteri-article-share__label-full { display: none; }
@media (min-width: 640px) { .esteri-article-share__label-full { display: inline; } }
.esteri-article-share__btn {
	flex-shrink: 0;
	width: 2.5rem;
	height: 2.5rem;
	display: inline-flex;
	align-items: center;
	justify-content: center;
	border-radius: 9999px;
	background: hsl(222 30% 12% / 0.05);
	border: 1px solid var(--wp--preset--color--border);
	color: hsl(222 30% 12% / 0.7);
	transition: color 0.15s cubic-bezier(0.4, 0, 0.2, 1), background-color 0.15s cubic-bezier(0.4, 0, 0.2, 1), border-color 0.15s cubic-bezier(0.4, 0, 0.2, 1);   /* source: transition-colors */
}
@media (min-width: 640px) { .esteri-article-share__btn { width: 2.75rem; height: 2.75rem; } }
.esteri-article-share__btn:hover {
	background: var(--wp--preset--color--accent);
	border-color: var(--wp--preset--color--accent);
	color: var(--wp--preset--color--accent-foreground, hsl(222, 68%, 20%));   /* source: hover:text-accent-foreground */
}

/* ── Sidebar (desktop, sticky) ──────────────────────────────────────────── */
.esteri-article-sidebar { display: none; }
@media (min-width: 1024px) { .esteri-article-sidebar { display: block; } }
.esteri-article-sidebar__sticky { position: sticky; top: 100px; display: flex; flex-direction: column; gap: 2rem; }
.esteri-article-sidebar__card { border: 1px solid var(--wp--preset--color--border); padding: 1.25rem; }
.esteri-article-sidebar__heading {
	display: block;
	margin-bottom: 1rem;
	font-family: 'Space Mono', monospace;
	font-size: 0.9375rem;
	font-weight: 700;
	letter-spacing: 0.05em;
	text-transform: uppercase;
	color: var(--wp--preset--color--muted-foreground);
}
.esteri-article-sidebar__author { display: flex; align-items: center; gap: 0.75rem; }
.esteri-article-sidebar__author-meta { display: flex; flex-direction: column; }
/* Both are `text-sm font-mono` in the source sidebar → the 15/24 pair, not the
 * body's 1.55 (measured on the live source: 24px). */
.esteri-article-sidebar__author-name {
	font-family: 'Space Mono', monospace;
	font-weight: 700;
	font-size: 0.9375rem;
	line-height: 24px;
	color: var(--wp--preset--color--foreground);
}
.esteri-article-sidebar__author-role {
	font-family: 'Space Mono', monospace;
	font-size: 0.9375rem;
	line-height: 24px;
	color: var(--wp--preset--color--muted-foreground);
}
.esteri-article-sidebar__follow-title {
	display: block;
	margin-bottom: 0.5rem;
	font-family: 'Space Mono', monospace;
	font-weight: 700;
	font-size: 0.9375rem;
	color: var(--wp--preset--color--foreground);
}
.esteri-article-sidebar__follow-lede {
	font-size: 0.9375rem;
	line-height: 1.6;   /* source 24px @15px (was 1.55 → 23.25) */
	color: var(--wp--preset--color--muted-foreground);
	margin: 0 0 1rem;
}
.esteri-article-sidebar__social-row { display: flex; align-items: center; gap: 0.5rem; }
.esteri-article-sidebar__social-btn {
	width: 2.25rem;
	height: 2.25rem;
	display: inline-flex;
	align-items: center;
	justify-content: center;
	border-radius: 9999px;
	background: hsl(222 30% 12% / 0.05);
	border: 1px solid var(--wp--preset--color--border);
	color: hsl(222 30% 12% / 0.7);
	transition: color 0.15s cubic-bezier(0.4, 0, 0.2, 1), background-color 0.15s cubic-bezier(0.4, 0, 0.2, 1), border-color 0.15s cubic-bezier(0.4, 0, 0.2, 1);   /* source: transition-colors */
}
.esteri-article-sidebar__social-btn:hover {
	background: var(--wp--preset--color--accent);
	border-color: var(--wp--preset--color--accent);
	color: var(--wp--preset--color--accent-foreground, hsl(222, 68%, 20%));   /* source: hover:text-accent-foreground */
}

/* ── Related strip ("Aiheeseen liittyvät artikkelit") ───────────────────── */
.esteri-article-related { padding-top: 5rem; padding-bottom: 5rem; background: hsl(220 14% 94% / 0.4); }
.esteri-article-related__head { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 2rem; }
.esteri-article-related__eyebrow { display: block; margin-bottom: 0.5rem; }
.esteri-article-related__eyebrow::before { display: none; } /* source `wire-label block` collapses the dash */
.esteri-article-related__title {
	font-size: 1.875rem;
	font-weight: 700;
	letter-spacing: -0.025em;
	color: var(--wp--preset--color--foreground);
	margin: 0;
}
@media (min-width: 768px) { .esteri-article-related__title { font-size: 2.25rem; } }
.esteri-article-related__all {
	display: none;
	align-items: center;
	gap: 0.5rem;
	flex-shrink: 0;
	font-family: 'Space Mono', monospace;
	font-size: 0.9375rem;
	letter-spacing: 0.18em;
	text-transform: uppercase;
	color: var(--wp--preset--color--foreground);
	border-bottom: 2px solid var(--wp--preset--color--accent);
	padding-bottom: 0.25rem;
	text-decoration: none;
	transition: color 0.15s ease;
}
@media (min-width: 640px) { .esteri-article-related__all { display: inline-flex; } }
.esteri-article-related__all:hover { color: var(--wp--preset--color--primary-deep); }
.esteri-article-related__all-arrow {
	width: 16px;
	height: 16px;
	background: currentColor;
	-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7 17 17 7'/%3E%3Cpath d='M7 7h10v10'/%3E%3C/svg%3E") center / contain no-repeat;
	mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7 17 17 7'/%3E%3Cpath d='M7 7h10v10'/%3E%3C/svg%3E") center / contain no-repeat;
	transition: transform 0.2s ease;
}
.esteri-article-related__all:hover .esteri-article-related__all-arrow { transform: translate(2px, -2px); }
.esteri-article-related__grid { display: grid; grid-template-columns: 1fr; gap: 1rem; }
@media (min-width: 640px)  { .esteri-article-related__grid { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 1024px) { .esteri-article-related__grid { grid-template-columns: repeat(3, 1fr); } }

/* Inline CTA typography (scoped — heading/paragraph styled by container so the
   block markup stays attribute-light + valid). Source: cta block text-2xl
   md:text-3xl white heading + white/75 lede (max-w-lg, mb-8). */
.esteri-article-cta .wp-block-heading {
	color: var(--wp--preset--color--primary-foreground);
	font-size: 1.5rem;
	font-weight: 700;
	letter-spacing: -0.02em;
	margin: 0 0 0.75rem;
}
@media (min-width: 768px) { .esteri-article-cta .wp-block-heading { font-size: 1.875rem; } }
.esteri-article-cta p {
	color: hsl(0 0% 100% / 0.75);
	font-size: 1rem;
	line-height: 1.625;
	max-width: 32rem;
	margin: 0 auto 2rem;
}

/* ─── 404 / NotFound template ─────────────────────────────────────
 * Source: src/pages/NotFound.tsx. <main flex-1 flex items-center>;
 * inner frame max-w-[1440px] mx-auto px-6 sm:px-10 py-24 sm:py-32;
 * content max-w-3xl, LEFT-aligned. Bordered status-chip eyebrow with a
 * green dot, huge mono 404 numeral (6→9→11rem), h2, muted lede, two
 * CTAs (stack full-width on mobile), divider + animated "Palaa
 * etusivulle" link. layout:default everywhere so children stay
 * left-aligned (layout:constrained would center them). */
/* NB: no `vh`-based min-height here. The source centres content with
 * `flex-1` on a `min-h-screen flex-col` body (self-referential, stable);
 * a `vh` floor on this inner element instead feeds back through the
 * fullpage-capture viewport-resize and inflates the page 20–40 %. The
 * frame's py-24/py-32 padding supplies the vertical breathing room. */
.esteri-404__frame {
	width: 100%;
	max-width: 1440px;
	margin-inline: auto;
	padding-inline: 1.5rem;   /* px-6 */
	padding-block: 6rem;      /* py-24 */
}
@media (min-width: 640px) {
	.esteri-404__frame {
		padding-inline: 2.5rem;  /* sm:px-10 */
		padding-block: 8rem;     /* sm:py-32 */
	}
}
.esteri-404__content {
	max-width: 48rem;          /* max-w-3xl */
}

/* Eyebrow status-chip: bordered, mono, with a 6px green dot. NOT the
 * wire-label bar — NotFound uses a distinct bordered chip + dot. */
.esteri-404__eyebrow {
	display: inline-flex;
	align-items: center;
	gap: 0.5rem;               /* gap-2 */
	margin: 0 0 2rem;          /* mb-8 */
	padding: 0.25rem 0.625rem; /* py-1 px-2.5 */
	border: 1px solid var(--wp--preset--color--border, hsl(220, 14%, 88%));
	font-family: 'Space Mono', monospace;
	font-size: 0.75rem;        /* text-xs */
	letter-spacing: 0.05em;    /* tracking-wider */
	line-height: 1rem;         /* text-xs bundled lh (16px) */
	color: var(--wp--preset--color--muted-foreground, hsl(222, 10%, 42%));
	max-width: 100%;           /* L8: cap the inline-flex badge to the viewport… */
	overflow-wrap: anywhere;   /* …and let a hostile no-space token wrap, not overflow @390 */
}
.esteri-404__eyebrow::before {
	content: '';
	width: 0.375rem;           /* w-1.5 (6px) */
	height: 0.375rem;
	border-radius: 50%;
	background: var(--wp--preset--color--accent, hsl(74, 87%, 40%));
	flex-shrink: 0;
}

/* Huge mono numeral — 6rem base, 9rem ≥640, 11rem ≥1024. */
.esteri-404__big {
	margin: 0 0 2rem;          /* mb-8 */
	font-family: 'Space Mono', monospace;
	font-weight: 600;          /* font-semibold */
	line-height: 1;            /* leading-none */
	letter-spacing: -0.025em;  /* tracking-tight */
	font-size: 6rem;
	color: var(--wp--preset--color--foreground, hsl(222, 30%, 12%));
}
@media (min-width: 640px)  { .esteri-404__big { font-size: 9rem; } }
@media (min-width: 1024px) { .esteri-404__big { font-size: 11rem; } }

.esteri-404__title {
	margin: 0 0 1rem;          /* mb-4 */
	font-size: 1.5rem;         /* text-2xl */
	font-weight: 600;
	line-height: 2rem;         /* text-2xl bundled lh (32px) */
	color: var(--wp--preset--color--foreground, hsl(222, 30%, 12%));
}
@media (min-width: 640px) {
	.esteri-404__title {
		font-size: 1.875rem;   /* sm:text-3xl */
		line-height: 2.25rem;  /* text-3xl bundled lh (36px) */
	}
}

.esteri-404__lede {
	max-width: 36rem;          /* max-w-xl */
	margin: 0 0 2.5rem;        /* mb-10 */
	line-height: 1.55;         /* inherits source body 1.55 */
	color: var(--wp--preset--color--muted-foreground, hsl(222, 10%, 42%));
	overflow-wrap: anywhere;   /* L8: hostile no-space tokens must wrap, not overflow @390 */
}
.esteri-404__title, .esteri-404__big { overflow-wrap: anywhere; }

/* Action buttons: row on desktop, stacked full-width on mobile
 * (source flex flex-col sm:flex-row gap-3). */
.esteri-404__actions {
	gap: 0.75rem;              /* gap-3 */
}
/* Source NotFound.tsx renders btn-primary + btn-outline-dark equal-height in a
 * flex ROW (56/56) via cross-axis stretch — reverting to NATURAL heights when
 * the row stacks at mobile (filled 52, bordered outline 56). Reproduce the
 * mechanism, NOT a transparent-2px-border on the filled button: that matched
 * 56 in the row but stayed 56 when stacked, 4px too tall vs the source's 52
 * @390 (caught by parity-diff STEP 4d). Stretch the row + make each
 * .wp-block-button a flex container so the stretch reaches the nested <a>;
 * restore the outline's 14px padding (WP-core is-style-outline shrinks it to
 * 0.667em). Same mechanism as `.esteri-final-cta` / `.esteri-hero-front`. */
.esteri-404__actions { align-items: stretch; }
.esteri-404__actions .wp-block-button { display: flex; }
.esteri-404__actions .wp-block-button.is-style-outline > .wp-block-button__link { padding: 14px 1.75rem; }
@media (max-width: 639px) {
	.esteri-404__actions {
		flex-direction: column;
		align-items: stretch;
	}
	.esteri-404__actions .wp-block-button,
	.esteri-404__actions .wp-block-button__link {
		width: 100%;
	}
}

/* Divider + animated back-to-home link. */
.esteri-404__back {
	margin-top: 3rem;          /* mt-12 */
	padding-top: 2rem;         /* pt-8 */
	border-top: 1px solid var(--wp--preset--color--border, hsl(220, 14%, 88%));
}
.esteri-404__back-p { margin: 0; }
.esteri-404__home-link {
	position: relative;
	display: inline-flex;
	align-items: center;
	gap: 0.375rem;             /* gap-1.5 */
	padding-bottom: 0.125rem;  /* pb-0.5 */
	font-size: 0.9375rem;      /* text-sm = 15px (was a 14px mis-port) */
	line-height: 1.6;          /* source computes 24px @15px (project text-sm ≠ Tailwind's 20px default) */
	color: var(--wp--preset--color--muted-foreground, hsl(222, 10%, 42%));
	text-decoration: none;
	transition: color 0.2s ease;
}
.esteri-404__home-link:hover { color: var(--wp--preset--color--accent, hsl(74, 87%, 40%)); }
/* Green underline sweep (nav-link-hover-flush). */
.esteri-404__home-link::before {
	content: '';
	position: absolute;
	left: 0;
	right: 0;
	bottom: 0;
	height: 2px;
	background: var(--wp--preset--color--accent, hsl(74, 87%, 40%));
	transform: scaleX(0);
	transform-origin: left;
	transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.esteri-404__home-link:hover::before { transform: scaleX(1); }
/* 14px trailing arrow that nudges right on hover. */
.esteri-404__home-link::after {
	content: '';
	width: 14px;
	height: 14px;
	flex-shrink: 0;
	background-color: currentColor;
	-webkit-mask: var(--esteri-arrow-right) center / contain no-repeat;
	        mask: var(--esteri-arrow-right) center / contain no-repeat;
	transition: transform 0.2s ease-out;
}
.esteri-404__home-link:hover::after { transform: translateX(0.25rem); }

/* ── Mobile (<md) line-height parity with the Lovable source ─────────────────
 * The source section headings use Tailwind sizes whose DEFAULT line-height
 * steps WITH the font-size across breakpoints; the WP rules carry a flat
 * line-height ratio that matches at desktop (gated at 1920) but not once the
 * font-size steps down below md, so mobile line-spacing drifted (10–16% on the
 * bigger heads). These restore the source's MOBILE line-height ratio, measured
 * with `parity-typography --width=390 --dump` (ratio = source lh ÷ font-size).
 * Scoped max-width:767.98px so the ≥768 desktop typography gate is untouched.
 * refs: parity-line-height-and-engine-fixes + per-page source components. */
@media (max-width: 767.98px) {
	/* hero titles — src 37.8 / fs 36 = 1.05 */
	.esteri-article-hero__title,
	.esteri-pd-hero__title { line-height: 1.05; }
	/* text-2xl heads — Tailwind default lh 32px / fs 24 = 1.3333. The contact h2
	 * needs the two-class form to out-specify the base `.esteri-contact-info
	 * .esteri-contact-info__h2 { line-height: 1.2 }` (0,2,0) rule. */
	.esteri-article-products__title,
	.esteri-article-cta .wp-block-heading,
	.esteri-contact-info .esteri-contact-info__h2 { line-height: 1.3333; }
	/* leading-tight — src 37.5 / fs 30 = 1.25 (service/warranty/category h2) */
	.esteri-service-action__h2,
	.esteri-warranty-highlight__h2,
	.esteri-products-categories h2 { line-height: 1.25; }
	/* text-3xl default lh 36px / fs 30 = 1.2 (solutions process/use-case h2) */
	.esteri-process__title,
	.esteri-usecases__title { line-height: 1.2; }
	/* leading-tight — src 25 / fs 20 = 1.25 (product feature + long-desc h3) */
	.esteri-pd-feature__title,
	.esteri-pd-longdesc__h3 { line-height: 1.25; }
	/* mobile "Kaikki ratkaisut" wire-link — source inherits 24px @15px = 1.6.
	 * At <768 the desktop is-style-wire-link is hidden and the .esteri-viewall-m
	 * mobile variant shows (no explicit lh → inherited 1.55); match source. */
	.esteri-viewall-m { line-height: 1.6; }
}

/* ── An empty paragraph is the author's blank line ─────────────────────────
 *
 * Saku, 2026-08-11: "now the spaces doesnt work" … "in live page". Pressing
 * Enter to open a gap shows a blank line in the editor and closed-up text on the
 * published page. Two causes, and the strip in
 * includes/render-empty-paragraphs.php was only the first: measured on /tuotteet,
 * injecting an empty `<p></p>` between two paragraphs moved the gap 20px → 20px,
 * because the empty box is 0px tall and its margin collapses straight through.
 * A blank line needs a line's HEIGHT to exist, which is this rule.
 *
 * `:not([class*="esteri-"])` is what keeps it to the author's gesture: every
 * unfilled SLOT in this build carries an `esteri-*` class (spec-row keys, card
 * fields, prose slots) and must stay collapsed — four migrated product pages have
 * blank `esteri-pd-spec-row__key` paragraphs that would otherwise start taking up
 * a line each. Not `:not([class])`, which reads like the obvious test and matches
 * NOTHING on WP 7.0: core stamps `wp-block-paragraph` on every paragraph, so an
 * Enter-made blank line serialises as `<p class="wp-block-paragraph"></p>`
 * (measured — the first version of this rule was silently inert).
 *
 * `1em` first for engines without the `lh` unit; `1lh` is the paragraph's own
 * line box, which is precisely what the editor paints.
 *
 * Front end only by scope (`.entry-content` is the post-content wrapper; the
 * editor canvas is `.is-root-container`) — and that is right here, because the
 * editor already gives an empty RichText its line box for free.
 *
 * A `core/spacer` ("Väli", already inserter-allowed via
 * allow-article-image-block.php) is still the tool for a specific measured gap.
 * This is for the gesture every author reaches for first. */
.entry-content p:empty:not([class*="esteri-"]),
.entry-content p:not([class*="esteri-"]):has(> br:only-child) {
	min-height: 1em;
	min-height: 1lh;
}
