<?php
/**
 * Per-instance CSS generator for Blocksify blocks.
 *
 * @package Blocksify
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Emits instance-scoped CSS rules that pair with each block's static style.css.
 * Static CSS covers equal-ratio layouts via .bsy-has-N-cols classes;
 * this class only emits what static can't express (custom fr ratios, per-device
 * overrides, span reshape rules).
 *
 * Reads flat suffixed attributes (`gap`, `gapTablet`, `gapMobile`). Cascade
 * resolution is implicit in the CSS media-query stack — browser applies
 * the tablet rule to the mobile range when mobile has no override.
 *
 * ARCHITECTURE — post-Phase-5 trait extraction (read this first):
 *
 * This class's role is **orchestration**, not emission. Trait CSS lives
 * in `Blocksify_X_Trait` classes (color / background / spacing / border /
 * size / overflow / position / zindex / visibility) — `emit_wrapper_rules`
 * + `emit_hover_wrapper_rules` are sequences of `Blocksify_X_Trait::emit_decls()`
 * calls that compose the per-rule decl string.
 *
 * What stays in this file:
 *   - Generic entry point (`generate_block_css($block_type, $attrs)`) —
 *     walks each declared trait + dispatches to block-specific layout
 *     adapters; wraps trait emit in `@media (max-width:1023px){...}` /
 *     `@media (max-width:767px){...}`.
 *   - **Section-specific** layout emit (`emit_device_rules` — count / fr /
 *     span / gap / rowGap / maxWidth) — block-local because grid layout
 *     is Section's identity, not a generalizable trait.
 *   - **Container-specific** flex emit (`emit_flex_rules` — direction /
 *     justify / align / wrap / row-gap / column-gap) — same reasoning.
 *   - DOM-hide rule orchestrator (`emit_dom_hide_rules`) — calls
 *     `Blocksify_Background_Trait::emit_video_visibility_rules` plus
 *     shape's null-sentinel display:none.
 *   - Backward-compat thin proxies for `bg_attr_key` / `is_meaningful` /
 *     `has_upstream_value` / `sanitize_css_color` — utilities physically
 *     live in `Blocksify_Attrs` now; proxies kept for any third-party Pro
 *     code that calls the generator's static methods directly.
 *
 * What does NOT stay in this file:
 *   - Trait emit logic (lives in `Blocksify_X_Trait::emit_decls`).
 *   - Attribute-key derivation, cascade-source probe, meaningful-value
 *     test, color sanitize (lives in `Blocksify_Attrs`).
 *
 * Block-specific layout adapters: Section's grid + max-width and
 * Container's flex are registered via the hardcoded `$adapters` map
 * inside `generate_block_css`. New blocks (Heading, Button, …) hit the
 * no-adapter path and get pure trait emit + visibility.
 */
class Blocksify_CSS_Generator {

	const TABLET_MAX = 1023;
	const MOBILE_MAX = 767;

	/**
	 * Generic per-instance CSS entry point. Walks each declared trait of
	 * the block (via `emit_wrapper_rules` + `emit_hover_wrapper_rules`)
	 * and dispatches to block-specific layout adapters when the block
	 * registers one. Replaces former per-block entries
	 * (`generate_section_css` / `generate_container_css`).
	 *
	 * Block-name → base-class derivation: `bsy-{suffix}-` where
	 * `{suffix}` is the last `/`-separated segment of the block name.
	 * Mirrors the JS class prefix per FOUNDATION §5.1.
	 *
	 * Adapter map (`$adapters`) registers two block-local hooks:
	 *   - `per_device`: method called per device (desktop / tablet /
	 *     mobile), output prepended to the trait wrapper rules INSIDE the
	 *     same @media block. Used by Section's grid + Container's flex.
	 *   - `tail`: method called once after the hover layer, output
	 *     appended OUTSIDE any @media wrapper. Used by Section's
	 *     max-width CSS-var emit.
	 *
	 * Blocks without an adapter (Heading, Button, …) get pure trait emit
	 * + visibility — no per-block code path needed.
	 *
	 * @param string $block_type Full block name (e.g. 'blocksify/section').
	 * @param array  $attributes Block attributes (flat-suffix shape).
	 * @return string CSS rules (no wrapping <style> tag).
	 */
	public static function generate_block_css( $block_type, $attributes ) {
		$uid = self::sanitize_uid( isset( $attributes['uid'] ) ? $attributes['uid'] : '' );
		if ( '' === $uid ) {
			return '';
		}

		$slash  = strrpos( (string) $block_type, '/' );
		$suffix = false === $slash ? (string) $block_type : substr( (string) $block_type, $slash + 1 );
		// Class shorthand registry — PHP twin of `BLOCK_CLASS_SHORTHAND` in
		// `src/shared/helpers.js`. Long block names compress to short selectors
		// (`blocksify/content-loop` → `.bsy-cl-{uid}`). Both sides must consult
		// the same map or JS preview vs PHP frontend produce mismatched classes
		// and trait CSS misses the wrapper.
		$shorthand = array(
			'content-loop' => 'cl',
			'icon-list'    => 'iconlist',
		);
		if ( isset( $shorthand[ $suffix ] ) ) {
			$suffix = $shorthand[ $suffix ];
		}
		$base = 'bsy-' . $suffix . '-';

		$adapters = array(
			'blocksify/section'   => array(
				'per_device' => 'emit_device_rules',
				'tail'       => 'emit_max_width_rules',
			),
			'blocksify/container' => array(
				'per_device' => 'emit_flex_rules',
				'tail'       => null,
			),
			'blocksify/image'     => array(
				'per_device' => null,
				'tail'       => 'emit_image_local_rules',
				// Image's per-instance class lives on `<img>`. Layout-level
				// traits (visibility hides whole figure, position absolute on
				// img decouples caption, zindex on replaced element rarely
				// meaningful) route to the figure parent via `:has()`.
				// `:is(figure,div)` covers Image's allowed `tagName`. Both
				// editor + frontend produce DOM where `.bsy-image-{uid}`
				// (the img) sits inside a figure/div either directly or via
				// an `<a>` link wrapper — descendant `:has()` selector
				// matches both shapes.
				'parent_traits'          => array( 'visibility', 'position', 'zindex' ),
				'parent_selector_format' => ':is(figure,div):has(.%s)',
			),
			'blocksify/text'      => array(
				'per_device' => null,
				'tail'       => 'emit_text_indent_rules',
			),
			'blocksify/button'    => array(
				'per_device' => null,
				// Tail emits the "sticky hover text" rule only. Fill + Outline
				// preset visuals are static `style.css` rules (Fill on the base
				// `[class*="bsy-button-"]`, Outline on `:where(.is-style-outline)`).
				// The tail re-asserts a user's base Text Color across the
				// interactive states so the static `:hover` defense pin (which
				// must out-specify theme `a:hover`) doesn't override it on hover.
				// See `emit_button_local_rules`.
				'tail'       => 'emit_button_local_rules',
			),
			'blocksify/tabs'         => array(
				'per_device' => 'emit_tabs_device_rules',
				'tail'       => 'emit_tabs_local_rules',
			),
			'blocksify/content-loop' => array(
				'per_device' => null,
				'tail'       => 'emit_content_loop_local_rules',
			),
			'blocksify/accordion' => array(
				'per_device' => null,
				'tail'       => 'emit_accordion_local_rules',
			),
			'blocksify/icon-list' => array(
				'per_device' => 'emit_icon_list_device_rules',
				'tail'       => 'emit_icon_list_local_rules',
			),
		);
		$adapter         = isset( $adapters[ $block_type ] ) ? $adapters[ $block_type ] : null;
		$per_device      = $adapter ? $adapter['per_device'] : null;
		$tail            = $adapter ? $adapter['tail'] : null;
		$parent_traits   = $adapter && isset( $adapter['parent_traits'] ) ? $adapter['parent_traits'] : array();
		$parent_selector = '';
		if ( $parent_traits && isset( $adapter['parent_selector_format'] ) ) {
			$parent_selector = sprintf( $adapter['parent_selector_format'], $base . $uid );
		}
		// Parts (SPEC-trait-parts §3.2): map of `part-name → selector-suffix`
		// for blocks where styling spans sub-elements within one instance
		// (Tabs, future Slider/Accordion). Source of truth = block.json's
		// `blocksifyParts`, captured at registration into Blocksify_Traits.
		// Reading directly here avoids duplicating the parts table in the
		// adapter map — single block.json declaration suffices.
		$parts = Blocksify_Traits::block_parts( $block_type );

		// Desktop: per-device extras (Section grid / Container flex) +
		// trait wrapper rules + per-part wrapper rules. Before any @media
		// wrapper. Per-part rules are emitted AFTER root rules so source
		// order resolves any same-property override naturally (root then
		// part's narrower selector wins by specificity in @part).
		$extra_desktop = $per_device ? self::{$per_device}( $uid, $attributes, 'desktop' ) : '';
		$css           = $extra_desktop
			. self::emit_wrapper_rules( $uid, $attributes, 'desktop', $base, $parent_traits, $parent_selector )
			. self::emit_part_wrapper_rules( $uid, $attributes, 'desktop', $base, $parts, $block_type );

		// Tablet: per-device extras + wrapper rules + per-part rules +
		// DOM hide rules, wrapped in @media (max-width:1023px).
		$extra_t      = $per_device ? self::{$per_device}( $uid, $attributes, 'tablet' ) : '';
		$tablet_rules = $extra_t
			. self::emit_wrapper_rules( $uid, $attributes, 'tablet', $base, $parent_traits, $parent_selector )
			. self::emit_part_wrapper_rules( $uid, $attributes, 'tablet', $base, $parts, $block_type )
			. self::emit_dom_hide_rules( $uid, $attributes, 'tablet', $base );
		if ( '' !== $tablet_rules ) {
			$css .= '@media (max-width:' . self::TABLET_MAX . 'px){' . $tablet_rules . '}';
		}

		// Mobile: same shape as tablet.
		$extra_m      = $per_device ? self::{$per_device}( $uid, $attributes, 'mobile' ) : '';
		$mobile_rules = $extra_m
			. self::emit_wrapper_rules( $uid, $attributes, 'mobile', $base, $parent_traits, $parent_selector )
			. self::emit_part_wrapper_rules( $uid, $attributes, 'mobile', $base, $parts, $block_type )
			. self::emit_dom_hide_rules( $uid, $attributes, 'mobile', $base );
		if ( '' !== $mobile_rules ) {
			$css .= '@media (max-width:' . self::MOBILE_MAX . 'px){' . $mobile_rules . '}';
		}

		// Hover layer — single rule outside any @media wrapper. CSS
		// specificity (`:hover` adds 1) ensures it wins over the
		// responsive base rule on every viewport, naturally implementing
		// "hover overrides responsive" without a compound cascade in PHP.
		$css .= self::emit_hover_wrapper_rules( $uid, $attributes, $base );
		$css .= self::emit_part_hover_wrapper_rules( $uid, $attributes, $base, $parts, $block_type );

		// Block-specific tail (e.g. Section's max-width CSS-var emit).
		if ( $tail ) {
			$css .= self::{$tail}( $uid, $attributes );
		}

		// Visibility rules carry their own @media wrappers; emit last so
		// they sit at the end of the per-instance CSS (declaration order
		// independent of the spec, but readable in browser devtools).
		// When the block routes `visibility` to a parent selector (Image's
		// figure-via-:has()), pass that selector through so `display:none`
		// hides the entire figure (img + caption) instead of just the img.
		$visibility_selector_override = '';
		if ( $parent_selector && in_array( 'visibility', $parent_traits, true ) ) {
			$visibility_selector_override = $parent_selector;
		}
		$css .= Blocksify_Visibility_Trait::emit_rules(
			$uid,
			$attributes,
			$base,
			$visibility_selector_override
		);

		return $css;
	}

	/**
	 * Emit per-instance flex rules for one device. Block-agnostic — works
	 * for any block that uses FlexControl + a `.bsy-{type}-{uid}`
	 * class on its wrapper.
	 *
	 * @param string $uid    8-char hex uid.
	 * @param array  $attrs  Block attributes (flat-suffix shape).
	 * @param string $device 'desktop' | 'tablet' | 'mobile'.
	 * @return string
	 */
	public static function emit_flex_rules( $uid, $attrs, $device ) {
		$bag   = self::collect_flex_bag( $attrs, $device );
		$decls = self::build_flex_decls( $bag, $device );
		if ( '' === $decls ) {
			return '';
		}
		// Single-class selector — doubled NOT needed (verified 2026-05-01).
		// No Gutenberg `!important` interceptors on flex props in editor;
		// only Container's own static `.bsy-container` rule
		// at (0,1,0) competes, and our (0,1,0) wins by source order
		// (inline render-time `<style>` after head `<link>`). See
		// `preview-css.js` header for the full rationale.
		$selector = '.bsy-container-' . $uid;
		return $selector . '{' . $decls . '}';
	}

	/**
	 * Collect the resolved flex bag for one device. Mirrors the per-attr
	 * read pattern in `collect_color_bag` / `collect_bg_bag` — reads each
	 * attr's device-suffixed key directly (no cascade walk; browser
	 * cascade via @media handles fallback).
	 *
	 * @param array  $attrs  Block attributes.
	 * @param string $device 'desktop' | 'tablet' | 'mobile'.
	 * @return array
	 */
	protected static function collect_flex_bag( $attrs, $device ) {
		return array(
			'flexDirection'  => isset( $attrs[ Blocksify_Attrs::key( 'flexDirection', $device ) ] )
				? $attrs[ Blocksify_Attrs::key( 'flexDirection', $device ) ]
				: null,
			'justifyContent' => isset( $attrs[ Blocksify_Attrs::key( 'justifyContent', $device ) ] )
				? $attrs[ Blocksify_Attrs::key( 'justifyContent', $device ) ]
				: null,
			'alignItems'     => isset( $attrs[ Blocksify_Attrs::key( 'alignItems', $device ) ] )
				? $attrs[ Blocksify_Attrs::key( 'alignItems', $device ) ]
				: null,
			'flexWrap'       => isset( $attrs[ Blocksify_Attrs::key( 'flexWrap', $device ) ] )
				? $attrs[ Blocksify_Attrs::key( 'flexWrap', $device ) ]
				: null,
			'rowGap'         => isset( $attrs[ Blocksify_Attrs::key( 'rowGap', $device ) ] )
				? $attrs[ Blocksify_Attrs::key( 'rowGap', $device ) ]
				: null,
			'columnGap'      => isset( $attrs[ Blocksify_Attrs::key( 'columnGap', $device ) ] )
				? $attrs[ Blocksify_Attrs::key( 'columnGap', $device ) ]
				: null,
		);
	}

