Code/Generation/SuiScssGenerator.cs
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using SboxUiDesigner.Runtime;

namespace SboxUiDesigner.Generation;

/// <summary>
/// SCSS emitter. Produces a single .razor.scss file per .sui document, with
/// the document's PanelComponent type name as the outer selector and one
/// nested rule per element. The nested-under-type-selector convention is
/// what every Facepunch sample uses (see ui-razor.md).
///
/// Every emitted property/value pair runs through
/// <see cref="SuiAllowedPropertyList.Validate"/> first; rejected emissions
/// produce a generation error rather than silent garbage in the output.
///
/// Engine constraints enforced (per ui-anti-patterns.md, Confusion 2):
/// - display: only `flex` or `none`
/// - position: only `static`, `relative`, `absolute`
/// - no CSS Grid, no `position: fixed`, no `display: block`
///
/// Visibility mapping (designer abstraction → real CSS):
/// - Visible -> no rule
/// - Hidden -> `opacity: 0` (still occupies layout space)
/// - Collapsed -> `display: none` (removed from layout)
///
/// Pointer-events emitted only when non-default (None == default == no rule).
/// </summary>
public sealed class SuiScssGenerator
{
	private readonly StringBuilder _sb = new();
	private readonly List<string> _errors = new();
	private readonly List<string> _warnings = new();
	private SuiDocument _doc;
	private Dictionary<string, SuiElement> _byId;

	public string Generate( SuiGenerationContext ctx, SuiGenerationResult result )
	{
		_sb.Clear();
		_errors.Clear();
		_warnings.Clear();

		_doc = ctx?.Document;
		if ( _doc == null )
		{
			result.Errors.Add( "scss: document is null" );
			return "";
		}

		_byId = new();
		foreach ( var el in _doc.Elements )
			if ( !string.IsNullOrEmpty( el.Id ) ) _byId[el.Id] = el;

		var typeName = SuiNameSanitizer.ToCSharpIdentifier( ctx.ClassName ?? _doc.Name );
		var root = _doc.GetRoot();

		_sb.Append( SuiHeaderEmitter.EmitScssHeader( _doc ) );
		_sb.Append( '\n' );

		_sb.AppendLine( $"{typeName} {{" );
		if ( root != null )
		{
			EmitElement( root, depth: 1, isRoot: true );
		}
		_sb.AppendLine( "}" );

		// User-owned sidecar — emitted ONLY in Final mode (compile-to-output).
		// SuiCompileWriter creates <typeName>.User.scss once and never overwrites,
		// so the @import always resolves at runtime. In Preview mode the cache
		// writer doesn't emit a sidecar; importing a missing file silently breaks
		// SCSS compilation and the panel ends up unstyled, so we skip the import.
		if ( ctx.Mode == SuiGenerationMode.Final )
		{
			_sb.AppendLine();
			_sb.AppendLine( $"// User-protected styles for {typeName} — safe to edit." );
			_sb.AppendLine( $"@import \"{typeName}.User.scss\";" );
		}

		foreach ( var e in _errors ) result.Errors.Add( e );
		foreach ( var w in _warnings ) result.Warnings.Add( w );

		return _sb.ToString();
	}

	// ─────────────────────────────────────────────────────────────────────
	//  Per-element emission
	// ─────────────────────────────────────────────────────────────────────