	/**
	 * Build flex decl string for one resolved state. Mirrors JS
	 * `buildFlexDecls` byte-for-byte. Desktop emits `display: flex` +
	 * `flex-direction` always (defaults from block.json). Non-desktop
	 * devices emit only changed properties so browser cascade carries
	 * unchanged ones from the parent @media rule.
	 *
	 * @param array  $bag    Resolved flex bag.
	 * @param string $device Active device (controls which properties
	 *                       emit unconditionally — desktop emits the
	 *                       always-present pair; tablet/mobile only the
	 *                       overridden subset).
	 * @return string
	 */
	protected static function build_flex_decls( $bag, $device ) {
		if ( ! is_array( $bag ) ) {
			return '';
		}

		// Track whether ANY attr in the bag has a meaningful value.
		// Desktop always emits display+direction; non-desktop only emits
		// when at least one override is present (else returns empty so
		// the @media wrapper doesn't render an empty rule).
		$has_override = false;
		foreach ( array( 'justifyContent', 'alignItems', 'flexWrap' ) as $k ) {
			if ( isset( $bag[ $k ] ) && is_string( $bag[ $k ] ) && '' !== $bag[ $k ] ) {
				$has_override = true;
				break;
			}
		}
		if ( ! $has_override ) {
			foreach ( array( 'rowGap', 'columnGap' ) as $k ) {
				if ( isset( $bag[ $k ] ) && is_array( $bag[ $k ] ) ) {
					$has_override = true;
					break;
				}
			}
		}
		$direction_set = isset( $bag['flexDirection'] ) && is_string( $bag['flexDirection'] ) && '' !== $bag['flexDirection'];

		if ( 'desktop' !== $device && ! $has_override && ! $direction_set ) {
			return '';
		}

		$decls = '';

		if ( 'desktop' === $device ) {
			// Desktop is the cascade root — always emit display+direction.
			$decls .= 'display: flex;';
			$direction = $direction_set ? $bag['flexDirection'] : 'column';
			$decls    .= 'flex-direction: ' . $direction . ';';
		} else {
			// Non-desktop only emits direction when overridden — browser
			// cascade carries desktop's value otherwise.
			if ( $direction_set ) {
				$decls .= 'flex-direction: ' . $bag['flexDirection'] . ';';
			}
		}

		if ( isset( $bag['justifyContent'] ) && is_string( $bag['justifyContent'] ) && '' !== $bag['justifyContent'] ) {
			$decls .= 'justify-content: ' . $bag['justifyContent'] . ';';
		}
		if ( isset( $bag['alignItems'] ) && is_string( $bag['alignItems'] ) && '' !== $bag['alignItems'] ) {
			$decls .= 'align-items: ' . $bag['alignItems'] . ';';
		}
		if ( isset( $bag['flexWrap'] ) && is_string( $bag['flexWrap'] ) && '' !== $bag['flexWrap'] ) {
			$decls .= 'flex-wrap: ' . $bag['flexWrap'] . ';';
		}

		if ( isset( $bag['rowGap'] ) && is_array( $bag['rowGap'] ) ) {
			$row_gap = self::format_gap_value( $bag['rowGap'] );
			if ( '' !== $row_gap ) {
				$decls .= 'row-gap: ' . $row_gap . ';';
			}
		}
		if ( isset( $bag['columnGap'] ) && is_array( $bag['columnGap'] ) ) {
			$col_gap = self::format_gap_value( $bag['columnGap'] );
			if ( '' !== $col_gap ) {
				$decls .= 'column-gap: ' . $col_gap . ';';
			}
		}

		return $decls;
	}


	/**
	 * @deprecated Phase 5 cleanup: utility moved to `Blocksify_Attrs::key()`.
	 *             Kept as thin proxy for backward compat with third-party
	 *             Pro code that may call the generator's static methods
	 *             directly. Remove after a release cycle.
	 */
	public static function bg_attr_key( $base, $device ) {
		return Blocksify_Attrs::key( $base, $device );
	}


	/**
	 * Emit `.{base_class}{uid}:hover` rules for the hover layer. Reads
	 * the flat `*Hover` attribute keys and emits a single rule per block.
	 *
	 * @param string $uid        8-char hex uid.
	 * @param array  $attrs      Block attributes (flat).
	 * @param string $base_class Per-block class prefix. Default Section
	 *                           prefix preserves backward compat.
	 * @return string
	 */
	public static function emit_hover_wrapper_rules( $uid, $attrs, $base_class = 'bsy-section-' ) {
		// Hover layer scope — color (text + bg) + link + spacing + border +
		// shadow + background gradient. Image / video / shape / overlay
		// don't get hover variants (image preload cost, video state-machine
		// complexity, shape/overlay niche).
		$base_sel          = '.' . $base_class . $uid;
		$selector          = $base_sel . ':hover';
		// Doubled-class for hover spacing — same specificity bump as the
		// non-hover path. See `emit_wrapper_rules` `$sel_spacing` comment.
		$sel_spacing_hover = $base_sel . $base_sel . ':hover';
		// Color hover: textColor + backgroundColor on the wrapper.
		// Synthetic device='hover' is mapped onto `*Hover` keys via the
		// trait's collect — see `Blocksify_Color_Trait::emit_hover_decls`.
		// Link hover lives on the descendant `a` selector — emitted
		// separately below so it doesn't pollute the wrapper rule.
		$decls    = Blocksify_Color_Trait::emit_hover_decls( $attrs );
		// Spacing hover emitted on `$sel_spacing_hover` (doubled-class)
		// below — see comment.
		// Pass desktop-resolved bag as cascade source so the hover layer
		// emits longhand override decls (only the properties the user
		// explicitly changed on hover); per-property CSS cascade carries
		// base's color / style / unchanged corners through to the
		// `:hover` rule.
		// Border + radius + shadow hover decls owned by the border trait.
		$decls   .= Blocksify_Border_Trait::emit_hover_decls( $attrs );
		// Background gradient hover — re-emits the full bg-image stack
		// (hover gradient + base image) so the image layer survives the
		// `background-image` property override on hover.
		$decls   .= Blocksify_Background_Trait::emit_hover_decls( $attrs );

		$css = '';
		if ( '' !== $decls ) {
			$css .= $selector . '{' . $decls . '}';
		}

		// Spacing hover on doubled-class selector (specificity bump). Owned
		// by the spacing trait.
		$spacing_hover_decls = Blocksify_Spacing_Trait::emit_hover_decls( $attrs );
		if ( '' !== $spacing_hover_decls ) {
			$css .= $sel_spacing_hover . '{' . $spacing_hover_decls . '}';
		}
		// Link hover lives on `<wrapper> a:hover` — fires when the LINK
		// itself is hovered (standard CSS convention). NOT
		// `<wrapper>:hover a` which would recolor links whenever the
		// section wrapper is hovered.
		$css .= Blocksify_Color_Trait::emit_link_hover_rule( $base_sel, $attrs );
		return $css;
	}

	/**
	 * Emit per-device wrapper-scoped rules (background color / gradient /
	 * image / overlay) — applied directly to `.bsy-section-{uid}`.
	 * Cascade for missing devices via @media stack.
	 *
	 * Video + Shape NOT emitted — they require DOM elements (`<video>`,
	 * `<svg>`) rendered by save.js + render.php. Tracked in PHASES.md
	 * Phase 4 follow-up "BackgroundLayers port".
	 *
	 * Three-state per non-desktop device key:
	 *   - **set value** → emit decl (override).
	 *   - **explicit `null`** sentinel → prepend reset decls
	 *     (`background-color:transparent;background-image:none;`) so the
	 *     browser doesn't inherit the upper device's value via @media
	 *     cascade. User intent: "I removed this layer on this device."
	 *   - **absent / undefined** → no emit; CSS cascade does the work.
	 *
	 * @param string $uid        8-char hex uid.
	 * @param array  $attrs      Block attributes (flat).
	 * @param string $device     'desktop' | 'tablet' | 'mobile'.
	 * @param string $base_class Per-block class prefix (e.g.
	 *                           'bsy-section-' or 'bsy-container-').
	 *                           Default keeps Section-era callers working.
	 * @return string
	 */
	public static function emit_wrapper_rules( $uid, $attrs, $device, $base_class = 'bsy-section-', $parent_traits = array(), $parent_selector = '' ) {
		$selector = '.' . $base_class . $uid;
		// All traits emit at single-class specificity (0,1,0). Source
		// order resolves the cascade — per-instance CSS lands LAST in
		// the inline pool, so it wins over any equal-specificity theme /
		// editor / shared-base rule. Doubled-class trick that earlier
		// versions used to bump specificity for spacing / typography /
		// position is dropped (per project decision 2026-05-07): every
		// per-instance rule shares the same selector, all decls merge
		// into ONE block per device, output is the minimum byte form.
		// Editor-only specificity wars (e.g. WP's `wp-block { margin:
		// auto }` rule) are handled by scoped editor.css overrides
		// instead of selector inflation, so the trait emit stays clean.
		//
		// Parent-selector routing: blocks whose per-instance class lives
		// on a non-layout element (Image's `<img>`) route layout-affecting
		// traits (position / zindex) to a parent selector via the
		// `$parent_traits` opt-in. The traits that route still go through
		// the same `emit_decls()` path; their decls get appended to a
		// `$parent_decls` bucket emitted on `$parent_selector` instead of
		// the per-instance class. Visibility (separate emit_rules) gets
		// the selector override applied in `generate_block_css`.
		$has_position_parent = $parent_selector && in_array( 'position', $parent_traits, true );
		$has_zindex_parent   = $parent_selector && in_array( 'zindex', $parent_traits, true );

		$decls = '';
		$decls .= Blocksify_Color_Trait::emit_decls( $attrs, $device );
		$decls .= Blocksify_Typography_Trait::emit_decls( $attrs, $device );
		$decls .= Blocksify_Background_Trait::emit_decls( $attrs, $device );
		$decls .= Blocksify_Spacing_Trait::emit_decls( $attrs, $device );
		$decls .= Blocksify_Border_Trait::emit_decls( $attrs, $device );
		$decls .= Blocksify_Size_Trait::emit_decls( $attrs, $device );
		$decls .= Blocksify_Overflow_Trait::emit_decls( $attrs, $device );

		$parent_decls = '';
		$position_decls = Blocksify_Position_Trait::emit_decls( $attrs, $device );
		if ( $has_position_parent ) {
			$parent_decls .= $position_decls;
		} else {
			$decls .= $position_decls;
		}
		$zindex_decls = Blocksify_ZIndex_Trait::emit_decls( $attrs, $device );
		if ( $has_zindex_parent ) {
			$parent_decls .= $zindex_decls;
		} else {
			$decls .= $zindex_decls;
		}

		$css = '';
		if ( '' !== $decls ) {
			$css .= $selector . '{' . $decls . '}';
		}
		if ( '' !== $parent_decls ) {
			$css .= $parent_selector . '{' . $parent_decls . '}';
		}

		// Overlay emits to a child `.bsy-bg-overlay` div (separate
		// rule) so it stacks ABOVE the video DOM child (z-index 0). Pure
		// CSS-bg rules above would render BEHIND video → overlay
		// invisible. The div renders only when at least one state has
		// overlay (see save.js / edit.js); per-device CSS controls
		// visibility per device. Block-agnostic class so Container +
		// future blocks re-use the same render + cascade machinery.
		$css .= Blocksify_Background_Trait::emit_overlay_rule( $selector, $attrs, $device );

		// Link color emits as a descendant `<wrapper> a` rule so user-picked
		// link colors override theme defaults without touching the wrapper's
		// `color`. Separate selector → separate rule, owned by the color
		// trait.
		$css .= Blocksify_Color_Trait::emit_link_rule( $selector, $attrs, $device );
		return $css;
	}



	/**
	 * @deprecated Moved to `Blocksify_Attrs::has_upstream_value()`.
	 */
	public static function has_upstream_value( $attrs, $base, $device ) {
		return Blocksify_Attrs::has_upstream_value( $attrs, $base, $device );
	}

	/**
	 * @deprecated Moved to `Blocksify_Attrs::is_meaningful()`.
	 */
	public static function is_meaningful( $value ) {
		return Blocksify_Attrs::is_meaningful( $value );
	}

	/**
	 * Emit `display:none` rules for DOM bg layers (`<video>`, shape SVGs)
	 * when the device key holds the explicit-empty sentinel (`null`).
	 *
	 * Why CSS hide instead of conditional DOM render: `save.js` produces
	 * ONE saved markup containing the desktop video / shape elements;
	 * per-device DOM swap (multiple `<video>` with media-query visibility)
	 * is deferred to Phase 5. Hiding via CSS keeps frontend HTML small
	 * (one DOM, browser preload="none" / "metadata" already prevents
	 * mobile bandwidth waste for hidden video) and matches the existing
	 * @media cascade pattern used for color/gradient/image.
	 *
	 * Selectors are BLOCK-AGNOSTIC (`.bsy-bg-video`,
	 * `.bsy-bg-shape`) so the same hide rule works for Section,
	 * Container, and any future block that uses BackgroundControl's
	 * render helpers.
	 *
	 * @param string $uid        8-char hex uid.
	 * @param array  $attrs      Block attributes.
	 * @param string $device     'tablet' | 'mobile'.
	 * @param string $base_class Per-block class prefix. Default Section
	 *                           prefix preserves backward compat.
	 * @return string
	 */
	protected static function emit_dom_hide_rules( $uid, $attrs, $device, $base_class = 'bsy-section-' ) {
		$suffix = 'tablet' === $device ? 'Tablet' : 'Mobile';
		$rules  = '';
		$prefix = '.' . $base_class . $uid . ' ';

		// Video visibility — per-device DOM swap + null sentinel handling.
		// save.js renders one `<video>` per device override (cascade-aware);
		// these rules toggle which device's element shows at the current
		// viewport. Block-agnostic class scope (`> .bsy-bg-video--{device}`)
		// so Container + future blocks reuse the same machinery by passing
		// their own UID prefix.
		$rules .= Blocksify_Background_Trait::emit_video_visibility_rules( $uid, $attrs, $device, $base_class );

		// Shape: null-sentinel hide only (per-device shape DOM swap deferred —
		// rare niche). Same pattern as video's null branch above but on the
		// shape container class.
		$shape_key = 'backgroundShape' . $suffix;
		if ( array_key_exists( $shape_key, $attrs ) && is_null( $attrs[ $shape_key ] )
			&& Blocksify_Attrs::has_upstream_value( $attrs, 'backgroundShape', $device ) ) {
			$rules .= $prefix . '.bsy-bg-shape{display:none;}';
		}

		return $rules;
	}




	/**
	 * @deprecated Moved to `Blocksify_Attrs::sanitize_color()`.
	 */
	public static function sanitize_css_color( $color ) {
		return Blocksify_Attrs::sanitize_color( $color );
	}


	// Scroll Effect oversize positioning ships as inline style="" on the
	// rendered DOM <img> / <video> element (see `renderImageLayer` +
	// `renderVideoLayer` in `src/shared/controls/BackgroundControl/render.js`).
	// No PHP CSS emission needed — save.js writes the inline style and
	// render.php just outputs $content.

	/**
	 * Emit per-device --blocksify--section--content-size overrides when the user
	 * opted out of theme inherit. The var is read by .bsy-section__inner
	 * via the cascade in style.css (user override → WP core content-size →
	 * 1200px).
	 *
	 * @param string $uid        8-char hex uid.
	 * @param array  $attributes Block attributes.
	 * @return string
	 */
	public static function emit_max_width_rules( $uid, $attributes ) {
		// Alignfull / alignwide vertical-margin reset emits regardless of
		// inheritMaxWidth. Lift it BEFORE the early return below so a section
		// with default max-width still gets the theme-margin override.
		$css = self::emit_section_align_margin_reset( $uid, $attributes );

		$inherit = isset( $attributes['inheritMaxWidth'] )
			? (bool) $attributes['inheritMaxWidth']
			: true;
		if ( $inherit ) {
			return $css;
		}

		$selector = '.bsy-section-' . $uid;

		// `--blocksify--section--inner-padding` intentionally NOT emitted —
		// keeping the static default `clamp(16px, 4vw, 32px)` gutter on
		// alignwide / alignfull custom-width sections ensures content
		// doesn't touch viewport edges on phones. The alignfull + alignwide
		// rules' `calc(content-size + 2 × inner-padding)` adds the gutter
		// back to max-width so the USABLE area still equals the user's
		// custom value exactly.
		$desktop       = self::normalize_max_width( isset( $attributes['maxWidth'] ) ? $attributes['maxWidth'] : null );
		$desktop_value = null !== $desktop ? $desktop : '100%';
		$css          .= $selector . '{'
			. '--blocksify--section--content-size:' . $desktop_value . ';'
			. '}';

		$tablet = self::normalize_max_width( isset( $attributes['maxWidthTablet'] ) ? $attributes['maxWidthTablet'] : null );
		if ( null !== $tablet ) {
			$css .= '@media (max-width:' . self::TABLET_MAX . 'px){'
				. $selector . '{--blocksify--section--content-size:' . $tablet . ';}'
				. '}';
		}

		$mobile = self::normalize_max_width( isset( $attributes['maxWidthMobile'] ) ? $attributes['maxWidthMobile'] : null );
		if ( null !== $mobile ) {
			$css .= '@media (max-width:' . self::MOBILE_MAX . 'px){'
				. $selector . '{--blocksify--section--content-size:' . $mobile . ';}'
				. '}';
		}

		return $css;
	}

	/**
	 * Coerce a raw max-width bag (`{ value: number, unit: string }`) to a
	 * formatted CSS length string ("800px", "80vw", "5rem", …) or null.
	 *
	 * Unit allowlist mirrors GapControl's CUSTOM_UNITS — px, %, vw, rem, em
	 * (+ vh for safety). Anything outside falls back to px to avoid
	 * emitting unknown units into the CSS pool.
	 *
	 * @param mixed $value Raw value from block attributes.
	 * @return string|null
	 */
	protected static function normalize_max_width( $value ) {
		if ( ! is_array( $value ) || ! isset( $value['value'] ) || ! is_numeric( $value['value'] ) ) {
			return null;
		}
		$num = (float) $value['value'];
		if ( $num <= 0 ) {
			return null;
		}
		$unit = isset( $value['unit'] ) && is_string( $value['unit'] ) ? $value['unit'] : 'px';
		if ( ! in_array( $unit, array( 'px', 'rem', 'em', 'vw', 'vh', '%' ), true ) ) {
			$unit = 'px';
		}
		// Trim trailing zeros from float→string to keep emit compact:
		// 800.0 → "800", 1.5 → "1.5", 80.25 → "80.25".
		$formatted = rtrim( rtrim( sprintf( '%.4f', $num ), '0' ), '.' );
		return $formatted . $unit;
	}

	/**
	 * Emit Section's align-aware vertical-margin reset (alignfull + alignwide).
	 *
	 * Themes (Twenty Twenty-One, Customify, Astra, …) commonly inject
	 * `.entry-content > .alignfull { margin-top: 1.5em; margin-bottom: 1.5em }`
	 * (and similar on `.alignwide` for some themes) at specificity (0,2,0).
	 * Blocksify's per-instance Spacing trait emit at `.bsy-section-{uid}`
	 * is (0,1,0) — loses to the theme rule. Result: a top-level aligned
	 * Section gets unwanted vertical margin even when the user set none,
	 * AND user-set margin via Spacing trait doesn't override the theme
	 * either.
	 *
	 * Fix: emit at `.bsy-section-{uid}.bsy-section-{uid}.{alignClass}`
	 * (0,3,0) — bumps specificity above the theme rule. Doubled-class trick
	 * is used here (vs source-order tied at 0,2,0) because Blocksify's
	 * inline pool empirically loads BEFORE the theme stylesheet in `<head>`
	 * on some setups (Customify theme observed at pos 37663 vs Blocksify
	 * inline at pos 32884), so tying at (0,2,0) would lose to theme.
	 * Net effect (per align value):
	 *  - Default (no user margin): emits `margin-block: 0` — sections sit
	 *    flush, theme margins suppressed.
	 *  - User set margin via Spacing trait: re-emits the same value at the
	 *    higher-specificity selector so user override beats the theme too.
	 *
	 * Horizontal margin treatment is OWNED by static base rules:
	 *  - alignfull static rule forces `margin-inline: calc(50% - 50vw)`
	 *    (full-viewport breakout) at (0,3,0).
	 *  - alignwide static rule forces `margin-inline: auto` (centered at
	 *    wide-size) at (0,3,0).
	 * Either way, user-set horizontal margin from Spacing trait (0,1,0)
	 * is silently ignored on aligned sections by design — align = positioning
	 * intent owned by Blocksify, theme-agnostic.
	 *
	 * Spacing trait still emits its own margin decls at (0,1,0); those become
	 * harmless once shadowed by our (0,3,0) rule, no extra branching needed.
	 *
	 * @param string $uid   8-char hex uid.
	 * @param array  $attrs Block attributes.
	 * @return string
	 */
	public static function emit_section_align_margin_reset( $uid, $attrs ) {
		$align = isset( $attrs['align'] ) ? $attrs['align'] : '';
		if ( 'full' !== $align && 'wide' !== $align ) {
			return '';
		}
		$align_class = 'align' . $align;

		// Doubled `.bsy-section-{uid}` lifts the selector to (0,3,0) so it
		// beats both theme's `.entry-content > .{alignClass}` (0,2,0) and
		// static base's `[class*="bsy-section-"].{alignClass}.{alignClass}`
		// (0,3,0) via source order (per-instance pool emits AFTER static
		// base bundle).
		$class = '.bsy-section-' . $uid;
		$sel   = $class . $class . '.' . $align_class;
		$css   = $sel . '{margin-block:0;}';

		// Per-device re-emit of user-set vertical margin. Empty media bag
		// arms desktop with no @media wrapper; tablet / mobile arm their
		// respective max-width queries.
		$devices = array(
			''       => 0,
			'Tablet' => self::TABLET_MAX,
			'Mobile' => self::MOBILE_MAX,
		);
		foreach ( $devices as $suffix => $max_width ) {
			$key = 'margin' . $suffix;
			if ( ! isset( $attrs[ $key ] ) || ! is_array( $attrs[ $key ] ) ) {
				continue;
			}
			$decls = self::section_alignfull_margin_decls( $attrs[ $key ] );
			if ( '' === $decls ) {
				continue;
			}
			$rule = $sel . '{' . $decls . '}';
			if ( 0 !== $max_width ) {
				$rule = '@media (max-width:' . $max_width . 'px){' . $rule . '}';
			}
			$css .= $rule;
		}

		// Hover re-emit — `:hover` pseudo class adds (0,0,1) to the selector
		// so `.bsy-section-{uid}.alignfull:hover` lands at (0,2,1), still
		// beating theme via source order (theme has no :hover variant).
		if ( isset( $attrs['marginHover'] ) && is_array( $attrs['marginHover'] ) ) {
			$decls = self::section_alignfull_margin_decls( $attrs['marginHover'] );
			if ( '' !== $decls ) {
				$css .= $sel . ':hover{' . $decls . '}';
			}
		}

		return $css;
	}

	/**
	 * Build the `margin-top:X;margin-bottom:Y;` declaration pair for one
	 * device bag. Returns '' when neither side is set. Token refs and
	 * `{ value, unit }` literals both supported via `cl_dim_to_css`.
	 *
	 * @param array $bag Margin bag for one device.
	 * @return string
	 */
	protected static function section_alignfull_margin_decls( $bag ) {
		$top    = isset( $bag['top'] ) ? self::cl_dim_to_css( $bag['top'] ) : null;
		$bottom = isset( $bag['bottom'] ) ? self::cl_dim_to_css( $bag['bottom'] ) : null;
		$decls  = '';
		if ( null !== $top ) {
			$decls .= 'margin-top:' . $top . ';';
		}
		if ( null !== $bottom ) {
			$decls .= 'margin-bottom:' . $bottom . ';';
		}
		return $decls;
	}

	/**
	 * Emit Image block-local rules — `aspect-ratio` + `object-fit` +
	 * `object-position` on `.bsy-image-{uid}`. Block-local because no shared
	 * trait covers these props (Image-only). Mirrors JS
	 * `src/blocks/image/helpers.js::buildImageCss` byte-for-byte.
	 */
	public static function emit_image_local_rules( $uid, $attrs ) {
		$ratio_raw = isset( $attrs['aspectRatio'] ) ? $attrs['aspectRatio'] : null;
		if ( ! is_string( $ratio_raw ) || '' === trim( $ratio_raw ) ) {
			return '';
		}
		// WP-core ratio set — twin of ASPECT_OPTIONS in image/inspector/Panels.js.
		$presets = array( '1/1', '4/3', '3/4', '3/2', '2/3', '16/9', '9/16' );
		$ratio   = null;
		if ( in_array( $ratio_raw, $presets, true ) ) {
			$ratio = str_replace( '/', ' / ', $ratio_raw );
		} elseif ( preg_match( '/^(\d+)\s*\/\s*(\d+)$/', trim( $ratio_raw ), $m ) ) {
			$w = (int) $m[1];
			$h = (int) $m[2];
			if ( $w > 0 && $h > 0 ) {
				$ratio = $w . ' / ' . $h;
			}
		}
		if ( null === $ratio ) {
			return '';
		}

		$decls        = array( 'aspect-ratio: ' . $ratio . ';' );
		$allowed_fits = array( 'cover', 'contain', 'fill', 'scale-down' );
		$fit_raw      = isset( $attrs['objectFit'] ) ? $attrs['objectFit'] : null;
		$fit          = in_array( $fit_raw, $allowed_fits, true ) ? $fit_raw : 'cover';
		$decls[]      = 'object-fit: ' . $fit . ';';

		if ( 'cover' === $fit || 'contain' === $fit ) {
			$pos_raw = isset( $attrs['objectPosition'] ) ? $attrs['objectPosition'] : null;
			if ( is_string( $pos_raw ) && '' !== trim( $pos_raw ) ) {
				$pos = trim( $pos_raw );
				$ok  = false;
				if ( preg_match( '/^(\d+(?:\.\d+)?%\s+\d+(?:\.\d+)?%|\d+(?:\.\d+)?px\s+\d+(?:\.\d+)?px)$/', $pos ) ) {
					$ok = true;
				} elseif ( preg_match( '/^(center|top|bottom|left|right)(\s+(center|top|bottom|left|right))?$/', $pos ) ) {
					$ok = true;
				}
				if ( $ok ) {
					$decls[] = 'object-position: ' . $pos . ';';
				}
			}
		}

		return '.bsy-image-' . $uid . '{' . implode( ' ', $decls ) . '}';
	}