	private void EmitElement( SuiElement el, int depth, bool isRoot, SuiLayoutMode parentMode = SuiLayoutMode.Absolute )
	{
		if ( el == null ) return;
		var indent = new string( ' ', depth * 2 );
		// Use the per-element UNIQUE class (sui-<id>) as the selector, not
		// the user-provided ClassName which is intentionally shared across
		// siblings. Without this, multiple slots all called ".slot" would
		// cascade-overwrite each other and only the last rule would win.
		var selectorClass = SuiRazorGenerator.ElementUniqueClass( el );

		_sb.Append( indent ).Append( '.' ).Append( selectorClass ).AppendLine( " {" );

		// Layout block — parent's mode controls whether THIS element is positioned
		// (parent=Absolute) or flowed (parent=Flex).
		EmitLayout( el, depth + 1, isRoot, parentMode );

		// Style block
		EmitStyle( el, depth + 1 );

		// Type-specific props that turn into CSS
		EmitTypeProps( el, depth + 1 );

		// Recurse into children — pass THIS element's mode so children know
		// whether their parent flows them or positions them.
		// Grid/InventoryGrid/Hotbar are flex containers via EmitTypeProps even
		// when Layout.Mode == Absolute, so their children must be treated as
		// flex items (no position:absolute) or they all stack at (0,0).
		var childParentMode = el.Layout?.Mode ?? SuiLayoutMode.Absolute;
		if ( el.Type == SuiElementType.Grid
			|| el.Type == SuiElementType.InventoryGrid
			|| el.Type == SuiElementType.Hotbar )
		{
			childParentMode = SuiLayoutMode.Flex;
		}
		foreach ( var childId in el.Children )
		{
			if ( _byId.TryGetValue( childId, out var child ) )
			{
				_sb.AppendLine();
				EmitElement( child, depth + 1, isRoot: false, parentMode: childParentMode );
			}
		}

		_sb.Append( indent ).AppendLine( "}" );
	}

	// ─────────────────────────────────────────────────────────────────────
	//  Layout (Absolute / Flex + spacing)
	// ─────────────────────────────────────────────────────────────────────

	private void EmitLayout( SuiElement el, int depth, bool isRoot, SuiLayoutMode parentMode )
	{
		var l = el.Layout;
		if ( l == null ) return;

		var hasOverlayChildren = el.Type == SuiElementType.Overlay;

		// Decide whether THIS element is "positioned" (position:absolute + anchor)
		// or "flowed" (a flex item under its parent). Root always positions itself.
		// Otherwise: parent.Mode == Absolute  → position absolute via anchor.
		//            parent.Mode == Flex      → flex item, no position, no x/y.
		var positionedByParent = isRoot || parentMode == SuiLayoutMode.Absolute;

		if ( positionedByParent )
		{
			Emit( depth, "position", "absolute" );

			if ( isRoot )
			{
				// Root always fills the panel — it represents the document's
				// 1920x1080 drawable area. Stretch via right:0;bottom:0 instead
				// of explicit width/height because the runtime world panel's
				// own internal Scale interacts unpredictably with explicit px
				// widths on root.
				Emit( depth, "left", "0" );
				Emit( depth, "top", "0" );
				Emit( depth, "right", "0" );
				Emit( depth, "bottom", "0" );
			}
			else
			{
				EmitAnchorRules( depth, l );

				// For Stretch anchors, Layout.Width / Layout.Height are MARGINS
				// (right/bottom), not the element's box size — EmitAnchorRules
				// already wrote them as `right: …` / `bottom: …`. Emitting them
				// here as `width/height` again would shrink the element to that
				// margin value (e.g. width: 8px) and defeat the stretch.
				var isStretchAnchor =
					l.Anchor == SuiAnchor.Stretch ||
					l.Anchor == SuiAnchor.StretchHorizontal ||
					l.Anchor == SuiAnchor.StretchVertical;
				if ( !isStretchAnchor )
				{
					if ( l.Width > 0f ) Emit( depth, "width", $"{Px(l.Width)}" );
					if ( l.Height > 0f ) Emit( depth, "height", $"{Px(l.Height)}" );
				}
				else
				{
					// StretchHorizontal: Height (cross axis) is still a real size.
					// StretchVertical: Width (cross axis) is still a real size.
					// Stretch: both axes are margins, neither emitted.
					if ( l.Anchor == SuiAnchor.StretchHorizontal && l.Height > 0f )
						Emit( depth, "height", $"{Px( l.Height )}" );
					if ( l.Anchor == SuiAnchor.StretchVertical && l.Width > 0f )
						Emit( depth, "width", $"{Px( l.Width )}" );
				}
				if ( l.MinWidth.HasValue ) Emit( depth, "min-width", $"{Px( l.MinWidth.Value )}" );
				if ( l.MinHeight.HasValue ) Emit( depth, "min-height", $"{Px( l.MinHeight.Value )}" );
				if ( l.MaxWidth.HasValue ) Emit( depth, "max-width", $"{Px( l.MaxWidth.Value )}" );
				if ( l.MaxHeight.HasValue ) Emit( depth, "max-height", $"{Px( l.MaxHeight.Value )}" );
				if ( l.ZIndex != 0 ) Emit( depth, "z-index", l.ZIndex.ToString( CultureInfo.InvariantCulture ) );
			}
		}
		else
		{
			// Flex item — let the parent's flex layout place us. Emit
			// position: relative so any absolute-positioned descendants
			// (e.g. an Image child with Stretch anchor) anchor to THIS
			// element's box, not to a distant positioned ancestor like the
			// root card. Without this, all such descendants overlap at the
			// nearest positioned ancestor (visible as one icon stretching
			// across the whole card with the others hidden behind it).
			Emit( depth, "position", "relative" );
			if ( l.Width > 0f ) Emit( depth, "width", $"{Px(l.Width)}" );
			if ( l.Height > 0f ) Emit( depth, "height", $"{Px(l.Height)}" );
			if ( l.MinWidth.HasValue ) Emit( depth, "min-width", $"{Px( l.MinWidth.Value )}" );
			if ( l.MinHeight.HasValue ) Emit( depth, "min-height", $"{Px( l.MinHeight.Value )}" );
			if ( l.MaxWidth.HasValue ) Emit( depth, "max-width", $"{Px( l.MaxWidth.Value )}" );
			if ( l.MaxHeight.HasValue ) Emit( depth, "max-height", $"{Px( l.MaxHeight.Value )}" );
		}

		// If THIS element is a Flex container, emit display:flex + its props
		// regardless of how it's positioned by its parent (a flex item can itself
		// be a flex container — that's how rows-inside-columns work).
		if ( l.Mode == SuiLayoutMode.Flex )
		{
			Emit( depth, "display", "flex" );
			Emit( depth, "flex-direction", FlexDirection( l.FlexDirection ) );
			if ( l.JustifyContent != SuiJustifyContent.FlexStart )
				Emit( depth, "justify-content", JustifyContent( l.JustifyContent ) );
			// Default align-items in s&box runtime is `stretch`; only emit if different
			if ( l.AlignItems != SuiAlignItems.Stretch )
				Emit( depth, "align-items", AlignItems( l.AlignItems ) );
			if ( l.FlexWrap != SuiFlexWrap.NoWrap )
				Emit( depth, "flex-wrap", FlexWrap( l.FlexWrap ) );
			if ( l.Gap > 0f ) Emit( depth, "gap", Px( l.Gap ) );
		}

		// Overlay containers are flexed but each child uses absolute — emit
		// position: relative so children with position: absolute anchor here.
		if ( hasOverlayChildren && l.Mode != SuiLayoutMode.Absolute && positionedByParent == false )
			Emit( depth, "position", "relative" );

		// Margin / padding
		EmitSpacing( depth, "margin", l.Margin );
		EmitSpacing( depth, "padding", l.Padding );
	}