	/**
	 * Emit Button per-instance preset overrides. Structural + defensive
	 * theme resets (display, alignment, gap, text-decoration, cursor,
	 * border, line-height, align-self) all live in the SHARED base rule
	 * `[class*="bsy-button-"]` shipped via `style.css` → inline CSS pool
	 * (one declaration covers every Button on the page). This function
	 * only emits per-instance VARIATIONS — currently just the Outline
	 * preset's bg/color/border overrides, which differ between instances
	 * based on `attrs.preset`. Default Fill visuals (background, color,
	 * border-radius, padding) flow through the trait pipeline via
	 * `block.json` defaults → `Blocksify_CSS_Pipeline::merge_block_defaults`.
	 *
	 * Returns empty string when preset === 'fill' (the default), keeping
	 * the inline pool minimal: 0 extra bytes per Fill button beyond its
	 * trait CSS.
	 */
	/**
	 * Block-local CSS for blocksify/accordion — marker svg sizing + header
	 * gap. Mirrors JS twin `buildAccordionLocalPreviewCss` byte-for-byte.
	 *
	 * Marker icon size + header gap are configurable via `iconSize` /
	 * `iconGap` attrs. They live here (not as a trait) because they
	 * target structural BEM children (`.bsy-accordion__marker svg`,
	 * `.bsy-accordion__header`) — the typography trait sets
	 * `font-size` / `line-height` on the item, not on the marker SVG box.
	 *
	 * @param string $uid   8-char hex uid.
	 * @param array  $attrs Block attributes.
	 * @return string CSS rules.
	 */
	public static function emit_accordion_local_rules( $uid, $attrs ) {
		$base = '.bsy-accordion-' . $uid;
		$indicator_size      = self::format_dim_value( isset( $attrs['indicatorSize'] ) ? $attrs['indicatorSize'] : null );
		$icon_gap            = self::format_dim_value( isset( $attrs['iconGap'] ) ? $attrs['iconGap'] : null );
		$icon_width          = self::format_dim_value( isset( $attrs['iconWidth'] ) ? $attrs['iconWidth'] : null );
		$icon_border_radius  = self::format_dim_value( isset( $attrs['iconBorderRadius'] ) ? $attrs['iconBorderRadius'] : null );

		$css = '';

		// Indicator size (default null → inherits 1em from style.css).
		// Targets ONLY the indicator SVG. The header icon (when set per
		// item) inherits font-size separately via the title element so
		// it scales with title typography — distinct from the indicator
		// which has its own size control.
		if ( '' !== $indicator_size ) {
			$css .= $base
				. ' > .bsy-accordion__item .bsy-accordion__header .bsy-accordion__indicator svg{'
				. 'width:' . $indicator_size . ';height:' . $indicator_size . ';}';
		}

		// Icon gap — gap between icon and title text (also gaps title-text
		// to indicator since they share the title flex container's `gap`).
		if ( '' !== $icon_gap ) {
			$css .= $base
				. ' > .bsy-accordion__item .bsy-accordion__header .bsy-accordion__title{'
				. 'gap:' . $icon_gap . ';}';
		}

		// Icon width — overrides the structural max-width: 50px on the
		// icon box. SVG + img inside also scale to the new width via
		// `width: 100%` so the visible icon size matches the user's
		// chosen Width setting (without this, SVGs would stay at 1em
		// even though the box grew).
		if ( '' !== $icon_width ) {
			$css .= $base
				. ' > .bsy-accordion__item .bsy-accordion__header .bsy-accordion__icon{'
				. 'max-width:' . $icon_width . ';width:' . $icon_width . ';}';
			$css .= $base
				. ' > .bsy-accordion__item .bsy-accordion__header .bsy-accordion__icon svg,'
				. $base
				. ' > .bsy-accordion__item .bsy-accordion__header .bsy-accordion__icon img{'
				. 'width:100%;height:auto;}';
		}

		// Icon border-radius — rounds the icon box. Forces `aspect-ratio:
		// 1 / 1` so the box is square regardless of the source image's
		// aspect (a 50% radius on a non-square box renders as an oval,
		// not a circle); `object-fit: cover` on the inner img + svg
		// fills the square and crops to fit, giving the standard
		// "circular avatar" look on any image. `overflow: hidden` clips
		// the rounded corners.
		if ( '' !== $icon_border_radius ) {
			$css .= $base
				. ' > .bsy-accordion__item .bsy-accordion__header .bsy-accordion__icon{'
				. 'border-radius:' . $icon_border_radius
				. ';aspect-ratio:1/1;overflow:hidden;}';
			$css .= $base
				. ' > .bsy-accordion__item .bsy-accordion__header .bsy-accordion__icon img,'
				. $base
				. ' > .bsy-accordion__item .bsy-accordion__header .bsy-accordion__icon svg{'
				. 'width:100%;height:100%;object-fit:cover;object-position:center;}';
		}

		// Item-to-item gap — sibling-margin pattern. Skipped on first
		// item (no preceding sibling). Responsive per device with @media
		// wrappers; cascade unchanged on devices where attr is unset.
		$item_gap_selector = $base . ' > .bsy-accordion__item + .bsy-accordion__item';
		$desktop_gap = self::format_dim_value( isset( $attrs['itemGap'] ) ? $attrs['itemGap'] : null );
		if ( '' !== $desktop_gap ) {
			$css .= $item_gap_selector . '{margin-top:' . $desktop_gap . ';}';
		}
		$tablet_gap = self::format_dim_value( isset( $attrs['itemGapTablet'] ) ? $attrs['itemGapTablet'] : null );
		if ( '' !== $tablet_gap ) {
			$css .= '@media (max-width:' . self::TABLET_MAX . 'px){'
				. $item_gap_selector . '{margin-top:' . $tablet_gap . ';}'
				. '}';
		}
		$mobile_gap = self::format_dim_value( isset( $attrs['itemGapMobile'] ) ? $attrs['itemGapMobile'] : null );
		if ( '' !== $mobile_gap ) {
			$css .= '@media (max-width:' . self::MOBILE_MAX . 'px){'
				. $item_gap_selector . '{margin-top:' . $mobile_gap . ';}'
				. '}';
		}

		// StylingRepeater — sparse-storage CSS for the indicator chip
		// (base) + active-state override. Both twins of edit.js's
		// `buildSparsePreviewCss` calls (FOUNDATION §9.1 byte-for-byte
		// parity). `partSelector`s must match the JS wiring exactly.
		// Active selector adds the `.is-open` filter on the item so the
		// rules raise specificity (0,3,1) over the base (0,2,1) — same
		// prop authored in both slices: active wins when item is open.
		// Empty `*Styling` → aggregator short-circuits to '' (no payload).
		$indicator_styling = isset( $attrs['indicatorStyling'] ) && is_array( $attrs['indicatorStyling'] )
			? $attrs['indicatorStyling']
			: array();
		if ( ! empty( $indicator_styling ) ) {
			$css .= Blocksify_Styling_Repeater::build_sparse_preview_css(
				$indicator_styling,
				array(
					'block_name'    => 'blocksify/accordion',
					'uid'           => $uid,
					'part_selector' => ' > .bsy-accordion__item > .bsy-accordion__header .bsy-accordion__indicator',
					'consumer_name' => 'accordion',
				)
			);
		}
		$indicator_active_styling = isset( $attrs['indicatorActiveStyling'] ) && is_array( $attrs['indicatorActiveStyling'] )
			? $attrs['indicatorActiveStyling']
			: array();
		if ( ! empty( $indicator_active_styling ) ) {
			$css .= Blocksify_Styling_Repeater::build_sparse_preview_css(
				$indicator_active_styling,
				array(
					'block_name'    => 'blocksify/accordion',
					'uid'           => $uid,
					'part_selector' => ' > .bsy-accordion__item.is-open > .bsy-accordion__header .bsy-accordion__indicator',
					'consumer_name' => 'accordion',
				)
			);
		}
		// Per-item icon — uses `excludeGroups: [ sizing, borderRadius ]`
		// on the JS side so the structural Width + Border Radius
		// FieldRows above own those axes (they have custom emit logic the
		// generic sparse cssBuilders can't replicate). The remaining
		// optional groups (Background / Border / Box Shadow / Color /
		// Padding / Margin) flow through here.
		$icon_styling = isset( $attrs['iconStyling'] ) && is_array( $attrs['iconStyling'] )
			? $attrs['iconStyling']
			: array();
		if ( ! empty( $icon_styling ) ) {
			$css .= Blocksify_Styling_Repeater::build_sparse_preview_css(
				$icon_styling,
				array(
					'block_name'    => 'blocksify/accordion',
					'uid'           => $uid,
					'part_selector' => ' > .bsy-accordion__item > .bsy-accordion__header .bsy-accordion__icon',
					'consumer_name' => 'accordion',
				)
			);
		}
		// Item Wrapper / Item Active / Item Inner / Title / Title Active
		// styling — five StylingRepeater consumers in the Inspector. Each
		// scoped to its own DOM surface via `part_selector`. Active-state
		// selectors add the `.is-open` filter on the item so their
		// declarations win (Item Active 0,3,0 over Item Wrapper 0,2,0;
		// Title Active 0,3,1 over base Title 0,2,1) on the same prop.
		$item_wrapper_styling = isset( $attrs['itemWrapperStyling'] ) && is_array( $attrs['itemWrapperStyling'] )
			? $attrs['itemWrapperStyling']
			: array();
		if ( ! empty( $item_wrapper_styling ) ) {
			$css .= Blocksify_Styling_Repeater::build_sparse_preview_css(
				$item_wrapper_styling,
				array(
					'block_name'    => 'blocksify/accordion',
					'uid'           => $uid,
					'part_selector' => ' > .bsy-accordion__item',
					'consumer_name' => 'accordion',
				)
			);
		}
		// Item Active — open item's box (`.is-open`). Seeds a stronger
		// border than the Item Wrapper base; emits at (0,3,0) so it wins
		// over the base item border (0,2,0) while the item is open.
		$item_active_styling = isset( $attrs['itemActiveStyling'] ) && is_array( $attrs['itemActiveStyling'] )
			? $attrs['itemActiveStyling']
			: array();
		if ( ! empty( $item_active_styling ) ) {
			$css .= Blocksify_Styling_Repeater::build_sparse_preview_css(
				$item_active_styling,
				array(
					'block_name'    => 'blocksify/accordion',
					'uid'           => $uid,
					'part_selector' => ' > .bsy-accordion__item.is-open',
					'consumer_name' => 'accordion',
				)
			);
		}
		$item_inner_styling = isset( $attrs['itemInnerStyling'] ) && is_array( $attrs['itemInnerStyling'] )
			? $attrs['itemInnerStyling']
			: array();
		if ( ! empty( $item_inner_styling ) ) {
			$css .= Blocksify_Styling_Repeater::build_sparse_preview_css(
				$item_inner_styling,
				array(
					'block_name'    => 'blocksify/accordion',
					'uid'           => $uid,
					'part_selector' => ' > .bsy-accordion__item > .bsy-accordion__body .bsy-accordion__body-content',
					'consumer_name' => 'accordion',
				)
			);
		}
		$title_styling = isset( $attrs['titleStyling'] ) && is_array( $attrs['titleStyling'] )
			? $attrs['titleStyling']
			: array();
		if ( ! empty( $title_styling ) ) {
			$css .= Blocksify_Styling_Repeater::build_sparse_preview_css(
				$title_styling,
				array(
					'block_name'         => 'blocksify/accordion',
					'uid'                => $uid,
					'part_selector'      => ' > .bsy-accordion__item > .bsy-accordion__header',
					'consumer_name'      => 'accordion',
					// Typography emits on the `.title` heading element
					// (not the header pill) so font-size beats theme
					// `:where(h1-h6)` directly — matches the JS-side
					// `selectorOverrides.typography` for FOUNDATION §9.1
					// byte-for-byte parity.
					'selector_overrides' => array(
						'typography' => ' > .bsy-accordion__item > .bsy-accordion__header .bsy-accordion__title',
					),
				)
			);
		}
		$title_active_styling = isset( $attrs['titleActiveStyling'] ) && is_array( $attrs['titleActiveStyling'] )
			? $attrs['titleActiveStyling']
			: array();
		if ( ! empty( $title_active_styling ) ) {
			$css .= Blocksify_Styling_Repeater::build_sparse_preview_css(
				$title_active_styling,
				array(
					'block_name'    => 'blocksify/accordion',
					'uid'           => $uid,
					'part_selector' => ' > .bsy-accordion__item.is-open > .bsy-accordion__header',
					'consumer_name' => 'accordion',
				)
			);
		}

		return $css;
	}

	/**
	 * Format a `{value, unit}` dimension bag into a CSS length string.
	 * Returns empty when value is null/empty. Mirrors JS `formatDim`
	 * helper in `src/blocks/accordion/preview-css.js`.
	 *
	 * @param mixed $bag Dimension bag.
	 * @return string CSS length (e.g. '1em', '16px') or empty.
	 */
	protected static function format_dim_value( $bag ) {
		if ( ! is_array( $bag ) ) {
			return '';
		}
		// Mode B token reference — emit `var()` so global token edits
		// cascade. Mirrors GapControl JS `formatGap` token branch.
		if ( isset( $bag['token'] ) && is_string( $bag['token'] ) && '' !== $bag['token'] ) {
			return 'var(--blocksify--spacing--' . $bag['token'] . ')';
		}
		$value = isset( $bag['value'] ) ? $bag['value'] : null;
		$unit  = isset( $bag['unit'] ) && is_string( $bag['unit'] ) && '' !== $bag['unit']
			? $bag['unit']
			: 'px';
		if ( null === $value || '' === $value ) {
			return '';
		}
		return $value . $unit;
	}

	/**
	 * Button tail — per-instance hover extras on `:hover/:focus/:active`. JS
	 * twin in `src/blocks/button/preview-css.js` (`buildButtonPreviewCss`);
	 * keep byte-for-byte (decl order: color, then filter).
	 *
	 * 1. **Sticky text** (`color`) — the static Fill `:hover` rule (style.css)
	 *    defends the DEFAULT text from a theme's `a:hover`, but at its
	 *    specificity it would also override a user's base Text Color on hover.
	 *    Re-assert it here. Skipped when an explicit Hover color is set (the
	 *    color trait emits that `:hover` rule) or no base Text Color.
	 *
	 * 2. **Default hover feedback** — a "state layer": `background-color:
	 *    color-mix(srgb, <bg>, currentColor 13%)`, mixing the bg toward the
	 *    button's OWN text colour. Adaptive by construction (a dark bg with
	 *    light text lightens; a light bg with dark text deepens), one mechanism
	 *    for every bg (slug or custom hex), harmonious (borrows the text hue) so
	 *    it never washes a light bg out nor greys it. Fill only; skipped on
	 *    Outline and once the user authors any Hover colour. Needs `color-mix()`
	 *    (Chrome 111+ / FF 113+ / Safari 16.2+); older browsers degrade to no
	 *    hover.
	 */
	public static function emit_button_local_rules( $uid, $attrs ) {
		$decls = '';

		$text      = ( isset( $attrs['textColor'] ) && is_string( $attrs['textColor'] ) ) ? $attrs['textColor'] : '';
		$text_hov  = ( isset( $attrs['textColorHover'] ) && is_string( $attrs['textColorHover'] ) && '' !== $attrs['textColorHover'] );
		if ( '' !== $text && ! $text_hov ) {
			$sanitized = Blocksify_Attrs::sanitize_color( $text );
			if ( '' !== $sanitized ) {
				$decls .= 'color:' . $sanitized . ';';
			}
		}

		// Effective bg slug (mirror of edit.js / render.php): explicit palette
		// slug, else the Fill static default `primary`, else none.
		$preset  = isset( $attrs['preset'] ) ? $attrs['preset'] : 'fill';
		$bg_raw  = ( isset( $attrs['backgroundColor'] ) && is_string( $attrs['backgroundColor'] ) ) ? $attrs['backgroundColor'] : '';
		$bg_slug = null;
		if ( '' !== $bg_raw ) {
			if ( preg_match( '/--wp--preset--color--([\w-]+)/', $bg_raw, $bg_m ) ) {
				$bg_slug = $bg_m[1];
			}
		} elseif ( 'outline' !== $preset ) {
			$bg_slug = 'primary';
		}
		$bg_hov   = ( isset( $attrs['backgroundColorHover'] ) && is_string( $attrs['backgroundColorHover'] ) && '' !== $attrs['backgroundColorHover'] );
		$bg_value = ( null !== $bg_slug ) ? 'var(--wp--preset--color--' . $bg_slug . ')' : $bg_raw;
		if ( 'outline' !== $preset && ! $bg_hov && ! $text_hov && '' !== $bg_value ) {
			// State layer — mix the bg toward the button's OWN text colour
			// (`currentColor`). Adaptive by construction: a dark bg with light
			// text lightens, a light bg with dark text deepens — one mechanism
			// for every bg (palette slug OR custom hex). Harmonious (borrows the
			// text hue) so it never washes a light bg out (a flat lighten) nor
			// greys it (a black overlay). Assumes a contrasting text — true for
			// the default Fill-on-primary and the theme `on-*` hook.
			$decls .= 'background-color:color-mix(in srgb,' . $bg_value . ',currentColor 13%);';
		}

		if ( '' === $decls ) {
			return '';
		}
		$sel = '.bsy-button-' . $uid;
		return $sel . ':hover,' . $sel . ':focus,' . $sel . ':active{' . $decls . '}';
	}

	/**
	 * Icon-related CSS for Buttons that have an icon. Emitted by the
	 * pipeline ONLY when at least one Button on the page carries an
	 * icon (frontend) or unconditionally inside the editor canvas
	 * (where user can insert icons at any time).
	 *
	 * Three rules:
	 *   - `[class*="bsy-button-"]:has(.bsy-button__icon){gap:0.5em}`
	 *     adds icon→text spacing only when the icon span actually
	 *     exists. `:has()` is a conditional matcher — no `gap` on
	 *     icon-less buttons. (Browser support: Chrome 105+, Firefox
	 *     121+, Safari 15.4+.)
	 *   - `.bsy-button__icon` BEM child sized in em so it scales with
	 *     the button's font-size; `currentColor` flows from the
	 *     button's color.
	 *   - `.bsy-button__icon svg, ... img` fills the BEM child box.
	 */
	public static function button_icon_css() {
		return '[class*="bsy-button-"]:has(.bsy-button__icon){gap:.5em}'
			. '.bsy-button__icon{display:inline-flex;align-items:center;justify-content:center;'
			. 'flex-shrink:0;width:1.1em;height:1.1em;color:inherit}'
			. '.bsy-button__icon svg,.bsy-button__icon img{display:block;width:100%;height:100%}';
	}

	/**
	 * Emit Tabs per-instance icon-position / vertical-alignment / size /
	 * gap rules. Empty when `showIcon` is off — Tabs without icons pay
	 * zero bytes here. JS twin in `src/blocks/tabs/preview-css.js`
	 * (`buildTabsLocalCss`) must serialize declarations in the same order
	 * for byte-for-byte editor ↔ frontend cascade parity (FOUNDATION §9).
	 *
	 * Rules target the Tabs's tab-button BEM child (`.bsy-tabs__tab`)
	 * scoped under the per-instance class so different Tabs instances
	 * don't clobber each other. Vertical-alignment is omitted when
	 * `iconPosition` is `top` (column flex direction handles cross-axis
	 * via separate horizontal-alignment controls — deferred).
	 */
	public static function emit_tabs_local_rules( $uid, $attrs ) {
		$wrapper = '.bsy-tabs-' . $uid;
		// Tab flex-direction from the DESKTOP (base) icon position — threaded
		// into the Tab Layout slice's `component_defaults.layout.direction`
		// below so authoring Justify / Align doesn't fall back to `column` and
		// flip the icon. iconPosition is now responsive; this UI-fallback uses
		// the base value (the per-device structural emit lives in
		// `tabs_icon_css`, called from the `per_device` hook). JS twin:
		// `tabFlexDirection`.
		$icon_position = ( isset( $attrs['iconPosition'] ) && '' !== $attrs['iconPosition'] ) ? (string) $attrs['iconPosition'] : 'left';
		$tab_direction = empty( $attrs['showIcon'] ) ? 'row'
			: ( 'top' === $icon_position ? 'column'
			: ( 'right' === $icon_position ? 'row-reverse' : 'row' ) );

		$css = '';

		// NOTE: layout (position / width / gap / spacing / overflow / tab
		// distribution) AND the per-device icon-position structural rules are
		// emitted by `emit_tabs_device_rules` (the `per_device` hook), NOT
		// here. This tail owns only the per-element sparse-styling slices.

		// StylingRepeater — sparse-storage CSS for the three Tabs parts
		// (tablist / tab / tabActive). Twins of edit.js's
		// `buildSparsePreviewCss` calls (FOUNDATION §9.1 byte-for-byte
		// parity). `part_selector`s must match the JS wiring exactly.
		// Empty `*Styling` → aggregator short-circuits to '' (no payload).
		$tablist_styling = isset( $attrs['tablistStyling'] ) && is_array( $attrs['tablistStyling'] )
			? $attrs['tablistStyling']
			: array();
		if ( ! empty( $tablist_styling ) ) {
			$css .= Blocksify_Styling_Repeater::build_sparse_preview_css(
				$tablist_styling,
				array(
					'block_name'    => 'blocksify/tabs',
					'uid'           => $uid,
					'part_selector' => ' > .bsy-tabs__tablist',
					'consumer_name' => 'tabs',
				)
			);
		}
		$tab_styling = isset( $attrs['tabStyling'] ) && is_array( $attrs['tabStyling'] )
			? $attrs['tabStyling']
			: array();
		if ( ! empty( $tab_styling ) ) {
			$css .= Blocksify_Styling_Repeater::build_sparse_preview_css(
				$tab_styling,
				array(
					'block_name'        => 'blocksify/tabs',
					'uid'               => $uid,
					'part_selector'     => ' > .bsy-tabs__tablist > .bsy-tabs__tab.bsy-tabs__tab.bsy-tabs__tab',
					'consumer_name'     => 'tabs',
					// Decision #69 — thread the icon-derived direction so the
					// Layout group's Justify / Align emit keeps the icon's
					// axis instead of the `column` fallback in
					// `build_layout_decls`. JS twin: edit.js `tabSparseCss`.
					'component_defaults' => array(
						'layout' => array( 'direction' => $tab_direction ),
					),
				)
			);
		}
		$tab_active_styling = isset( $attrs['tabActiveStyling'] ) && is_array( $attrs['tabActiveStyling'] )
			? $attrs['tabActiveStyling']
			: array();
		if ( ! empty( $tab_active_styling ) ) {
			$css .= Blocksify_Styling_Repeater::build_sparse_preview_css(
				$tab_active_styling,
				array(
					'block_name'    => 'blocksify/tabs',
					'uid'           => $uid,
					'part_selector' => ' > .bsy-tabs__tablist > .bsy-tabs__tab.bsy-tabs__tab.bsy-tabs__tab[aria-selected="true"]',
					'consumer_name' => 'tabs',
				)
			);
		}
		// Tab Content styling — uniform per-instance styling for every tab
		// content panel. Targets `.bsy-tabs__panel` (stable BEM marker
		// class set on both editor and frontend Tab wrappers) so the
		// same rule applies to both sides. Role attribute can't be the
		// target — editor's BlockListBlock overrides our useBlockProps
		// `role="tabpanel"` to `role="document"` for keyboard nav.
		// Parent's selector beats child Tab's own trait CSS on shared
		// props (parent 0,2,0 > child 0,1,0) — intentional design choice
		// for "uniform default across all panels in a Tabs instance";
		// per-panel overrides flow through the child Tab's own Inspector.
		$tab_content_styling = isset( $attrs['tabContentStyling'] ) && is_array( $attrs['tabContentStyling'] )
			? $attrs['tabContentStyling']
			: array();
		if ( ! empty( $tab_content_styling ) ) {
			$css .= Blocksify_Styling_Repeater::build_sparse_preview_css(
				$tab_content_styling,
				array(
					'block_name'    => 'blocksify/tabs',
					'uid'           => $uid,
					'part_selector' => ' > .bsy-tabs__panel',
					'consumer_name' => 'tabs',
				)
			);
		}
		// Subtitle styling — gated behind `showSubtitle` so disabled
		// subtitles pay zero CSS bytes. JS twin gates identically in
		// `edit.js`. Scoped to `.bsy-tabs__tab-subtitle` (the inline text
		// span inside each tab button). Repeater on the Inspector side
		// surfaces only typography + color groups, so this emit
		// produces font / line-height / weight / color declarations
		// only.
		$subtitle_styling = isset( $attrs['subtitleStyling'] ) && is_array( $attrs['subtitleStyling'] )
			? $attrs['subtitleStyling']
			: array();
		if ( ! empty( $attrs['showSubtitle'] ) && ! empty( $subtitle_styling ) ) {
			$css .= Blocksify_Styling_Repeater::build_sparse_preview_css(
				$subtitle_styling,
				array(
					'block_name'    => 'blocksify/tabs',
					'uid'           => $uid,
					'part_selector' => ' > .bsy-tabs__tablist > .bsy-tabs__tab .bsy-tabs__tab-subtitle',
					'consumer_name' => 'tabs',
				)
			);
		}

		// Icon + Icon Active styling — gated behind `showIcon` (JS twin gates
		// identically in `edit.js`). Scoped to the `.bsy-tabs__tab-icon` span;
		// the active variant targets the icon inside the selected tab.
		if ( ! empty( $attrs['showIcon'] ) ) {
			$icon_styling = isset( $attrs['iconStyling'] ) && is_array( $attrs['iconStyling'] )
				? $attrs['iconStyling']
				: array();
			if ( ! empty( $icon_styling ) ) {
				$css .= Blocksify_Styling_Repeater::build_sparse_preview_css(
					$icon_styling,
					array(
						'block_name'    => 'blocksify/tabs',
						'uid'           => $uid,
						'part_selector' => ' > .bsy-tabs__tablist > .bsy-tabs__tab .bsy-tabs__tab-icon',
						'consumer_name' => 'tabs',
					)
				);
			}
			$icon_active_styling = isset( $attrs['iconActiveStyling'] ) && is_array( $attrs['iconActiveStyling'] )
				? $attrs['iconActiveStyling']
				: array();
			if ( ! empty( $icon_active_styling ) ) {
				$css .= Blocksify_Styling_Repeater::build_sparse_preview_css(
					$icon_active_styling,
					array(
						'block_name'    => 'blocksify/tabs',
						'uid'           => $uid,
						'part_selector' => ' > .bsy-tabs__tablist > .bsy-tabs__tab[aria-selected="true"] .bsy-tabs__tab-icon',
						'consumer_name' => 'tabs',
					)
				);
			}
		}

		return $css;
	}

	/**
	 * Format a `{ value, unit }` length bag as a CSS length string.
	 * Returns null on falsy / malformed input so callers skip the
	 * declaration entirely. Mirrors the JS `formatLength` helper in
	 * Tabs's preview-css.js — keep both sides byte-for-byte aligned
	 * (PHP `(float)` cast + JS `parseFloat()` produce identical output
	 * for standard integer + decimal inputs).
	 */
	private static function format_tabs_length( $bag ) {
		if ( ! is_array( $bag ) || ! isset( $bag['value'] ) ) {
			return null;
		}
		if ( '' === $bag['value'] || null === $bag['value'] ) {
			return null;
		}
		$num  = (float) $bag['value'];
		$unit = ( isset( $bag['unit'] ) && is_string( $bag['unit'] ) && '' !== $bag['unit'] )
			? $bag['unit']
			: 'em';
		return $num . $unit;
	}

	/**
	 * Resolve a responsive attr's effective value for a device by walking
	 * Mobile → Tablet → Desktop. Mirrors JS `resolveResponsive`
	 * (`src/shared/helpers.js`): an explicit `null` at a level stops the
	 * cascade (returns null); absent / non-meaningful falls through.
	 *
	 * @param array  $attrs  Block attributes.
	 * @param string $name   Base attr name.
	 * @param string $device 'desktop' | 'tablet' | 'mobile'.
	 * @return mixed Resolved value.
	 */
	private static function tabs_resolve( $attrs, $name, $device ) {
		if ( 'desktop' === $device ) {
			return isset( $attrs[ $name ] ) ? $attrs[ $name ] : null;
		}
		if ( 'tablet' === $device ) {
			if ( array_key_exists( $name . 'Tablet', $attrs ) ) {
				$t = $attrs[ $name . 'Tablet' ];
				if ( null === $t ) {
					return null;
				}
				if ( Blocksify_Attrs::is_meaningful( $t ) ) {
					return $t;
				}
			}
			return isset( $attrs[ $name ] ) ? $attrs[ $name ] : null;
		}
		// Mobile cascade.
		if ( array_key_exists( $name . 'Mobile', $attrs ) ) {
			$m = $attrs[ $name . 'Mobile' ];
			if ( null === $m ) {
				return null;
			}
			if ( Blocksify_Attrs::is_meaningful( $m ) ) {
				return $m;
			}
		}
		if ( array_key_exists( $name . 'Tablet', $attrs ) ) {
			$t = $attrs[ $name . 'Tablet' ];
			if ( null === $t ) {
				return null;
			}
			if ( Blocksify_Attrs::is_meaningful( $t ) ) {
				return $t;
			}
		}
		return isset( $attrs[ $name ] ) ? $attrs[ $name ] : null;
	}

	/**
	 * Build per-instance LAYOUT css for one device. Position layout is a
	 * bundle of interdependent properties (wrapper direction, tablist
	 * orientation / width / overflow, panel flex, tab distribution), so
	 * every device emits the FULL set with explicit values — that lets the
	 * @media cascade override a different desktop position cleanly with no
	 * leftover properties. `$panel_sel` differs editor vs frontend
	 * (`.wp-block` vs `[role="tabpanel"]`); JS twin `buildTabsLayout` in
	 * `src/blocks/tabs/preview-css.js` mirrors this for same-DOM rules.
	 *
	 * @param string $sel       Per-instance wrapper selector.
	 * @param array  $attrs     Block attributes.
	 * @param string $device    'desktop' | 'tablet' | 'mobile'.
	 * @param string $panel_sel Panel selector.
	 * @return string CSS.
	 */
	private static function tabs_layout_css( $sel, $attrs, $device, $panel_sel ) {
		$pos = self::tabs_resolve( $attrs, 'tabPosition', $device );
		if ( ! in_array( $pos, array( 'top', 'right', 'bottom', 'left' ), true ) ) {
			$pos = 'top';
		}
		$is_side = ( 'left' === $pos || 'right' === $pos );

		$width   = self::format_tabs_length( self::tabs_resolve( $attrs, 'tablistWidth', $device ) );
		$gap_bag = self::tabs_resolve( $attrs, 'tabGap', $device );
		$gap     = self::format_dim_value( is_array( $gap_bag ) ? $gap_bag : array() );
		// Inter-tab spacing — flex `gap` on the tablist itself (distinct from
		// the wrapper's tablist↔content `tabGap`). One value spaces tabs on
		// BOTH axes. JS twin: `buildTabsLayout`.
		$spacing_bag  = self::tabs_resolve( $attrs, 'tabSpacing', $device );
		$spacing      = self::format_dim_value( is_array( $spacing_bag ) ? $spacing_bag : array() );
		$spacing_decl = ( '' !== $spacing ) ? 'gap:' . $spacing . ';' : '';

		$overflow = self::tabs_resolve( $attrs, 'tabOverflow', $device );
		if ( ! in_array( $overflow, array( 'scroll', 'wrap', 'carousel', 'stack' ), true ) ) {
			$overflow = 'scroll';
		}

		// Auto-stack a left/right rail on MOBILE when no explicit mobile Tab
		// Position is set — a side rail is too narrow on a phone, so it
		// collapses to a full-width vertical stack on top. Opt out via Tab
		// Position on Mobile. JS twin mirrors this.
		if ( 'mobile' === $device && $is_side
			&& ! ( array_key_exists( 'tabPositionMobile', $attrs ) && Blocksify_Attrs::is_meaningful( $attrs['tabPositionMobile'] ) )
		) {
			$pos      = 'top';
			$is_side  = false;
			$overflow = 'stack';
		}

		$tab_width = self::tabs_resolve( $attrs, 'tabWidth', $device );
		if ( ! in_array( $tab_width, array( 'auto', 'equal', 'justified' ), true ) ) {
			$tab_width = 'auto';
		}
		$align = self::tabs_resolve( $attrs, 'tabAlign', $device );
		if ( ! in_array( $align, array( 'start', 'center', 'end' ), true ) ) {
			$align = 'start';
		}
		$align_val = ( 'center' === $align ) ? 'center' : ( ( 'end' === $align ) ? 'flex-end' : 'flex-start' );

		$dir = ( 'bottom' === $pos ) ? 'column-reverse'
			: ( ( 'left' === $pos ) ? 'row'
			: ( ( 'right' === $pos ) ? 'row-reverse' : 'column' ) );

		$tl  = '.bsy-tabs__tablist';
		$tab = $tl . '>.bsy-tabs__tab';

		$css = $sel . '{flex-direction:' . $dir . ';gap:' . ( '' !== $gap ? $gap : '0' ) . ';}';

		if ( $is_side ) {
			// Side-rail (left/right) default width is `auto` (content width) —
			// a fixed % would force an arbitrary rail size before the user opts in.
			$w   = ( null !== $width ) ? $width : 'auto';
			$ov  = ( 'scroll' === $overflow ) ? 'overflow-x:hidden;overflow-y:auto;' : 'overflow:visible;';
			$css .= $sel . '>' . $tl . '{flex-direction:column;flex-shrink:0;width:' . $w . ';justify-content:flex-start;flex-wrap:nowrap;' . $ov . $spacing_decl . '}';
			$css .= $sel . '>' . $panel_sel . '{flex:1 1 0;min-width:0;}';
			$css .= $sel . '>' . $tab . '{flex:0 0 auto;min-width:auto;}';
		} elseif ( 'stack' === $overflow ) {
			// Top/bottom + STACK: the tablist becomes a full-width vertical
			// column of tabs (a rail placed on top), so many tabs on a narrow
			// screen stack instead of scrolling off-screen. Per-device via
			// tabOverflow (already responsive) — a horizontal strip on desktop
			// can stack on mobile. `align-self:stretch` spans the full wrapper
			// width; `overflow:visible` lets the column grow tall. tabWidth +
			// tabAlign don't apply (tabs go full-width via the column's
			// cross-axis stretch). Every tablist property the row branch can
			// set is re-set here so the @media cascade overrides a row desktop
			// cleanly. JS twin: `buildTabsLayout`.
			$css .= $sel . '>' . $tl . '{flex-direction:column;flex-shrink:1;align-self:stretch;width:auto;max-width:none;flex-wrap:nowrap;overflow:visible;' . $spacing_decl . '}';
			$css .= $sel . '>' . $panel_sel . '{flex:0 1 auto;min-width:auto;}';
			$css .= $sel . '>' . $tab . '{flex:0 0 auto;min-width:auto;}';
		} else {
			// Top/bottom strip. tabWidth `auto` → the strip HUGS its content
			// (`width:fit-content`) and `tabAlign` positions it via
			// `align-self` (it's a flex item of the column wrapper) — a styled
			// strip reads as a compact, aligned pill instead of a full-width
			// bar. `fit-content` still caps at the container width so
			// scroll / carousel overflow keeps working. `equal` / `justified`
			// stay full-width so the tabs can fill it (`justify-content`
			// aligns the filled row). JS twin: `buildTabsLayout`.
			$ov         = ( 'wrap' === $overflow ) ? 'flex-wrap:wrap;overflow:visible;' : 'flex-wrap:nowrap;overflow-x:auto;';
			$hide_bar   = ( 'carousel' === $overflow ) ? 'scrollbar-width:none;' : '';
			$align_self = ( 'center' === $align ) ? 'center' : ( ( 'end' === $align ) ? 'flex-end' : 'flex-start' );
			// `max-width:100%` caps the hugging strip at the wrapper width so an
			// overflowing strip scrolls internally (carousel / scroll keep
			// working, arrows stay at the edges) instead of `fit-content`
			// spilling out to max-content.
			$sizing     = ( 'equal' === $tab_width || 'justified' === $tab_width )
				? 'width:auto;justify-content:' . $align_val . ';'
				: 'width:fit-content;max-width:100%;align-self:' . $align_self . ';';
			$css .= $sel . '>' . $tl . '{flex-direction:row;flex-shrink:1;' . $sizing . $ov . $hide_bar . $spacing_decl . '}';
			if ( 'carousel' === $overflow ) {
				$css .= $sel . '>' . $tl . '::-webkit-scrollbar{display:none;}';
			}
			$css .= $sel . '>' . $panel_sel . '{flex:0 1 auto;min-width:auto;}';
			// `min-width:auto` (not 0) so an equal / justified tab never
			// shrinks BELOW its content: on overflow (narrow viewport,
			// carousel / scroll) tabs stay content-width and the strip
			// scrolls instead of the labels cramming + overlapping.
			$dist = ( 'equal' === $tab_width ) ? 'flex:1 1 0;min-width:auto;'
				: ( ( 'justified' === $tab_width ) ? 'flex:1 1 auto;min-width:auto;' : 'flex:0 0 auto;min-width:auto;' );
			$css .= $sel . '>' . $tab . '{' . $dist . '}';
		}
		return $css;
	}