	private void EmitAnchorRules( int depth, SuiLayoutData l )
	{
		// Note: PRD doc 08 lays out the canonical anchor → CSS mapping. For MVP
		// we emit explicit left/top from x/y plus width/height. Right-anchored
		// elements use right: instead of left:; centered ones use the
		// transform fallback. Stretch anchors override width/height with 100%.
		switch ( l.Anchor )
		{
			case SuiAnchor.TopLeft:
				Emit( depth, "left", Px( l.X ) );
				Emit( depth, "top", Px( l.Y ) );
				break;

			case SuiAnchor.TopRight:
				Emit( depth, "right", Px( l.X ) );
				Emit( depth, "top", Px( l.Y ) );
				break;

			case SuiAnchor.BottomLeft:
				Emit( depth, "left", Px( l.X ) );
				Emit( depth, "bottom", Px( l.Y ) );
				break;

			case SuiAnchor.BottomRight:
				Emit( depth, "right", Px( l.X ) );
				Emit( depth, "bottom", Px( l.Y ) );
				break;

			// Center-based anchors: position the anchor reference at the center
			// of the relevant axis, then apply X/Y as additional pixel offset
			// (chained translate). Without the second translate the element
			// stays glued to the centerline regardless of X/Y, which breaks
			// drag-to-move + selection chrome (hit-test honors X/Y).
			case SuiAnchor.TopCenter:
				Emit( depth, "left", "50%" );
				Emit( depth, "top", Px( l.Y ) );
				Emit( depth, "transform", $"translateX(-50%) translateX({Px( l.X )})" );
				break;

			case SuiAnchor.BottomCenter:
				Emit( depth, "left", "50%" );
				Emit( depth, "bottom", Px( l.Y ) );
				Emit( depth, "transform", $"translateX(-50%) translateX({Px( l.X )})" );
				break;

			case SuiAnchor.MiddleLeft:
				Emit( depth, "left", Px( l.X ) );
				Emit( depth, "top", "50%" );
				Emit( depth, "transform", $"translateY(-50%) translateY({Px( l.Y )})" );
				break;

			case SuiAnchor.MiddleRight:
				Emit( depth, "right", Px( l.X ) );
				Emit( depth, "top", "50%" );
				Emit( depth, "transform", $"translateY(-50%) translateY({Px( l.Y )})" );
				break;

			case SuiAnchor.MiddleCenter:
				Emit( depth, "left", "50%" );
				Emit( depth, "top", "50%" );
				Emit( depth, "transform", $"translate(-50%, -50%) translate({Px( l.X )}, {Px( l.Y )})" );
				break;

			case SuiAnchor.Stretch:
				Emit( depth, "left", "0" );
				Emit( depth, "top", "0" );
				Emit( depth, "right", "0" );
				Emit( depth, "bottom", "0" );
				break;

			case SuiAnchor.StretchHorizontal:
				Emit( depth, "left", "0" );
				Emit( depth, "right", "0" );
				Emit( depth, "top", Px( l.Y ) );
				break;

			case SuiAnchor.StretchVertical:
				Emit( depth, "top", "0" );
				Emit( depth, "bottom", "0" );
				Emit( depth, "left", Px( l.X ) );
				break;
		}
	}