	/**
	 * Per-device adapter (`per_device` hook). Desktop always emits the base
	 * layout; tablet / mobile emit only when that device has at least one
	 * layout override (unchanged devices inherit via the absent @media).
	 * `generate_block_css` wraps tablet / mobile output in @media.
	 *
	 * @param string $uid    Per-instance uid.
	 * @param array  $attrs  Block attributes.
	 * @param string $device 'desktop' | 'tablet' | 'mobile'.
	 * @return string CSS.
	 */
	public static function emit_tabs_device_rules( $uid, $attrs, $device ) {
		if ( 'desktop' !== $device ) {
			$suffix = ucfirst( $device );
			$has    = false;
			foreach ( array( 'tabPosition', 'tablistWidth', 'tabGap', 'tabSpacing', 'tabOverflow', 'tabWidth', 'tabAlign', 'iconPosition', 'iconVerticalAlignment', 'iconSize', 'iconGap' ) as $base ) {
				if ( array_key_exists( $base . $suffix, $attrs ) ) {
					$has = true;
					break;
				}
			}
			// Mobile also emits to AUTO-STACK a left/right rail when no explicit
			// mobile Tab Position is set (see tabs_layout_css) — otherwise the
			// rail would inherit desktop and stay cramped on a phone.
			if ( ! $has && 'mobile' === $device ) {
				$mobile_pos_set = array_key_exists( 'tabPositionMobile', $attrs ) && Blocksify_Attrs::is_meaningful( $attrs['tabPositionMobile'] );
				$resolved_pos   = self::tabs_resolve( $attrs, 'tabPosition', 'mobile' );
				if ( ! $mobile_pos_set && ( 'left' === $resolved_pos || 'right' === $resolved_pos ) ) {
					$has = true;
				}
			}
			if ( ! $has ) {
				return '';
			}
		}
		return self::tabs_layout_css( '.bsy-tabs-' . $uid, $attrs, $device, '[role="tabpanel"]' )
			. self::tabs_icon_css( '.bsy-tabs-' . $uid, $attrs, $device );
	}

	/**
	 * Per-device icon-position structural rules — tab flex-direction (icon
	 * side) + align-items (vertical alignment, left/right only) + icon↔label
	 * gap on the tab button, plus width/height on the inner icon span. Gated
	 * on `showIcon`. iconPosition / iconVerticalAlignment / iconSize / iconGap
	 * are all responsive (resolved per device). Tripled-class tab selector
	 * (0,5,0) so these beat the static reset (0,4,0). JS twin:
	 * `buildTabsLocalCss` icon block. `generate_block_css` wraps tablet /
	 * mobile output in @media.
	 *
	 * @param string $sel    Per-instance wrapper selector.
	 * @param array  $attrs  Block attributes.
	 * @param string $device 'desktop' | 'tablet' | 'mobile'.
	 * @return string CSS.
	 */
	private static function tabs_icon_css( $sel, $attrs, $device ) {
		if ( empty( $attrs['showIcon'] ) ) {
			return '';
		}
		$sel_tab  = $sel . ' > .bsy-tabs__tablist > .bsy-tabs__tab.bsy-tabs__tab.bsy-tabs__tab';
		$sel_icon = $sel_tab . ' > .bsy-tabs__tab-icon';

		$position = self::tabs_resolve( $attrs, 'iconPosition', $device );
		$position = ( is_string( $position ) && '' !== $position ) ? $position : 'left';
		$direction = ( 'top' === $position ) ? 'column' : ( ( 'right' === $position ) ? 'row-reverse' : 'row' );

		$valign = self::tabs_resolve( $attrs, 'iconVerticalAlignment', $device );
		$valign = ( is_string( $valign ) && '' !== $valign ) ? $valign : 'center';
		$size   = self::format_tabs_length( self::tabs_resolve( $attrs, 'iconSize', $device ) );
		$gap_bag = self::tabs_resolve( $attrs, 'iconGap', $device );
		$gap     = self::format_dim_value( is_array( $gap_bag ) ? $gap_bag : array() );

		$tab_decls = 'flex-direction:' . $direction . ';';
		if ( 'left' === $position || 'right' === $position ) {
			switch ( $valign ) {
				case 'top':
					$tab_decls .= 'align-items:flex-start;';
					break;
				case 'bottom':
					$tab_decls .= 'align-items:flex-end;';
					break;
				case 'center':
				default:
					$tab_decls .= 'align-items:center;';
					break;
			}
		}
		if ( '' !== $gap ) {
			$tab_decls .= 'gap:' . $gap . ';';
		}

		$css = '';
		if ( '' !== $tab_decls ) {
			$css .= $sel_tab . '{' . $tab_decls . '}';
		}
		if ( null !== $size ) {
			$css .= $sel_icon . '{width:' . $size . ';height:' . $size . ';}';
		}
		return $css;
	}

	/**
	 * Emit the Icon List's per-device rules — the list-level flex on the
	 * `<ul>` wrapper (direction / justify / align / wrap / gap, reusing the
	 * shared flex builders, twin of `<FlexControl>`) PLUS the responsive
	 * icon gap (on the link) + icon size (on the icon). Per-device adapter:
	 * `generate_block_css` wraps tablet / mobile output in media queries, so
	 * this reads RAW per-device keys (overrides only) like `collect_flex_bag`.
	 *
	 * @param string $uid    Per-instance uid.
	 * @param array  $attrs  Block attributes.
	 * @param string $device 'desktop' | 'tablet' | 'mobile'.
	 * @return string CSS (no `<style>` wrapper).
	 */
	public static function emit_icon_list_device_rules( $uid, $attrs, $device ) {
		$wrapper = '.bsy-iconlist-' . $uid;
		$css     = '';

		// List flex on the <ul>.
		$bag        = self::collect_flex_bag( $attrs, $device );
		$flex_decls = self::build_flex_decls( $bag, $device );
		if ( '' !== $flex_decls ) {
			$css .= $wrapper . '{' . $flex_decls . '}';
		}

		// Responsive icon gap (link) + icon size (icon) — RAW per-device key
		// so tablet / mobile media queries carry overrides only.
		$suffix = ( 'desktop' === $device ) ? '' : ucfirst( $device );

		$gap = self::format_icon_list_length(
			isset( $attrs[ 'iconGap' . $suffix ] ) ? $attrs[ 'iconGap' . $suffix ] : null
		);
		if ( null !== $gap ) {
			$css .= $wrapper . ' .bsy-iconlist__link{gap:' . $gap . ';}';
		}

		$size = self::format_icon_list_length(
			isset( $attrs[ 'iconSize' . $suffix ] ) ? $attrs[ 'iconSize' . $suffix ] : null
		);
		if ( null !== $size ) {
			$css .= $wrapper . ' .bsy-iconlist__icon{width:' . $size . ';height:' . $size . ';}';
		}

		return $css;
	}

	/**
	 * Emit Icon List block-local tail rules — the single-value icon layout
	 * (flex-direction by `iconPosition`, align-items by
	 * `iconVerticalAlignment`) + the three StylingRepeater calls. The
	 * responsive gap / size + the list flex are emitted by
	 * `emit_icon_list_device_rules` (per_device). Twin of JS
	 * `buildIconListLocalCss` (position / valign part) + the
	 * `buildSparsePreviewCss` calls in edit.js.
	 *
	 * @param string $uid   Per-instance uid.
	 * @param array  $attrs Block attributes (defaults merged upstream).
	 * @return string CSS (no `<style>` wrapper).
	 */
	public static function emit_icon_list_local_rules( $uid, $attrs ) {
		$wrapper  = '.bsy-iconlist-' . $uid;
		$sel_link = $wrapper . ' .bsy-iconlist__link';

		$position = ( isset( $attrs['iconPosition'] ) && '' !== $attrs['iconPosition'] ) ? (string) $attrs['iconPosition'] : 'left';
		$valign   = ( isset( $attrs['iconVerticalAlignment'] ) && '' !== $attrs['iconVerticalAlignment'] ) ? (string) $attrs['iconVerticalAlignment'] : 'center';

		$link_decls = '';
		switch ( $position ) {
			case 'top':
				// Column direction — align the icon to the start (left) so
				// icons line up across items instead of centering over each
				// item's own text width.
				$link_decls .= 'flex-direction:column;align-items:flex-start;';
				break;
			case 'right':
				$link_decls .= 'flex-direction:row-reverse;';
				break;
			case 'left':
			default:
				$link_decls .= 'flex-direction:row;';
				break;
		}
		if ( 'left' === $position || 'right' === $position ) {
			switch ( $valign ) {
				case 'top':
					$link_decls .= 'align-items:flex-start;';
					break;
				case 'bottom':
					$link_decls .= 'align-items:flex-end;';
					break;
				case 'center':
				default:
					$link_decls .= 'align-items:center;';
					break;
			}
		}

		$css = '';
		if ( '' !== $link_decls ) {
			$css .= $sel_link . '{' . $link_decls . '}';
		}

		// Element styling (StylingRepeater) — twins of edit.js's
		// `buildSparsePreviewCss` calls. Empty `*Styling` short-circuits.
		$item_styling = isset( $attrs['itemStyling'] ) && is_array( $attrs['itemStyling'] ) ? $attrs['itemStyling'] : array();
		if ( ! empty( $item_styling ) ) {
			$css .= Blocksify_Styling_Repeater::build_sparse_preview_css(
				$item_styling,
				array(
					'block_name'    => 'blocksify/icon-list',
					'uid'           => $uid,
					'part_selector' => ' .bsy-iconlist__item',
					'consumer_name' => 'icon-list',
				)
			);
		}
		$icon_styling = isset( $attrs['iconStyling'] ) && is_array( $attrs['iconStyling'] ) ? $attrs['iconStyling'] : array();
		if ( ! empty( $icon_styling ) ) {
			$css .= Blocksify_Styling_Repeater::build_sparse_preview_css(
				$icon_styling,
				array(
					'block_name'    => 'blocksify/icon-list',
					'uid'           => $uid,
					'part_selector' => ' .bsy-iconlist__icon',
					'consumer_name' => 'icon-list',
				)
			);
		}
		$text_styling = isset( $attrs['textStyling'] ) && is_array( $attrs['textStyling'] ) ? $attrs['textStyling'] : array();
		if ( ! empty( $text_styling ) ) {
			$css .= Blocksify_Styling_Repeater::build_sparse_preview_css(
				$text_styling,
				array(
					'block_name'    => 'blocksify/icon-list',
					'uid'           => $uid,
					'part_selector' => ' .bsy-iconlist__text',
					'consumer_name' => 'icon-list',
				)
			);
		}

		return $css;
	}

	/**
	 * Format a length bag for Icon List. Supports Mode-B spacing tokens
	 * (`{ token: 'md' }` → `var(--blocksify--spacing--md)`) + literal
	 * `{ value, unit }` bags. Twin of the JS `formatLength` in
	 * icon-list/preview-css.js. Returns null on falsy / malformed input.
	 *
	 * @param mixed $bag Length bag or null.
	 * @return string|null
	 */
	private static function format_icon_list_length( $bag ) {
		if ( ! is_array( $bag ) ) {
			return null;
		}
		if ( isset( $bag['token'] ) && is_string( $bag['token'] ) && '' !== $bag['token'] ) {
			return 'var(--blocksify--spacing--' . $bag['token'] . ')';
		}
		if ( ! isset( $bag['value'] ) || '' === $bag['value'] || null === $bag['value'] ) {
			return null;
		}
		$num  = (float) $bag['value'];
		$unit = ( isset( $bag['unit'] ) && is_string( $bag['unit'] ) && '' !== $bag['unit'] )
			? $bag['unit']
			: 'em';
		return $num . $unit;
	}

	/**
	 * Emit Text block-local responsive `text-indent` rules. Single-class
	 * selector at (0,1,0); source order resolves cascade ties with theme
	 * rules. Mirrors JS `buildIndentPreviewCss` in `src/blocks/text/edit.js`
	 * + the responsive emit pattern that wraps non-desktop in @media.
	 */
	public static function emit_text_indent_rules( $uid, $attrs ) {
		$selector = '.bsy-text-' . $uid;
		$out      = self::build_text_indent_rule( $selector, $attrs, 'desktop' );

		$breakpoints = array(
			'tablet' => self::TABLET_MAX,
			'mobile' => self::MOBILE_MAX,
		);
		foreach ( $breakpoints as $device => $max_px ) {
			$rule = self::build_text_indent_rule( $selector, $attrs, $device );
			if ( '' !== $rule ) {
				$out .= '@media (max-width:' . $max_px . 'px){' . $rule . '}';
			}
		}
		return $out;
	}

	/**
	 * Build one device's text-indent rule string. '' when attribute is
	 * unset / malformed. Sanitizes the unit against a 4-value whitelist.
	 */
	protected static function build_text_indent_rule( $selector, $attrs, $device ) {
		$key = 'desktop' === $device ? 'indent' : 'indent' . ucfirst( $device );
		$val = isset( $attrs[ $key ] ) ? $attrs[ $key ] : null;
		if ( ! is_array( $val ) || ! isset( $val['value'] ) || ! is_numeric( $val['value'] ) ) {
			return '';
		}
		$unit          = isset( $val['unit'] ) && is_string( $val['unit'] ) ? $val['unit'] : 'px';
		$allowed_units = array( 'px', 'em', 'rem', '%' );
		if ( '' !== $unit && ! in_array( $unit, $allowed_units, true ) ) {
			$unit = 'px';
		}
		return $selector . '{text-indent:' . $val['value'] . $unit . ';}';
	}

	/**
	 * Slug + fallback table for spacing tokens. Mirrors the slug map in
	 * src/shared/stores/spacing-tokens.js and the seed values in
	 * src/shared/constants.js — both files seed from SPACING_PRESETS so
	 * adding a preset requires updating both layers.
	 *
	 * Shape: `[ '<preset id>' => [ '<slug>', '<fallback length>' ] ]`.
	 *
	 * @return array<string,array{0:string,1:string}>
	 */
	protected static function cl_spacing_token_table() {
		return array(
			'none' => array( 'none', '0px' ),
			'20'   => array( '2xs', '0.5rem' ),
			'30'   => array( 'xs',  '1rem' ),
			'40'   => array( 'sm',  '1.5rem' ),
			'50'   => array( 'md',  '2rem' ),
			'60'   => array( 'lg',  '3rem' ),
			'70'   => array( 'xl',  '4rem' ),
			'80'   => array( '2xl', '5rem' ),
			'90'   => array( '3xl', '6.5rem' ),
			'100'  => array( '4xl', '8rem' ),
			'110'  => array( '5xl', '10rem' ),
		);
	}

	/**
	 * Resolve a spacing dimension (token ref or literal) to a CSS length.
	 * Token refs become `var(--blocksify--spacing--{slug}, fallback)`;
	 * literals stay literal. Returns null when the value is missing.
	 */
	protected static function cl_spacing_to_css( $dim ) {
		if ( ! is_array( $dim ) ) {
			return null;
		}
		if ( isset( $dim['token'] ) && is_string( $dim['token'] ) ) {
			$table = self::cl_spacing_token_table();
			$entry = isset( $table[ $dim['token'] ] ) ? $table[ $dim['token'] ] : array( $dim['token'], '0' );
			return 'var(--blocksify--spacing--' . $entry[0] . ',' . $entry[1] . ')';
		}
		if ( ! isset( $dim['value'] ) || '' === $dim['value'] || null === $dim['value'] ) {
			return null;
		}
		$unit = isset( $dim['unit'] ) && is_string( $dim['unit'] ) ? $dim['unit'] : 'px';
		return $dim['value'] . $unit;
	}

	/**
	 * Format a dimension `{value, unit}` array as a CSS length string.
	 * Token refs delegate to `cl_spacing_to_css`. Returns null when
	 * the dimension has no meaningful value.
	 */
	protected static function cl_dim_to_css( $dim ) {
		if ( ! is_array( $dim ) ) {
			return null;
		}
		if ( isset( $dim['token'] ) && is_string( $dim['token'] ) ) {
			return self::cl_spacing_to_css( $dim );
		}
		if ( ! isset( $dim['value'] ) || '' === $dim['value'] || null === $dim['value'] ) {
			return null;
		}
		$unit = isset( $dim['unit'] ) && is_string( $dim['unit'] ) ? $dim['unit'] : 'px';
		return $dim['value'] . $unit;
	}

	/**
	 * Emit per-instance CSS for blocksify/content-loop.
	 *
	 * Emits direct CSS properties on `.bsy-cl-{uid}.cl-is-grid` — NO custom
	 * property indirection. Static rules in `src/blocks/content-loop/style.css`
	 * carry the design defaults; this function emits per-instance overrides
	 * only when an attribute deviates from the default OR a responsive override
	 * exists. Token refs (Mode B) resolve to `var(--blocksify--spacing--{slug},
	 * literal)` via `cl_dim_to_css()`. Must match JS twin
	 * `buildContentLoopPreviewCss` byte-for-byte (FOUNDATION §9.1).
	 *
	 * Phase 1+2 scope: columns + col-gap + row-gap. Layer B sparse styling
	 * (zones/elements/overlay slots) lands in Phase 3 via a separate walker.
	 *
	 * @param string $uid   8-char hex uid.
	 * @param array  $attrs Block attributes.
	 * @return string
	 */
	public static function emit_content_loop_local_rules( $uid, $attrs ) {
		$selector = '.bsy-cl-' . $uid . '.cl-is-grid';

		$desktop = self::content_loop_decls( $attrs, 'desktop' );
		$tablet  = self::content_loop_decls( $attrs, 'tablet' );
		$mobile  = self::content_loop_decls( $attrs, 'mobile' );

		$css = '' !== $desktop ? $selector . '{' . $desktop . '}' : '';
		if ( '' !== $tablet ) {
			$css .= '@media (max-width:' . self::TABLET_MAX . 'px){' . $selector . '{' . $tablet . '}}';
		}
		if ( '' !== $mobile ) {
			$css .= '@media (max-width:' . self::MOBILE_MAX . 'px){' . $selector . '{' . $mobile . '}}';
		}

		// Image element <img>-level rule. Static `.bsy-card__media-image img`
		// defaults to `aspect-ratio: 16/9; object-fit: cover` so only deviating
		// values emit. JS twin: `buildImageImgDecls` in preview-css.js.
		$img_decls = self::content_loop_image_img_decls( $attrs );
		if ( '' !== $img_decls ) {
			$css .= '.bsy-cl-' . $uid . ' .bsy-card__media-image img{' . $img_decls . '}';
		}

		// Image hover effect (zoom) — card-hover scoped, CSS-only. JS twin:
		// `buildImageHoverCss` in preview-css.js.
		$css .= self::content_loop_image_hover_css( $attrs, $uid );

		// Background overlay scrim on the media figure (`::before` pseudo) —
		// per-device, wrapped in `@media` queries for tablet / mobile.
		// JS twin: `buildMediaBgOverlayCss` in preview-css.js.
		$css .= self::content_loop_media_bg_overlay_css( $attrs, $uid );

		// Per-element sparse-storage CSS — twin of edit.js's
		// `walkLayout`+`buildSparsePreviewCss` loop. Empty when no element
		// in the layout has authored styling.
		// See `Blocksify_Content_Loop_Renderer::emit_layout_sparse_css`.
		$layout = isset( $attrs['layout'] ) ? $attrs['layout'] : null;
		if ( is_array( $layout ) ) {
			$css .= Blocksify_Content_Loop_Renderer::emit_layout_sparse_css( $layout, $uid, $attrs );
		}

		// Phase 6.1 — pagination sparse CSS (per-item + outer wrapper).
		// Twin of edit.js's pagination CSS pipeline. Always called — the
		// helper short-circuits when both `paginationItems` styling and
		// `paginationStyling` are empty, producing no output.
		$css .= Blocksify_Content_Loop_Renderer::emit_pagination_sparse_css( $attrs, $uid );

		return $css;
	}

	/**
	 * Build the `.bsy-card__media-image::before` scrim decls for ONE device
	 * (flat-suffix per FOUNDATION §6.4). Mirror of
	 * `buildMediaBgOverlayDeclsForDevice` in preview-css.js.
	 *
	 * @param array  $bg     Sparse `bgOverlay` bag.
	 * @param string $device 'desktop' | 'tablet' | 'mobile'.
	 * @return string Decls body. '' when the device has nothing authored.
	 */
	protected static function content_loop_media_bg_overlay_decls( $bg, $device ) {
		if ( ! is_array( $bg ) ) {
			return '';
		}
		// 'hover' maps to the device-agnostic `{field}Hover` keys (§6.4).
		$suffix       = ( 'tablet' === $device )
			? 'Tablet'
			: ( ( 'mobile' === $device )
				? 'Mobile'
				: ( ( 'hover' === $device ) ? 'Hover' : '' ) );
		$color_key    = 'color' . $suffix;
		$gradient_key = 'gradient' . $suffix;
		$opacity_key  = 'opacity' . $suffix;

		$color    = isset( $bg[ $color_key ] ) ? $bg[ $color_key ] : null;
		$gradient = isset( $bg[ $gradient_key ] ) ? $bg[ $gradient_key ] : null;
		$opacity  = isset( $bg[ $opacity_key ] ) ? $bg[ $opacity_key ] : null;

		$has_color    = is_string( $color ) && '' !== $color;
		$has_gradient = is_string( $gradient ) && '' !== $gradient;
		$has_opacity  = is_numeric( $opacity )
			&& (float) $opacity >= 0
			&& (float) $opacity < 100;
		if ( ! $has_color && ! $has_gradient && ! $has_opacity ) {
			return '';
		}
		$out = '';
		if ( $has_color || $has_gradient ) {
			$layers = array();
			if ( $has_gradient ) {
				$layers[] = $gradient;
			}
			if ( $has_color ) {
				$layers[] = $color;
			}
			$out .= 'background:' . implode( ',', $layers ) . ';';
		}
		if ( $has_opacity ) {
			$out .= 'opacity:' . ( (float) $opacity / 100 ) . ';';
		}
		return $out;
	}

	/**
	 * Build all three device emits for the media bg overlay, each wrapped
	 * in its `@media` query for tablet / mobile. Mirror of
	 * `buildMediaBgOverlayCss` in preview-css.js — keep byte-equal for
	 * the same input (FOUNDATION §9.1).
	 *
	 * @param array  $attrs Block attributes.
	 * @param string $uid   Block uid.
	 * @return string CSS string ('' when no overlay authored).
	 */
	protected static function content_loop_media_bg_overlay_css( $attrs, $uid ) {
		$bg = isset( $attrs['layout']['media']['bgOverlay'] ) && is_array( $attrs['layout']['media']['bgOverlay'] )
			? $attrs['layout']['media']['bgOverlay']
			: null;
		if ( ! is_array( $bg ) ) {
			return '';
		}
		$sel       = '.bsy-cl-' . $uid . ' .bsy-card__media-image::before';
		$hover_sel = '.bsy-cl-' . $uid . ' .bsy-card:hover .bsy-card__media-image::before';
		$hover     = self::content_loop_media_bg_overlay_decls( $bg, 'hover' );
		$css       = '';

		$desktop = self::content_loop_media_bg_overlay_decls( $bg, 'desktop' );
		// Transition the scrim only when a hover layer exists (smooth fade).
		// Twin: `buildMediaBgOverlayCss` in preview-css.js (non-hover path).
		$base_desktop = ( '' !== $hover )
			? $desktop . 'transition:' . Blocksify_Content_Loop_Renderer::MEDIA_OVERLAY_HOVER_TRANSITION . ';'
			: $desktop;
		if ( '' !== $base_desktop ) {
			$css .= $sel . '{' . $base_desktop . '}';
		}
		$tablet = self::content_loop_media_bg_overlay_decls( $bg, 'tablet' );
		if ( '' !== $tablet ) {
			$css .= '@media (max-width:' . self::TABLET_MAX . 'px){' . $sel . '{' . $tablet . '}}';
		}
		$mobile = self::content_loop_media_bg_overlay_decls( $bg, 'mobile' );
		if ( '' !== $mobile ) {
			$css .= '@media (max-width:' . self::MOBILE_MAX . 'px){' . $sel . '{' . $mobile . '}}';
		}
		// Card-hover scrim override — only the authored hover props; the
		// browser cascade keeps the base values for everything else.
		if ( '' !== $hover ) {
			$css .= $hover_sel . '{' . $hover . '}';
		}
		return $css;
	}

	/**
	 * Build the `<img>`-level decls (aspect-ratio + object-fit). Mirror of
	 * `buildImageImgDecls` in preview-css.js — keep both byte-for-byte for
	 * the same input (FOUNDATION §9.1).
	 *
	 * @param array $attrs Block attributes.
	 * @return string Decls body (no selector). '' when defaults match.
	 */
	protected static function content_loop_image_img_decls( $attrs ) {
		$image = isset( $attrs['layout']['media']['image']['value'] ) && is_array( $attrs['layout']['media']['image']['value'] )
			? $attrs['layout']['media']['image']['value']
			: null;
		if ( ! is_array( $image ) ) {
			return '';
		}
		$out = '';
		$ar  = isset( $image['aspectRatio'] ) ? (string) $image['aspectRatio'] : '';
		if ( 'original' === $ar ) {
			$out .= 'aspect-ratio:auto;';
		} elseif (
			'' !== $ar
			&& Blocksify_Content_Loop_Renderer::IMAGE_DEFAULT_ASPECT_RATIO !== $ar
			&& preg_match( '#^[0-9]+/[0-9]+$#', $ar )
		) {
			$out .= 'aspect-ratio:' . $ar . ';';
		}
		$of = isset( $image['objectFit'] ) ? (string) $image['objectFit'] : '';
		if (
			'' !== $of
			&& Blocksify_Content_Loop_Renderer::IMAGE_DEFAULT_OBJECT_FIT !== $of
		) {
			$out .= 'object-fit:' . $of . ';';
		}
		// `object-position` — Phase 5 element settings. Static CSS implies
		// the spec default `center`, so emit only when the user overrides.
		$op = isset( $image['objectPosition'] ) ? (string) $image['objectPosition'] : '';
		$allowed_positions = array( 'center', 'top', 'bottom', 'left', 'right' );
		if (
			'' !== $op
			&& Blocksify_Content_Loop_Renderer::IMAGE_DEFAULT_OBJECT_POSITION !== $op
			&& in_array( $op, $allowed_positions, true )
		) {
			$out .= 'object-position:' . $op . ';';
		}
		return $out;
	}

	/**
	 * Build the image hover-effect (zoom) rules — triggered on CARD hover
	 * (`.bsy-card:hover`) so the image zooms when the user hovers anywhere
	 * on the card (pairs with the card-hover overlay scrim). CSS-only.
	 * Mirror of `buildImageHoverCss` in preview-css.js; keep byte-for-byte
	 * (FOUNDATION §9.1).
	 *
	 *   - zoom-in  → `img` gets a transition; card:hover scales it up.
	 *   - zoom-out → `img` rests scaled up + transitioned; card:hover
	 *                returns it to scale 1.
	 *
	 * Both stay >= 1 so the `overflow:hidden` media box never gaps.
	 * `prefers-reduced-motion` opt-out lives in static style.css.
	 *
	 * @param array  $attrs Block attributes.
	 * @param string $uid   8-char hex uid.
	 * @return string CSS string ('' when no effect).
	 */
	protected static function content_loop_image_hover_css( $attrs, $uid ) {
		$image = isset( $attrs['layout']['media']['image']['value'] ) && is_array( $attrs['layout']['media']['image']['value'] )
			? $attrs['layout']['media']['image']['value']
			: null;
		if ( ! is_array( $image ) ) {
			return '';
		}
		$effect = isset( $image['hoverEffect'] ) ? (string) $image['hoverEffect'] : '';
		if ( 'zoom-in' !== $effect && 'zoom-out' !== $effect ) {
			return '';
		}
		$base  = '.bsy-cl-' . $uid . ' .bsy-card__media-image img';
		$hover = '.bsy-cl-' . $uid . ' .bsy-card:hover .bsy-card__media-image img';
		$scale = Blocksify_Content_Loop_Renderer::IMAGE_HOVER_ZOOM_SCALE;
		$trans = Blocksify_Content_Loop_Renderer::IMAGE_HOVER_TRANSITION;
		if ( 'zoom-out' === $effect ) {
			return $base . '{transition:' . $trans . ';transform:scale(' . $scale . ');}'
				. $hover . '{transform:scale(1);}';
		}
		return $base . '{transition:' . $trans . ';}'
			. $hover . '{transform:scale(' . $scale . ');}';
	}

	/**
	 * Build the CSS declarations for one device. Returns the body of the rule
	 * (`grid-template-columns:…;column-gap:…;row-gap:…;`) or empty string when
	 * nothing for that device.
	 *
	 * Desktop emits when the value deviates from the static-CSS default
	 * (columns default 3; col/row gap default null → no emit). Tablet/mobile
	 * emit only when the device-suffixed attribute is explicitly set.
	 */
	protected static function content_loop_decls( $attrs, $device ) {
		$suffix = ( 'tablet' === $device ) ? 'Tablet' : ( ( 'mobile' === $device ) ? 'Mobile' : '' );
		$out    = '';

		$cols_key   = 'columns' . $suffix;
		$cols_value = isset( $attrs[ $cols_key ] ) ? $attrs[ $cols_key ] : null;
		if ( null !== $cols_value && '' !== $cols_value ) {
			$cols_int = max( 1, min( 5, (int) $cols_value ) );
			$emit_cols = true;
			if ( 'desktop' === $device && 3 === $cols_int ) {
				// Static CSS already has `repeat(3, ...)` — skip the redundant rule.
				$emit_cols = false;
			}
			if ( $emit_cols ) {
				$out .= 'grid-template-columns:repeat(' . $cols_int . ',minmax(0,1fr));';
			}
		}

		$gap_key    = 'gap' . $suffix;
		$col_gap_css = self::cl_dim_to_css( isset( $attrs[ $gap_key ] ) ? $attrs[ $gap_key ] : null );
		if ( null !== $col_gap_css ) {
			$out .= 'column-gap:' . $col_gap_css . ';';
		}

		$row_gap_key = 'rowGap' . $suffix;
		$row_gap_css = self::cl_dim_to_css( isset( $attrs[ $row_gap_key ] ) ? $attrs[ $row_gap_key ] : null );
		if ( null !== $row_gap_css ) {
			$out .= 'row-gap:' . $row_gap_css . ';';
		}

		return $out;
	}