	private void EmitSpacing( int depth, string property, SuiSpacing s )
	{
		if ( s == null || s.IsZero ) return;
		if ( s.IsUniform )
		{
			Emit( depth, property, Px( s.Left ) );
		}
		else
		{
			// Emit shorthand "top right bottom left"
			Emit( depth, property, $"{Px(s.Top)} {Px(s.Right)} {Px(s.Bottom)} {Px(s.Left)}" );
		}
	}

	// ─────────────────────────────────────────────────────────────────────
	//  Style (background, border, opacity, visibility, pointer-events)
	// ─────────────────────────────────────────────────────────────────────

	private void EmitStyle( SuiElement el, int depth )
	{
		var s = el.Style;
		if ( s == null ) return;

		if ( !string.IsNullOrEmpty( s.BackgroundColor ) ) Emit( depth, "background-color", s.BackgroundColor );

		// Border: emit BOTH width and color, or NEITHER. Emitting width alone
		// makes s&box's CSS parser fall back to a white border (canvas paint
		// skips the stroke entirely when color is empty), causing a Preview vs
		// Canvas divergence the user can't fix from the inspector.
		var hasBorderColor = !string.IsNullOrEmpty( s.BorderColor );
		var hasBorderWidth = s.BorderWidth > 0f;
		if ( hasBorderColor && hasBorderWidth )
		{
			Emit( depth, "border-color", s.BorderColor );
			Emit( depth, "border-width", Px( s.BorderWidth ) );
		}
		if ( s.BorderRadius > 0f ) Emit( depth, "border-radius", Px( s.BorderRadius ) );

		// Opacity: only emit if hidden (visibility=Hidden) OR explicitly < 1.
		if ( s.Visibility == SuiVisibility.Hidden )
		{
			Emit( depth, "opacity", "0" );
		}
		else if ( s.Opacity < 0.9999f )
		{
			Emit( depth, "opacity", Float( s.Opacity ) );
		}

		if ( s.Visibility == SuiVisibility.Collapsed )
		{
			Emit( depth, "display", "none" );
		}

		// Pointer-events: only emit if non-default. Default is None.
		if ( s.PointerEvents == SuiPointerEvents.All )
		{
			Emit( depth, "pointer-events", "all" );
		}

		// Overflow: only emit if non-default (Visible).
		if ( s.Overflow == SuiOverflow.Hidden )
			Emit( depth, "overflow", "hidden" );
		else if ( s.Overflow == SuiOverflow.Scroll )
			Emit( depth, "overflow", "scroll" );
	}