	/**
	 * Emit CSS rules for a single device. Reads flat suffixed attrs.
	 *   - Desktop: emit base values when they deviate from static defaults.
	 *   - Tablet/mobile: emit only the rules whose own keys (e.g. `gapTablet`)
	 *     exist. Cascade is handled by browser via media-query stack.
	 *
	 * @param string $uid    8-char hex uid.
	 * @param array  $attrs  Block attributes (flat).
	 * @param string $device 'desktop' | 'tablet' | 'mobile'.
	 * @return string
	 */
	public static function emit_device_rules( $uid, $attrs, $device ) {
		$section_sel = '.bsy-section-' . $uid;
		$inner_sel   = $section_sel . ' > .bsy-section__inner';
		$rules       = '';

		if ( 'desktop' === $device ) {
			$count = isset( $attrs['count'] ) ? (int) $attrs['count'] : 2;
			$fr    = self::normalize_fr(
				isset( $attrs['fr'] ) ? $attrs['fr'] : array(),
				$count
			);
			// Skip per-instance emit only when fr exactly matches what the
			// static `.bsy-has-{count}-cols` class would emit (uniform `[1,
			// 1,...,1]` of length count). Reshape presets store fr.length !=
			// count intentionally (e.g. fr=[1] on count=3 for Single Column,
			// or fr=[1,1] on count=3 for "Two Columns · Last Item Wraps") —
			// without the length check, those skip the per-instance emit and
			// the frontend falls back to the static 3-col rule, silently
			// dropping the reshape.
			$matches_static = count( $fr ) === $count && self::is_equal_ratios( $fr );
			if ( ! $matches_static ) {
				$rules .= $inner_sel . '{grid-template-columns:' . self::emit_grid_template( $fr ) . ';}';
			}

			$gap     = self::normalize_gap( isset( $attrs['gap'] ) ? $attrs['gap'] : null );
			$row_gap = self::normalize_gap( isset( $attrs['rowGap'] ) ? $attrs['rowGap'] : null );
			if ( ! self::gap_equals( $gap, self::default_gap() ) || ! self::gap_equals( $row_gap, self::default_gap() ) ) {
				$rules .= $inner_sel . '{' . self::emit_gap( $gap, $row_gap ) . '}';
			}

			$span = isset( $attrs['span'] ) ? $attrs['span'] : null;
			if ( ! empty( $span ) ) {
				$rules .= self::emit_span_rule( $uid, $span, count( $fr ) );
			}

			return $rules;
		}

		// Tablet or mobile — emit only the keys that this device explicitly
		// overrides. Cascade handled by browser via media query stack.
		$suffix = 'tablet' === $device ? 'Tablet' : 'Mobile';

		$fr_key = 'fr' . $suffix;
		if ( isset( $attrs[ $fr_key ] ) && is_array( $attrs[ $fr_key ] ) && count( $attrs[ $fr_key ] ) > 0 ) {
			$fr = self::normalize_fr( $attrs[ $fr_key ], count( $attrs[ $fr_key ] ) );
			$rules .= $inner_sel . '{grid-template-columns:' . self::emit_grid_template( $fr ) . ';}';
		}

		$gap_key     = 'gap' . $suffix;
		$row_gap_key = 'rowGap' . $suffix;
		$has_gap     = isset( $attrs[ $gap_key ] );
		$has_row_gap = isset( $attrs[ $row_gap_key ] );
		if ( $has_gap || $has_row_gap ) {
			// One axis can override while the other inherits — fall back to
			// desktop value for the inheriting axis so the shorthand stays
			// coherent.
			$gap = $has_gap
				? self::normalize_gap( $attrs[ $gap_key ] )
				: self::normalize_gap( isset( $attrs['gap'] ) ? $attrs['gap'] : null );
			$row_gap = $has_row_gap
				? self::normalize_gap( $attrs[ $row_gap_key ] )
				: self::normalize_gap( isset( $attrs['rowGap'] ) ? $attrs['rowGap'] : null );
			$rules .= $inner_sel . '{' . self::emit_gap( $gap, $row_gap ) . '}';
		}

		$span_key = 'span' . $suffix;
		if ( isset( $attrs[ $span_key ] ) && in_array( $attrs[ $span_key ], array( 'first', 'last' ), true ) ) {
			$fr_for_span = isset( $attrs[ $fr_key ] ) && is_array( $attrs[ $fr_key ] )
				? $attrs[ $fr_key ]
				: ( isset( $attrs['fr'] ) && is_array( $attrs['fr'] ) ? $attrs['fr'] : array() );
			$rules .= self::emit_span_rule( $uid, $attrs[ $span_key ], count( $fr_for_span ) );
		}

		return $rules;
	}

	/**
	 * Convert fr integer array to grid-template-columns value.
	 *
	 * @param array $fr Integer fr values.
	 * @return string
	 */
	public static function emit_grid_template( $fr ) {
		$parts = array();
		foreach ( $fr as $value ) {
			$parts[] = 'minmax(0,' . ( (int) $value ) . 'fr)';
		}
		return implode( ' ', $parts );
	}

	/**
	 * Emit gap shorthand. Single value when col === row, otherwise "row col".
	 *
	 * @param array $col_gap { value, unit } object for column gap.
	 * @param array $row_gap { value, unit } object for row gap.
	 * @return string CSS declaration ending in a semicolon.
	 */
	public static function emit_gap( $col_gap, $row_gap ) {
		$col = self::format_gap_value( $col_gap );
		$row = self::format_gap_value( $row_gap );
		if ( $col === $row ) {
			return 'gap:' . $col . ';';
		}
		return 'gap:' . $row . ' ' . $col . ';';
	}

	/**
	 * Format a { value, unit } gap object into a CSS length string. Returns
	 * "0" for zero-valued gaps regardless of unit.
	 *
	 * @param array|int|float|null $gap Gap value.
	 * @return string
	 */
	public static function format_gap_value( $gap ) {
		// Mode B token reference — emit `var()` with literal fallback.
		// Same rationale as `Blocksify_Spacing_Trait::format_side()`.
		if (
			is_array( $gap )
			&& isset( $gap['token'] )
			&& is_string( $gap['token'] )
			&& '' !== $gap['token']
		) {
			return Blocksify_Tokens::var_expression( 'spacing', $gap['token'] );
		}
		if ( is_array( $gap ) ) {
			$value = isset( $gap['value'] ) ? (float) $gap['value'] : 0;
			$unit  = isset( $gap['unit'] ) && is_string( $gap['unit'] ) && '' !== $gap['unit']
				? $gap['unit']
				: 'px';
		} else {
			$value = is_numeric( $gap ) ? (float) $gap : 0;
			$unit  = 'px';
		}
		if ( 0.0 === $value ) {
			return '0';
		}
		$formatted = rtrim( rtrim( sprintf( '%F', $value ), '0' ), '.' );
		return $formatted . $unit;
	}

	/**
	 * Compare two gap objects for equality.
	 *
	 * @param mixed $a First gap value.
	 * @param mixed $b Second gap value.
	 * @return bool
	 */
	public static function gap_equals( $a, $b ) {
		return self::format_gap_value( $a ) === self::format_gap_value( $b );
	}

	/**
	 * The Medium preset — canonical default for freshly inserted Sections.
	 * Mirrors DEFAULT_GAP in src/shared/constants.js.
	 *
	 * @return array
	 */
	public static function default_gap() {
		return array(
			'value' => 1.5,
			'unit'  => 'rem',
		);
	}

	/**
	 * Emit the span rule for Pattern B ("last") or Pattern C ("first") reshape.
	 *
	 * @param string $uid   8-char hex uid.
	 * @param string $span  'first' | 'last'.
	 * @param int    $count Column count (unused in phase 1; kept for future).
	 * @return string
	 */
	public static function emit_span_rule( $uid, $span, $count ) {
		unset( $count );
		// Section's frontend children are the block instances themselves
		// (no chrome). Selector targets the inner's direct children — no
		// `.bsy-container` filter (the rendered class is `bsy-container-
		// {uid}`, not the bare `.bsy-container`, so the old selector
		// never matched). Section can also wrap non-Container children
		// (Image, Text at the top level of a Section), and span-reshape
		// applies regardless of which block sits at the wrap position.
		$selector = '.bsy-section-' . $uid . ' > .bsy-section__inner';
		if ( 'last' === $span ) {
			return $selector . ' > :last-child:nth-child(odd){grid-column:span 2;}';
		}
		if ( 'first' === $span ) {
			return $selector . ' > :first-child:nth-last-child(odd){grid-column:span 2;}';
		}
		return '';
	}

	/**
	 * Check whether all fr values are equal. Equal-ratio layouts are covered
	 * by the static .bsy-has-N-cols classes, so no inline rule needed.
	 *
	 * @param array $fr Integer fr values.
	 * @return bool
	 */
	public static function is_equal_ratios( $fr ) {
		if ( ! is_array( $fr ) || count( $fr ) === 0 ) {
			return true;
		}
		$first = (int) $fr[0];
		foreach ( $fr as $value ) {
			if ( (int) $value !== $first ) {
				return false;
			}
		}
		return true;
	}

	/**
	 * Validate a UID as 8 lowercase hex chars.
	 *
	 * @param mixed $uid Raw uid value.
	 * @return string
	 */
	public static function sanitize_uid( $uid ) {
		if ( ! is_string( $uid ) ) {
			return '';
		}
		return preg_match( '/^[a-f0-9]{8}$/', $uid ) ? $uid : '';
	}

	/**
	 * Coerce an fr array. Each value is clamped to a positive integer.
	 * If empty, returns an array of `1`s with length = $count.
	 *
	 * @param mixed $fr    Raw fr value.
	 * @param int   $count Fallback count when $fr is empty.
	 * @return array
	 */
	protected static function normalize_fr( $fr, $count ) {
		$out = array();
		if ( is_array( $fr ) ) {
			foreach ( $fr as $value ) {
				$out[] = max( 1, (int) $value );
			}
		}
		if ( empty( $out ) ) {
			$count = max( 1, min( 6, (int) $count ) );
			$out   = array_fill( 0, $count, 1 );
		}
		return $out;
	}

	/**
	 * Coerce a raw gap attribute into one of two canonical shapes:
	 *   - Mode B token: `{ token: 'xl' }` — preserved as-is so
	 *     `format_gap_value` can emit the `var(--blocksify--spacing--…)`
	 *     reference with a literal fallback (mirrors the Spacing trait
	 *     token branch in `Blocksify_Spacing_Trait::normalize_side`).
	 *   - Legacy value/unit: `{ value: 1.5, unit: 'rem' }` — clamps to
	 *     non-negative, falls back to the Medium default for missing
	 *     fields.
	 *
	 * Without the token branch, token-shaped saved attrs collapsed to the
	 * default value/unit pair via the fallbacks below; the downstream
	 * `gap_equals( …, default_gap() )` guard in `emit_device_rules` then
	 * read the gap as "no override" and skipped emission, leaving the
	 * frontend on the static `1.5rem` fallback while the editor's JS
	 * twin (`formatGap` in `src/blocks/section/edit.js`) emitted the
	 * token correctly — a JS↔PHP parity violation per FOUNDATION §9.1.
	 *
	 * @param mixed $value Raw attribute value.
	 * @return array
	 */
	protected static function normalize_gap( $value ) {
		$default = self::default_gap();
		if ( ! is_array( $value ) ) {
			return $default;
		}
		if (
			isset( $value['token'] )
			&& is_string( $value['token'] )
			&& '' !== $value['token']
		) {
			return array( 'token' => $value['token'] );
		}
		$num = isset( $value['value'] ) && is_numeric( $value['value'] )
			? max( 0, (float) $value['value'] )
			: $default['value'];
		$unit = isset( $value['unit'] ) && is_string( $value['unit'] ) && '' !== $value['unit']
			? $value['unit']
			: $default['unit'];
		return array(
			'value' => $num,
			'unit'  => $unit,
		);
	}

	/**
	 * Emit per-part wrapper rules for one device — sister method to
	 * `emit_wrapper_rules` for blocks declaring `blocksifyParts` (SPEC-
	 * trait-parts §3.2). Walks each declared part, collects per-trait
	 * `emit_decls` output (using the part name as `$part_prefix` so traits
	 * read `tabBg` / `tabActiveBg` / etc. instead of root `bg` keys).
	 *
	 * Selector format: `.bsy-{block}-{uid}{part_selector_suffix}`. Suffix
	 * is the verbatim string declared in the adapter map (e.g.
	 * ` > .bsy-tabs__tablist > .bsy-tabs__tab`). Single-class specificity
	 * baseline (0,1,0) — source order resolves cascade ties with root.
	 *
	 * Trait-to-part membership is read from `Blocksify_Traits::
	 * block_trait_parts()` (which reads `blocksifySupports.{trait}.parts`
	 * from block.json). Only traits that opt in to a given part contribute
	 * to that part's rule.
	 *
	 * @param string $uid        8-char hex uid.
	 * @param array  $attrs      Block attributes (flat-suffix shape).
	 * @param string $device     'desktop' | 'tablet' | 'mobile'.
	 * @param string $base       Per-block class prefix (e.g. 'bsy-tabs-').
	 * @param array  $parts      part-name → selector-suffix map.
	 * @param string $block_type Full block name (e.g. 'blocksify/tabs').
	 * @return string CSS rules (no @media wrapper).
	 */
	public static function emit_part_wrapper_rules( $uid, $attrs, $device, $base, $parts, $block_type ) {
		if ( empty( $parts ) || ! is_array( $parts ) ) {
			return '';
		}
		$declared_traits = Blocksify_Traits::block_traits( $block_type );
		if ( empty( $declared_traits ) ) {
			return '';
		}
		$css = '';
		foreach ( $parts as $part_name => $selector_suffix ) {
			if ( ! is_string( $part_name ) || '' === $part_name ) {
				continue;
			}
			$decls = '';
			foreach ( $declared_traits as $trait_name ) {
				$trait_parts = Blocksify_Traits::block_trait_parts( $block_type, $trait_name );
				if ( empty( $trait_parts ) || ! in_array( $part_name, $trait_parts, true ) ) {
					continue;
				}
				$class = Blocksify_Traits::trait_class( $trait_name );
				if ( $class && method_exists( $class, 'emit_decls' ) ) {
					$decls .= $class::emit_decls( $attrs, $device, $part_name );
				}
			}
			if ( '' !== $decls ) {
				$css .= '.' . $base . $uid . $selector_suffix . '{' . $decls . '}';
			}
		}
		return $css;
	}

	/**
	 * Per-part hover layer — sister to `emit_hover_wrapper_rules`. Builds
	 * `.bsy-{block}-{uid}{suffix}:hover` rules from per-trait
	 * `emit_hover_decls` output, scoped to the part prefix so traits
	 * read `tabBgHover` / `tabActiveBgHover` / etc.
	 *
	 * @param string $uid        8-char hex uid.
	 * @param array  $attrs      Block attributes.
	 * @param string $base       Per-block class prefix.
	 * @param array  $parts      part-name → selector-suffix map.
	 * @param string $block_type Full block name.
	 * @return string CSS rules.
	 */
	public static function emit_part_hover_wrapper_rules( $uid, $attrs, $base, $parts, $block_type ) {
		if ( empty( $parts ) || ! is_array( $parts ) ) {
			return '';
		}
		$declared_traits = Blocksify_Traits::block_traits( $block_type );
		if ( empty( $declared_traits ) ) {
			return '';
		}
		$css = '';
		foreach ( $parts as $part_name => $selector_suffix ) {
			if ( ! is_string( $part_name ) || '' === $part_name ) {
				continue;
			}
			$decls = '';
			foreach ( $declared_traits as $trait_name ) {
				$trait_parts = Blocksify_Traits::block_trait_parts( $block_type, $trait_name );
				if ( empty( $trait_parts ) || ! in_array( $part_name, $trait_parts, true ) ) {
					continue;
				}
				$class = Blocksify_Traits::trait_class( $trait_name );
				if ( $class && method_exists( $class, 'emit_hover_decls' ) ) {
					$decls .= $class::emit_hover_decls( $attrs, $part_name );
				}
			}
			if ( '' !== $decls ) {
				$css .= '.' . $base . $uid . $selector_suffix . ':hover{' . $decls . '}';
			}
		}
		return $css;
	}

	// =================================================================
	// Trait-owned emit code (post-Phase-5):
	//   Color → Blocksify_Color_Trait (text + bg + link)
	//   Background → Blocksify_Background_Trait (image + gradient + video
	//     visibility + overlay + scrollEffect)
	//   Spacing → Blocksify_Spacing_Trait (padding + margin)
	//   Border → Blocksify_Border_Trait (border + radius + shadow)
	//   Size → Blocksify_Size_Trait
	//   Overflow → Blocksify_Overflow_Trait
	//   Position → Blocksify_Position_Trait
	//   Z-Index → Blocksify_ZIndex_Trait
	// `emit_wrapper_rules` / `emit_hover_wrapper_rules` delegate via
	// each trait's `::emit_decls()` / `::emit_hover_decls()`.
	// =================================================================
}