	// ─────────────────────────────────────────────────────────────────────
	//  Type-specific props (Text/Image/Grid/etc)
	// ─────────────────────────────────────────────────────────────────────

	private void EmitTypeProps( SuiElement el, int depth )
	{
		var p = el.Props;
		if ( p == null ) return;

		switch ( el.Type )
		{
			case SuiElementType.Text:
				if ( p.FontSize > 0f ) Emit( depth, "font-size", Px( p.FontSize ) );
				if ( !string.IsNullOrEmpty( p.FontFamily ) ) Emit( depth, "font-family", p.FontFamily );
				if ( p.FontWeight != SuiFontWeight.Normal ) Emit( depth, "font-weight", FontWeight( p.FontWeight ) );
				if ( !string.IsNullOrEmpty( p.Color ) ) Emit( depth, "color", p.Color );
				if ( p.TextAlign != SuiTextAlign.Left ) Emit( depth, "text-align", TextAlign( p.TextAlign ) );
				if ( p.LineHeight.HasValue ) Emit( depth, "line-height", Float( p.LineHeight.Value ) );
				if ( p.LetterSpacing != 0f ) Emit( depth, "letter-spacing", Px( p.LetterSpacing ) );
				if ( p.TextOverflow != SuiTextOverflow.Clip )
					Emit( depth, "text-overflow", p.TextOverflow == SuiTextOverflow.Ellipsis ? "ellipsis" : "clip" );

				// TextSizeMode-specific rules — controls wrap/sizing/vertical alignment.
				switch ( p.TextSizeMode )
				{
					case SuiTextSizeMode.Auto:
						// Single-line, content-sized. Override any width/height the
						// shared layout pass might have emitted by re-emitting auto.
						Emit( depth, "white-space", "nowrap" );
						Emit( depth, "width", "auto" );
						Emit( depth, "height", "auto" );
						break;
					case SuiTextSizeMode.AutoHeightWrap:
						// Width fixed, height grows with wrap. Don't emit explicit height.
						Emit( depth, "white-space", "normal" );
						Emit( depth, "height", "auto" );
						break;
					case SuiTextSizeMode.Fixed:
						// User width/height are emitted normally by the layout pass.
						// Vertical align via flex on the Text element.
						Emit( depth, "display", "flex" );
						Emit( depth, "flex-direction", "column" );
						Emit( depth, "justify-content", VerticalAlignToJustify( p.VerticalAlign ) );
						break;
				}
				break;

			case SuiElementType.Image:
			case SuiElementType.ItemIcon:
			case SuiElementType.InventorySlot:
			{
				// Source path: Image uses ImagePath; ItemIcon and InventorySlot
				// store their icon in PreviewIconPath (the same field that the
				// canvas's PaintItemIcon reads). Without this fallback, the
				// runtime SCSS emits no background-image for ItemIcon and the
				// slot/icon shows empty in Play even though the canvas paints
				// it correctly — pure SCSS/canvas divergence.
				var imagePath = !string.IsNullOrEmpty( p.ImagePath ) ? p.ImagePath : p.PreviewIconPath;
				if ( !string.IsNullOrEmpty( imagePath ) )
				{
					Emit( depth, "background-image", $"url(\"{imagePath}\")" );
					Emit( depth, "background-size", FitMode( p.FitMode ) );
					Emit( depth, "background-position", BgPosition( p.BackgroundPosition ) );
					// CSS default for background-repeat is "repeat", which makes
					// Contain/Cover/None tile when the fitted image is smaller
					// than the container. Force no-repeat so what the canvas
					// paints matches what the runtime shows.
					Emit( depth, "background-repeat", "no-repeat" );
					if ( !string.IsNullOrEmpty( p.Tint ) && p.Tint != "#ffffff" && p.Tint != "#FFFFFF" )
						Emit( depth, "background-image-tint", p.Tint );
				}
				break;
			}

			case SuiElementType.Button:
				// Button = <div class="btn"><label class="label">text</label></div>.
				// Center the label inside the div via flex and emit text styles on
				// both the outer (for hover/state hooks) and the inner .label
				// (so the text actually inherits the user-configured font + color
				// without relying on the runtime's CSS-inheritance behavior, which
				// has been spotty for font-size on nested labels).
				Emit( depth, "display", "flex" );
				Emit( depth, "flex-direction", "row" );
				Emit( depth, "justify-content", "center" );
				Emit( depth, "align-items", "center" );

				// Emit per-button text styles on the inner .label using a nested
				// rule. We close it before continuing other emissions.
				var indentLabel = new string( ' ', depth * 2 );
				_sb.Append( indentLabel ).AppendLine( ".label {" );
				if ( p.FontSize > 0f ) Emit( depth + 1, "font-size", Px( p.FontSize ) );
				if ( !string.IsNullOrEmpty( p.FontFamily ) ) Emit( depth + 1, "font-family", p.FontFamily );
				if ( p.FontWeight != SuiFontWeight.Normal ) Emit( depth + 1, "font-weight", FontWeight( p.FontWeight ) );
				if ( !string.IsNullOrEmpty( p.Color ) ) Emit( depth + 1, "color", p.Color );
				if ( p.TextAlign != SuiTextAlign.Left ) Emit( depth + 1, "text-align", TextAlign( p.TextAlign ) );
				_sb.Append( indentLabel ).AppendLine( "}" );
				break;

			case SuiElementType.Grid:
			case SuiElementType.InventoryGrid:
			case SuiElementType.Hotbar:
				// Wrapped-flex strategy (PRD doc 08 strategy A).
				if ( p.Columns <= 0 || p.Rows <= 0 || p.CellWidth <= 0f || p.CellHeight <= 0f )
					break;

				// If Layout.Mode == Flex, EmitLayout already wrote display/flex/gap
				// from the user's settings. Re-emitting here causes duplicate
				// declarations (later wins): cell-derived `gap: 4px` overrode the
				// user's `gap: 8px`, and cell-derived `width: 64px` overrode the
				// user's `width: 800px`, collapsing the Hotbar to one cell wide.
				var gridLayout = el.Layout;
				var alreadyFlex = gridLayout != null && gridLayout.Mode == SuiLayoutMode.Flex;
				if ( !alreadyFlex )
				{
					Emit( depth, "display", "flex" );
					Emit( depth, "flex-direction", "row" );
					Emit( depth, "flex-wrap", el.Type == SuiElementType.Hotbar ? "nowrap" : "wrap" );
					Emit( depth, "gap", Px( p.GridGap ) );
				}

				// Only auto-size from cells when the user hasn't pinned a size.
				// Pinned size (Layout.Width/Height > 0) is the user's intent;
				// derived size is just a sensible default for new grids.
				// IMPORTANT: when the grid has a border (border-width > 0) AND
				// the runtime uses border-box sizing (s&box's default), the
				// border eats into the content area. A grid sized exactly
				// `Cols × CellW + (Cols-1) × Gap` will wrap one fewer column
				// than intended because the last item overflows by `borderW × 2`.
				// Compensate by adding the border allowance up front.
				// Padding emission is handled separately via EmitSpacing so a
				// user-configured padding does NOT need to be added here.
				var hasExplicitW = gridLayout != null && gridLayout.Width > 0f;
				var hasExplicitH = gridLayout != null && gridLayout.Height > 0f;
				var borderSlack = 2f * ((el.Style?.BorderWidth ?? 0f) > 0f ? el.Style.BorderWidth : 0f);
				var paddingSlackX = (el.Layout?.Padding?.Left ?? 0f) + (el.Layout?.Padding?.Right ?? 0f);
				var paddingSlackY = (el.Layout?.Padding?.Top ?? 0f) + (el.Layout?.Padding?.Bottom ?? 0f);
				if ( !hasExplicitW )
				{
					var w = p.Columns * p.CellWidth + (p.Columns - 1) * p.GridGap + borderSlack + paddingSlackX;
					Emit( depth, "width", Px( w ) );
				}
				if ( !hasExplicitH )
				{
					var h = p.Rows * p.CellHeight + (p.Rows - 1) * p.GridGap + borderSlack + paddingSlackY;
					Emit( depth, "height", Px( h ) );
				}
				break;

			case SuiElementType.ProgressBar:
				// Bare container background; M9 doesn't generate the inner fill div
				// — V1 will. Just ensures the bar shape exists.
				break;
		}
	}

	// ─────────────────────────────────────────────────────────────────────
	//  Emit helper with allowed-property validation
	// ─────────────────────────────────────────────────────────────────────

	private void Emit( int depth, string property, string value )
	{
		var err = SuiAllowedPropertyList.Validate( property, value );
		if ( err != null )
		{
			_errors.Add( $"scss emit blocked: {err}" );
			return;
		}
		var indent = new string( ' ', depth * 2 );
		_sb.Append( indent ).Append( property ).Append( ": " ).Append( value ).AppendLine( ";" );
	}

	private static string Px( float v ) => v == 0f ? "0" : v.ToString( "0.##", CultureInfo.InvariantCulture ) + "px";
	private static string Float( float v ) => v.ToString( "0.###", CultureInfo.InvariantCulture );


	// ─────────────────────────────────────────────────────────────────────
	//  Enum → CSS keyword
	// ─────────────────────────────────────────────────────────────────────

	private static string FlexDirection( SuiFlexDirection d ) => d switch
	{
		SuiFlexDirection.Row => "row",
		SuiFlexDirection.Column => "column",
		SuiFlexDirection.RowReverse => "row-reverse",
		SuiFlexDirection.ColumnReverse => "column-reverse",
		_ => "row",
	};

	private static string JustifyContent( SuiJustifyContent j ) => j switch
	{
		SuiJustifyContent.FlexStart => "flex-start",
		SuiJustifyContent.Center => "center",
		SuiJustifyContent.FlexEnd => "flex-end",
		SuiJustifyContent.SpaceBetween => "space-between",
		SuiJustifyContent.SpaceAround => "space-around",
		SuiJustifyContent.SpaceEvenly => "space-evenly",
		_ => "flex-start",
	};

	private static string AlignItems( SuiAlignItems a ) => a switch
	{
		SuiAlignItems.FlexStart => "flex-start",
		SuiAlignItems.Center => "center",
		SuiAlignItems.FlexEnd => "flex-end",
		SuiAlignItems.Stretch => "stretch",
		SuiAlignItems.Baseline => "baseline",
		_ => "stretch",
	};

	private static string FlexWrap( SuiFlexWrap w ) => w switch
	{
		SuiFlexWrap.NoWrap => "nowrap",
		SuiFlexWrap.Wrap => "wrap",
		SuiFlexWrap.WrapReverse => "wrap-reverse",
		_ => "nowrap",
	};

	private static string TextAlign( SuiTextAlign a ) => a switch
	{
		SuiTextAlign.Left => "left",
		SuiTextAlign.Center => "center",
		SuiTextAlign.Right => "right",
		SuiTextAlign.Justify => "justify",
		_ => "left",
	};

	private static string FontWeight( SuiFontWeight w ) => w switch
	{
		SuiFontWeight.Light => "300",
		SuiFontWeight.Normal => "400",
		SuiFontWeight.Medium => "500",
		SuiFontWeight.SemiBold => "600",
		SuiFontWeight.Bold => "700",
		SuiFontWeight.ExtraBold => "800",
		_ => "400",
	};

	private static string VerticalAlignToJustify( SuiVerticalAlign v ) => v switch
	{
		SuiVerticalAlign.Center => "center",
		SuiVerticalAlign.Bottom => "flex-end",
		_ => "flex-start",
	};

	private static string FitMode( SuiImageFitMode m ) => m switch
	{
		SuiImageFitMode.Contain => "contain",
		SuiImageFitMode.Cover => "cover",
		SuiImageFitMode.Stretch => "100% 100%",
		SuiImageFitMode.None => "auto",
		_ => "contain",
	};

	private static string BgPosition( SuiBackgroundPosition p ) => p switch
	{
		SuiBackgroundPosition.Center => "center",
		SuiBackgroundPosition.Top => "top",
		SuiBackgroundPosition.Bottom => "bottom",
		SuiBackgroundPosition.Left => "left",
		SuiBackgroundPosition.Right => "right",
		SuiBackgroundPosition.TopLeft => "left top",
		SuiBackgroundPosition.TopRight => "right top",
		SuiBackgroundPosition.BottomLeft => "left bottom",
		SuiBackgroundPosition.BottomRight => "right bottom",
		_ => "center",
	};
}