Editor/DesignerWindow.cs
using System.Collections.Generic;
using System.Linq;
using Editor;
using Grains.RazorDesigner.Canvas;
using Grains.RazorDesigner.Document;
using Grains.RazorDesigner.Hierarchy;
using Grains.RazorDesigner.Inspector;
using Grains.RazorDesigner.Palette;
using Grains.RazorDesigner.Selection;
using Grains.RazorDesigner.Projection;
using Grains.RazorDesigner.Serialization;
using Grains.RazorDesigner.Templates;
using Grains.RazorDesigner.Validation;
using Sandbox;
using Sandbox.UI;

namespace Grains.RazorDesigner;

[Dock( "Editor", "Razor Designer", "brush" )]
public class DesignerWindow : Widget
{
	private const string LogPrefix = "[Grains.RazorDesigner]";
	private const string SplitterOuterCookie = "razordesigner.splitter.outer";
	private const string SplitterLeftCookie  = "razordesigner.splitter.left";
	private const string ThemePathCookie     = "razordesigner.theme.path";
	private const string CanvasSizeCookie    = "razordesigner.canvas.size";
	private const string ChromeHiddenCookie  = "razordesigner.chrome.hidden";
	private const string PreviewTargetCookie = "razordesigner.canvas.target";
	private const string GridShowCookie      = "razordesigner.canvas.grid.show";
	private const string GridSnapCookie      = "razordesigner.canvas.grid.snap";
	private const string GridSizeCookie      = "razordesigner.canvas.grid.size";
	private const string ArtboardFillCookie   = "razordesigner.canvas.artboard.fill";
	private const string ArtboardCustomCookie = "razordesigner.canvas.artboard.custom";

	private static readonly Vector2 DefaultViewport = new( 1920, 1080 );
	private const string FitCookieValue = "fit";
	// First-open grid defaults — show + snap on, 16px step. See RestoreGridFromCookies.
	private const float DefaultGridStep = 16f;

	private Layout _canvasHost;
	private LiveTreeMirror _mirror;
	private DesignerWindowController _controller;
	private PalettePanel _palette;
	private HierarchyPanel _hierarchy;
	private InspectorPanel _inspector;
	private StateStylingWindow _stateStyling;
	private DesignerDocument _document;
	private SelectionController _selection;
	private OverlayController _overlay;
	private CanvasManipulator _manipulator;
	private Splitter _outerSplitter;
	private Splitter _leftStackSplitter;
	private Editor.Option _saveOption;
	private Editor.Option _themeOption;
	private Editor.Option _targetOption;
	private Editor.Option _chromeHiddenOption;
	// Inline canvas-size widgets — replace the prior "Preview ▾" viewport-presets dropdown.
	private Editor.LineEdit _widthEdit;
	private Editor.LineEdit _heightEdit;
	private Editor.Option _fitOption;
	// Inline grid widgets — replace the prior "Grid ▾" dropdown. State mirrored in _gridShow/Snap/Step.
	private Editor.Option _gridShowOption;
	private Editor.Option _gridSnapOption;
	private Editor.LineEdit _gridStepEdit;
	private bool _gridShow;
	private bool _gridSnap;
	private float _gridStep = DefaultGridStep;

	// Artboard fill picker — small "Artboard ▾" button with menu (Dark / Mid / Light / Checker / Custom).
	private Editor.Option _artboardOption;
	private ArtboardFillMode _artboardFill = ArtboardFillMode.Dark;
	private Color _artboardCustom = new( 0.137f, 0.165f, 0.200f );
	// Re-import toolbar button — enabled only when _trackedPath is set and a .razor sibling exists.
	private Editor.Option _reimportOption;
	// W2 — "Code ▾" toolbar button that opens the CodeDialog for State + Parameters.
	private Editor.Option _codeOption;
	// Non-null when a stamp-mismatch banner is pending; shown in inspector diagnostics until dismissed.
	private IReadOnlyList<ValidationDiagnostic> _stampMismatchDiags;
	private bool _chromeHidden;
	private CanvasViewportFrame _viewportFrame;
	private Vector2? _viewport;        // null = Fit
	private Vector2 _lastSizedViewport = new( 1920, 1080 );
	private PreviewTargetMode _previewTarget = PreviewTargetMode.None;
	private PreviewTheme _theme = PreviewTheme.Default;
	private string _trackedPath;
	private Editor.Label _fileLabel;
	private string _lastAppliedCss;
	private string _razorExportFailedSuffix;
	// bd memory: stylesheet-leak. Track sheet by ref; Remove(string) is a no-op on FromString sheets.
	private StyleSheet _previewSheet;

	private static bool _serializerWarmed;
	private static void WarmSerializerAsync()
	{
		if ( _serializerWarmed ) return;
		_serializerWarmed = true;
		System.Threading.Tasks.Task.Run( () =>
		{
			try
			{
				var sw = System.Diagnostics.Stopwatch.StartNew();
				_ = Serialization.IR.IRWriter.WriteDocument( new DesignerDocument() );
				Log.Info( $"{LogPrefix} serializer warm-up done ({sw.Elapsed.TotalMilliseconds:F0}ms)" );
			}
			catch ( System.Exception ex )
			{
				Log.Warning( $"{LogPrefix} serializer warm-up failed: {ex.GetType().Name}: {ex.Message}" );
			}
		} );
	}

	public DesignerWindow( Widget parent ) : base( parent )
	{
		Log.Info( $"{LogPrefix} DesignerWindow ctor" );
		WarmSerializerAsync();

		Layout = Layout.Column();
		Layout.Margin = 0;
		Layout.Spacing = 0;

		MinimumWidth = 960;
		MinimumHeight = 640;

		// ── Toolbar strip: [ fileBar (text+icon) │ divider │ viewBar (icon-only) ……… filename chip ] ──
		var toolStrip = Layout.Row();

		// File operations — text beside icon so the buttons people hunt for are legible.
		var fileBar = new ToolBar( this, "RazorDesignerFileBar" );
		fileBar.SetIconSize( 16 );
		fileBar.ButtonStyle = ToolButtonStyle.TextBesideIcon;
		fileBar.SetStyles( "QToolButton:disabled { color: #4d4d4d; }" );

		var newOption = fileBar.AddOption( "New", "add", () => OnNew() );
		newOption.ToolTip = "New design (clears canvas)  [Ctrl+N]";
		newOption.StatusTip = "Discard the current design and start fresh.";

		var openOption = fileBar.AddOption( "Open", "folder_open", () => OnOpen() );
		openOption.ToolTip = "Open design (.razor or .razor.json)  [Ctrl+O]";
		openOption.StatusTip = "Replace current design with one loaded from a .razor or .razor.json file.";

		_saveOption = fileBar.AddOption( "Save", "save", () => OnSave() );
		_saveOption.ToolTip = "Save design (.razor + .razor.scss)  [Ctrl+S]";
		_saveOption.StatusTip = "Write .razor markup and paired .razor.scss stylesheet.";

		fileBar.AddSeparator();

		_reimportOption = fileBar.AddOption( "Re-import", "upload_file", () => OnReimportFromRazor() );
		_reimportOption.ToolTip = "File → Re-import from .razor  (lift hand-edits back into the IR)";
		_reimportOption.StatusTip = "Re-parse the .razor file via LegacyRazorImporter. Shows a loss-preview if differences are found.";
		_reimportOption.Enabled = false;   // enabled once a file with a .razor sibling is tracked

		toolStrip.Add( fileBar );

		var toolStripDivider = new Widget( this );
		toolStripDivider.FixedWidth = 1;
		toolStripDivider.SetSizeMode( SizeMode.Default, SizeMode.CanGrow );
		toolStripDivider.OnPaintOverride = () =>
		{
			Paint.SetBrushAndPen( Color.Parse( "#1d1d1d" ).Value );
			Paint.DrawRect( toolStripDivider.LocalRect );
			return true;   // suppress default OnPaint
		};
		toolStrip.AddSpacingCell( 4 );
		toolStrip.Add( toolStripDivider );
		toolStrip.AddSpacingCell( 4 );

		var viewBar = new ToolBar( this, "RazorDesignerViewBar" );
		viewBar.SetIconSize( 16 );
		viewBar.ButtonStyle = ToolButtonStyle.TextBesideIcon;

		_themeOption = viewBar.AddOption( "Theme ▾", "palette", OnThemeButton );
		_themeOption.StatusTip = "Choose preview theme — Default or load a custom .scss.";
		RestoreThemeFromCookie();
		UpdateThemeOptionTooltip();

		toolStrip.Add( viewBar );

		RestoreViewportFromCookie();

		toolStrip.AddSpacingCell( 8 );
		_widthEdit = MakeNumericFieldEdit( _lastSizedViewport.x, "Canvas width (logical pixels)" );
		_widthEdit.EditingFinished += () => OnSizeFieldCommitted();
		toolStrip.Add( _widthEdit );

		var sizeSeparator = new Editor.Label( this ) { Text = "×" };
		sizeSeparator.SetStyles( "color: #777; padding: 0 4px;" );
		toolStrip.Add( sizeSeparator );

		_heightEdit = MakeNumericFieldEdit( _lastSizedViewport.y, "Canvas height (logical pixels)" );
		_heightEdit.EditingFinished += () => OnSizeFieldCommitted();
		toolStrip.Add( _heightEdit );

		toolStrip.AddSpacingCell( 4 );
		var sizeBar = new ToolBar( this, "RazorDesignerSizeBar" );
		sizeBar.SetIconSize( 16 );
		sizeBar.ButtonStyle = ToolButtonStyle.TextBesideIcon;
		_fitOption = sizeBar.AddOption( "Fit", "fit_screen", OnFitToggle );
		_fitOption.Checkable = true;
		_fitOption.Checked = !_viewport.HasValue;
		_fitOption.ToolTip = "Fit canvas to the pane (no fixed viewport size).";
		_fitOption.StatusTip = "When on, the canvas fills the pane instead of rendering at a fixed pixel size.";
		toolStrip.Add( sizeBar );

		// ── viewBar2 (mid): Target ▾, Refresh, Chrome ──
		toolStrip.AddSpacingCell( 6 );
		var viewBar2 = new ToolBar( this, "RazorDesignerViewBar2" );
		viewBar2.SetIconSize( 16 );
		viewBar2.ButtonStyle = ToolButtonStyle.TextBesideIcon;

		// "Target ▾" — render-target Scale pin (None / ScreenPanel / WorldPanel) + Reset pan & zoom.
		_targetOption = viewBar2.AddOption( "Target ▾", "tv", OnTargetButton );
		_targetOption.StatusTip = "Pick the runtime render-target Scale pin or reset pan & zoom.";
		RestorePreviewTargetFromCookie();
		UpdateTargetOptionTooltip();

		var refreshOption = viewBar2.AddOption( "Refresh", "refresh", () => Refresh() );
		refreshOption.ToolTip = "Refresh preview (also fires automatically on code reload)";
		refreshOption.StatusTip = "Re-mirror the preview from the document and re-apply the stylesheet.";

		_chromeHiddenOption = viewBar2.AddOption( "Chrome", "visibility", OnChromeToggle );
		_chromeHiddenOption.Checkable = true;
		_chromeHidden = EditorCookie.Get<bool>( ChromeHiddenCookie, false );
		_chromeHiddenOption.Checked = _chromeHidden;
		_chromeHiddenOption.ToolTip = "Toggle scaffold chrome (borders, empty-container labels, empty-image placeholder, selection outline).";
		_chromeHiddenOption.StatusTip = "When off, the canvas previews the design as it would render outside the editor.";

		toolStrip.Add( viewBar2 );

		RestoreGridFromCookies();

		toolStrip.AddSpacingCell( 6 );
		var gridBar = new ToolBar( this, "RazorDesignerGridBar" );
		gridBar.SetIconSize( 16 );
		gridBar.ButtonStyle = ToolButtonStyle.IconOnly;
		_gridShowOption = gridBar.AddOption( "", "grid_on", OnGridShowToggle );
		_gridShowOption.Checkable = true;
		_gridShowOption.Checked = _gridShow;
		_gridShowOption.ToolTip = "Show canvas grid";
		_gridShowOption.StatusTip = "Toggle visibility of the canvas grid overlay.";
		toolStrip.Add( gridBar );

		toolStrip.AddSpacingCell( 2 );
		_gridStepEdit = MakeNumericFieldEdit( _gridStep, "Grid step (CSS pixels)" );
		_gridStepEdit.EditingFinished += () => OnGridStepCommitted();
		toolStrip.Add( _gridStepEdit );

		toolStrip.AddSpacingCell( 2 );
		var snapBar = new ToolBar( this, "RazorDesignerSnapBar" );
		snapBar.SetIconSize( 16 );
		snapBar.ButtonStyle = ToolButtonStyle.IconOnly;
		_gridSnapOption = snapBar.AddOption( "", "straighten", OnGridSnapToggle );
		_gridSnapOption.Checkable = true;
		_gridSnapOption.Checked = _gridSnap;
		_gridSnapOption.ToolTip = "Snap drag to grid";
		_gridSnapOption.StatusTip = "Toggle snap-to-grid for drag operations.";
		toolStrip.Add( snapBar );

		RestoreArtboardFillFromCookies();

		toolStrip.AddSpacingCell( 6 );
		var artboardBar = new ToolBar( this, "RazorDesignerArtboardBar" );
		artboardBar.SetIconSize( 16 );
		artboardBar.ButtonStyle = ToolButtonStyle.TextBesideIcon;
		_artboardOption = artboardBar.AddOption( "Artboard ▾", "format_paint", OnArtboardButton );
		_artboardOption.StatusTip = "Pick the artboard backdrop fill (Dark / Mid / Light / Checker / Custom).";
		UpdateArtboardOptionTooltip();
		toolStrip.Add( artboardBar );

		// W2 — Code ▾ bar. Divider after viewBar mirrors the fileBar/viewBar pattern.
		var codeBarDivider = new Widget( this );
		codeBarDivider.FixedWidth = 1;
		codeBarDivider.SetSizeMode( SizeMode.Default, SizeMode.CanGrow );
		codeBarDivider.OnPaintOverride = () =>
		{
			Paint.SetBrushAndPen( Color.Parse( "#1d1d1d" ).Value );
			Paint.DrawRect( codeBarDivider.LocalRect );
			return true;
		};
		toolStrip.AddSpacingCell( 4 );
		toolStrip.Add( codeBarDivider );
		toolStrip.AddSpacingCell( 4 );

		var codeBar = new ToolBar( this, "RazorDesignerCodeBar" );
		codeBar.SetIconSize( 16 );
		codeBar.ButtonStyle = ToolButtonStyle.TextBesideIcon;

		_codeOption = codeBar.AddOption( "Code ▾", "code", OnCodeButton );
		_codeOption.ToolTip = "Document-scoped State & Parameters (W2). Methods/Lifecycle/Bindings land in W3+.";
		_codeOption.StatusTip = "Open the Code dialog to declare State (private fields) and Parameters (public [Parameter] properties).";

		toolStrip.Add( codeBar );

		// Push the filename chip to the far right.
		toolStrip.AddStretchCell();

		_fileLabel = new Editor.Label( this );
		_fileLabel.SetStyles( "color: #888; padding-right: 8px;" );
		toolStrip.Add( _fileLabel );

		Layout.Add( toolStrip );

		_document = new DesignerDocument();
		_mirror = new LiveTreeMirror(
			_document,
			() => Canvas?.DesignerScene?.Root,
			() => _chromeHidden );

		_outerSplitter = new Splitter( this );
		_outerSplitter.IsHorizontal = true;
		StyleSplitter( _outerSplitter );
		Layout.Add( _outerSplitter, 1 );

		// IsHorizontal=false is a no-op in engine (setter always assigns Horizontal); IsVertical=true is the correct flip.
		_leftStackSplitter = new Splitter( this );
		_leftStackSplitter.IsVertical = true;
		_leftStackSplitter.MinimumWidth = 200;
		_leftStackSplitter.MaximumWidth = 280;
		StyleSplitter( _leftStackSplitter );

		_hierarchy = new HierarchyPanel( this, _document );
		_leftStackSplitter.AddWidget( _hierarchy );

		_palette = new PalettePanel( this );
		var paletteScroll = new ScrollArea( this );
		paletteScroll.Canvas = _palette;
		_leftStackSplitter.AddWidget( paletteScroll );

		_leftStackSplitter.SetStretch( 0, 1 );
		_leftStackSplitter.SetStretch( 1, 1 );
		_outerSplitter.AddWidget( _leftStackSplitter );

		var canvasContainer = new Widget( this );
		canvasContainer.Layout = Layout.Column();
		canvasContainer.SetSizeMode( SizeMode.CanGrow, SizeMode.CanGrow );
		canvasContainer.MinimumWidth = 320;
		_canvasHost = canvasContainer.Layout;
		_outerSplitter.AddWidget( canvasContainer );

		_inspector = new InspectorPanel( this, _document );
		_inspector.MinimumWidth = 280;
		_inspector.MaximumWidth = 380;
		_inspector.OpenStateStylingRequested = OpenStateStylingForTarget;
		_outerSplitter.AddWidget( _inspector );

		_outerSplitter.SetStretch( 0, 0 );
		_outerSplitter.SetStretch( 1, 10 );
		_outerSplitter.SetStretch( 2, 0 );
		_outerSplitter.SetCollapsible( 0, false );
		_outerSplitter.SetCollapsible( 1, false );
		_outerSplitter.SetCollapsible( 2, false );
		_leftStackSplitter.SetCollapsible( 0, false );
		_leftStackSplitter.SetCollapsible( 1, false );

		RestoreSplitterState();
		ApplyDefaultSplitterSizes();

		_selection = new SelectionController( _document );
		_overlay = new OverlayController( _selection );

		_controller = new DesignerWindowController(
			_document,
			_mirror,
			_selection,
			_overlay,
			_hierarchy,
			_inspector,
			_palette,
			() => Canvas.DpiScale,
			() => ApplyPreviewStylesheet(),
			this,
			onValidated: () =>
			{
				var diags = new Validator().Validate( new RecordNode( _document.RootRecord ) );
				_inspector.SetDiagnostics( diags );
			} );

		_manipulator = new CanvasManipulator(
			_selection,
			_overlay,
			() => Canvas?.DesignerScene,
			() => Canvas?.DpiScale ?? 1f,
			r => _controller.CommitCanvasManipulation( r ),
			() => (_gridSnap, _gridStep) );
		_controller.Manipulator = _manipulator;

		_palette.TypeAddRequested += _controller.OnPaletteTypeAddRequested;
		_palette.TemplateAddRequested += _controller.OnPaletteTemplateAddRequested;
		_inspector.ValueChanged += _controller.OnInspectorValueChanged;
		_inspector.ClassNameChanged += _controller.OnInspectorClassNameChanged;
		_hierarchy.RecordSelected += _controller.OnHierarchyRowClicked;
		_hierarchy.SelectionChanged += _controller.OnHierarchySelectionChanged;
		_hierarchy.RecordMoved += _controller.OnRecordMoved;
		_hierarchy.RecordsDeleteRequested += _controller.OnHierarchyDeleteRequested;
		_hierarchy.RecordCreated += _controller.OnHierarchyRecordCreated;
		_hierarchy.RecordsCutRequested += _controller.OnHierarchyCutRequested;
		_hierarchy.RecordsCopyRequested += _controller.OnHierarchyCopyRequested;
		_hierarchy.RecordPasteRequested += _controller.OnHierarchyPasteRequested;
		_hierarchy.RecordRenameRequested += _controller.OnHierarchyRenameRequested;
		_hierarchy.RecordAddRequested += _controller.OnHierarchyAddRequested;
		_hierarchy.RecordsSaveAsTemplateRequested += _controller.OnHierarchySaveAsTemplateRequested;
		_hierarchy.TemplateDropRequested += _controller.OnHierarchyTemplateDropRequested;

		// TabOrClickOrWheel is required for the dock to receive Delete keypresses.
		FocusMode = FocusMode.TabOrClickOrWheel;

		UpdateFileLabel();   // initial "Untitled" (chip + fields now exist)
		Build();
	}

	public DesignerCanvas Canvas { get; private set; }

	public string TrackedFilename => string.IsNullOrEmpty( _trackedPath )
		? "Untitled"
		: System.IO.Path.GetFileName( _trackedPath );

	// Splitter handles default to invisible; this paints them with a tinted divider so users can grab them.
	private static void StyleSplitter( Splitter splitter )
	{
		splitter.HandleWidth = 4;
		splitter.SetStyles(
			"QSplitter::handle { background-color: #1d1d1d; }" +
			"QSplitter::handle:hover { background-color: #4aa0ff; }" +
			"QSplitter::handle:pressed { background-color: #4aa0ff; }" );
	}

	[EditorEvent.Hotload]
	public void Refresh()
	{
		Log.Info( $"{LogPrefix} === Refresh ===" );

		if ( Canvas is not null && _controller is not null )
		{
			_lastAppliedCss = null;
			_previewSheet = null;
			_controller.RepopulateMirrorAndRefresh();
			return;
		}

		Build();
	}

	// Frame-gated so we don't redundantly bash Enabled every paint; only flips when state changes.
	[EditorEvent.Frame]
	private void OnFrame()
	{
		if ( _saveOption is null || _document is null ) return;
		var canSave = _document.RootRecord.Children.Count > 0
				   || ( _document.Wiring is { Symbols.Count: > 0 } );
		if ( _saveOption.Enabled != canSave )
			_saveOption.Enabled = canSave;

		// Re-import button: enabled when there's a tracked path AND a .razor sibling on disk.
		if ( _reimportOption is not null )
		{
			var stem = _trackedPath is not null && _trackedPath.EndsWith( ".razor.json", System.StringComparison.OrdinalIgnoreCase )
				? _trackedPath[..^5]
				: _trackedPath;
			var canReimport = stem is not null && System.IO.File.Exists( stem );
			if ( _reimportOption.Enabled != canReimport )
				_reimportOption.Enabled = canReimport;
		}
	}

	private bool _outerSplitterRestored;
	private bool _leftSplitterRestored;

	private void RestoreSplitterState()
	{
		var outer = EditorCookie.Get<string>( SplitterOuterCookie, null );
		if ( !string.IsNullOrEmpty( outer ) )
		{
			_outerSplitter?.RestoreState( outer );
			_outerSplitterRestored = true;
			Log.Info( $"{LogPrefix} Splitter state restored (outer)" );
		}
		var left = EditorCookie.Get<string>( SplitterLeftCookie, null );
		if ( !string.IsNullOrEmpty( left ) )
		{
			_leftStackSplitter?.RestoreState( left );
			_leftSplitterRestored = true;
			Log.Info( $"{LogPrefix} Splitter state restored (left)" );
		}
	}

	private void ApplyDefaultSplitterSizes()
	{
		if ( !_outerSplitterRestored )
		{
			if ( _leftStackSplitter is not null ) _leftStackSplitter.Width = 240f;
			if ( _inspector is not null ) _inspector.Width = 320f;
			Log.Info( $"{LogPrefix} Outer splitter: no saved state, applied default widths (left=240, inspector=320)" );
		}
		// Left stack is hierarchy / palette stacked vertically — split evenly when fresh.
		if ( !_leftSplitterRestored && _hierarchy is not null )
		{
			Log.Info( $"{LogPrefix} Left splitter: no saved state, even split (Qt default with stretch 1/1 already correct)" );
		}
	}

	public override void OnDestroyed()
	{
		base.OnDestroyed();
		try
		{
			if ( _outerSplitter is not null )
				EditorCookie.Set( SplitterOuterCookie, _outerSplitter.SaveState() );
			if ( _leftStackSplitter is not null )
				EditorCookie.Set( SplitterLeftCookie, _leftStackSplitter.SaveState() );
			Log.Info( $"{LogPrefix} Splitter state saved on destroy" );
		}
		catch ( System.Exception ex )
		{
			Log.Warning( $"{LogPrefix} Splitter state save failed: {ex.Message}" );
		}
	}

	private void OnThemeButton()
	{
		var menu = new Editor.Menu( this );

		// Defaults submenu: engine baseline + every .scss bundled in the addon's Themes/.
		var defaults = menu.AddMenu( "Default", "auto_awesome" );
		defaults.AddOption( new Editor.Option
		{
			Text = "Default theme",
			Icon = "auto_awesome",
			Checkable = true,
			Checked = _theme.IsDefault,
			Triggered = () => SetTheme( PreviewTheme.Default ),
		} );
		foreach ( var bundled in EnumerateBundledThemes() )
		{
			// Capture loop locals — Triggered is a closure.
			var path = bundled.Path;
			var name = bundled.Name;
			defaults.AddOption( new Editor.Option
			{
				Text = name,
				Icon = "palette",
				Checkable = true,
				Checked = string.Equals( _theme.Source, path, System.StringComparison.OrdinalIgnoreCase ),
				Triggered = () => SetTheme( PreviewTheme.FromFile( path ) ),
			} );
		}

		// Custom submenu: user-loaded .scss off disk.
		var custom = menu.AddMenu( "Custom", "folder_open" );
		custom.AddOption( "Load custom .scss…", "folder_open", LoadCustomThemeViaDialog );

		// Reload at the root, applies to whichever non-default theme is active (bundled or custom).
		if ( !_theme.IsDefault )
		{
			menu.AddSeparator();
			menu.AddOption( $"Reload {_theme.Name}", "refresh", () => SetTheme( PreviewTheme.FromFile( _theme.Source ) ) );
		}

		menu.OpenAtCursor();
	}

	private static readonly (string Ident, string Subpath)[] BundledThemeRoots = new[]
	{
		( "razordesigner",        "Assets/Themes" ),
		( "grains_razordesigner", "Libraries/xaz.razordesigner/Assets/Themes" ),
	};

	private static string BundledThemesDirectoryPath()
	{
		foreach ( var (ident, subpath) in BundledThemeRoots )
		{
			var root = System.Linq.Enumerable
				.FirstOrDefault( EditorUtility.Projects.GetAll(), p => string.Equals( p.Config?.Ident, ident, System.StringComparison.OrdinalIgnoreCase ) )
				?.GetRootPath();
			if ( string.IsNullOrEmpty( root ) ) continue;
			var dir = System.IO.Path.Combine( root, subpath );
			if ( System.IO.Directory.Exists( dir ) ) return dir;
		}
		return null;
	}

	private static IEnumerable<(string Name, string Path)> EnumerateBundledThemes()
	{
		var dir = BundledThemesDirectoryPath();
		if ( string.IsNullOrEmpty( dir ) || !System.IO.Directory.Exists( dir ) )
			yield break;
		foreach ( var f in System.IO.Directory.EnumerateFiles( dir, "*.scss" ) )
			yield return ( DisplayNameFromFile( f ), f );
	}

	// "github-dark.scss" -> "Github Dark". Cheap title-case on - and _ separators; .scss stripped.
	private static string DisplayNameFromFile( string path )
	{
		var name = System.IO.Path.GetFileNameWithoutExtension( path );
		var parts = name.Split( '-', '_' );
		for ( int i = 0; i < parts.Length; i++ )
		{
			if ( parts[i].Length == 0 ) continue;
			parts[i] = char.ToUpperInvariant( parts[i][0] ) + parts[i].Substring( 1 );
		}
		return string.Join( ' ', parts );
	}

	private void LoadCustomThemeViaDialog()
	{
		var dialog = new FileDialog( null )
		{
			Title = "Load preview theme (.scss)",
			DefaultSuffix = ".scss",
		};
		dialog.SetFindFile();
		dialog.SetModeOpen();
		dialog.SetNameFilter( "Stylesheet (*.scss)" );

		if ( !dialog.Execute() )
		{
			Log.Info( $"{LogPrefix} Theme load cancelled" );
			return;
		}

		SetTheme( PreviewTheme.FromFile( dialog.SelectedFile ) );
	}

	private void SetTheme( PreviewTheme theme )
	{
		_theme = theme ?? PreviewTheme.Default;
		EditorCookie.Set( ThemePathCookie, _theme.IsDefault ? "" : _theme.Source );
		UpdateThemeOptionTooltip();
		// Force a re-apply on the next frame even if document CSS hasn't changed.
		_lastAppliedCss = null;
		WarnOnScssOnlySyntax( _theme );
		ApplyPreviewStylesheet();
		Log.Info( $"{LogPrefix} Theme set: {_theme.Name} ({_theme.Source})" );
	}

	private static void WarnOnScssOnlySyntax( PreviewTheme theme )
	{
		if ( theme is null || theme.IsDefault || string.IsNullOrEmpty( theme.Css ) ) return;
		var css = theme.Css;
		var hits = new List<string>();
		if ( css.Contains( "&:" ) || css.Contains( "& " ) || css.Contains( "&." ) || css.Contains( "&#" ) )
			hits.Add( "nested selectors via `&`" );
		if ( css.Contains( "@mixin" ) )    hits.Add( "@mixin" );
		if ( css.Contains( "@include" ) )  hits.Add( "@include" );
		if ( css.Contains( "@import" ) )   hits.Add( "@import" );
		if ( css.Contains( "@function" ) ) hits.Add( "@function" );
		if ( css.Contains( "@if" ) )       hits.Add( "@if" );
		if ( css.Contains( "@each" ) )     hits.Add( "@each" );
		// `$ident:` SCSS variable assignment heuristic — common pattern at file top.
		if ( System.Text.RegularExpressions.Regex.IsMatch( css, @"\$[A-Za-z_][\w-]*\s*:" ) )
			hits.Add( "$variables" );
		if ( hits.Count == 0 ) return;
		Log.Warning( $"{LogPrefix} Theme \"{theme.Name}\" contains SCSS-only syntax that will not parse: {string.Join( ", ", hits )}. Flatten to plain CSS." );
	}

	private void RestoreThemeFromCookie()
	{
		var path = EditorCookie.Get<string>( ThemePathCookie, null );
		if ( string.IsNullOrEmpty( path ) ) return;
		if ( !System.IO.File.Exists( path ) )
		{
			Log.Warning( $"{LogPrefix} Saved theme path no longer exists: {path}; staying on Default" );
			return;
		}
		_theme = PreviewTheme.FromFile( path );
	}

	private void UpdateThemeOptionTooltip()
	{
		if ( _themeOption is null ) return;
		_themeOption.ToolTip = _theme.IsDefault
			? "Theme: Default (click to change)"
			: $"Theme: {_theme.Name} (click to change)";
	}

	private void OnTargetButton()
	{
		var menu = new Editor.Menu( this );
		AddRenderTargetSection( menu );
		menu.AddSeparator();
		menu.AddOption( "Reset pan & zoom", "filter_center_focus", () => _viewportFrame?.ResetView() );
		menu.OpenAtCursor();
	}

	private Editor.LineEdit MakeNumericFieldEdit( float initial, string tooltip )
	{
		var edit = new Editor.LineEdit( this )
		{
			Text = ((int)System.MathF.Round( initial )).ToString(),
			ToolTip = tooltip,
		};
		edit.RegexValidator = "[0-9]+";
		edit.FixedWidth = 52;
		return edit;
	}

	private void OnSizeFieldCommitted()
	{
		if ( !TryParseSizeFields( out var size ) )
		{
			if ( _widthEdit is not null )  _widthEdit.Text  = ((int)_lastSizedViewport.x).ToString();
			if ( _heightEdit is not null ) _heightEdit.Text = ((int)_lastSizedViewport.y).ToString();
			return;
		}
		_lastSizedViewport = size;
		SetViewport( size );
		if ( _fitOption is not null ) _fitOption.Checked = false;
	}

	// Fit toolbar toggle. Editor.Option with Checkable=true updates Checked before firing — read it.
	private void OnFitToggle()
	{
		var fit = _fitOption.Checked;
		if ( fit )
		{
			// Preserve the typed values so flipping Fit back off returns to them.
			if ( TryParseSizeFields( out var current ) ) _lastSizedViewport = current;
			SetViewport( null );
		}
		else
		{
			// Flipping Fit off applies whatever's in the fields right now (falling back to last sized).
			var size = TryParseSizeFields( out var current ) ? current : _lastSizedViewport;
			_lastSizedViewport = size;
			SetViewport( size );
		}
	}

	private bool TryParseSizeFields( out Vector2 size )
	{
		size = Vector2.Zero;
		if ( _widthEdit is null || _heightEdit is null ) return false;
		if ( !int.TryParse( _widthEdit.Text, out var w ) || w < 16 || w > 16384 ) return false;
		if ( !int.TryParse( _heightEdit.Text, out var h ) || h < 16 || h > 16384 ) return false;
		size = new Vector2( w, h );
		return true;
	}

	// Editor.Option with Checkable=true updates Checked before firing the callback.
	private void OnChromeToggle()
	{
		_chromeHidden = _chromeHiddenOption.Checked;
		EditorCookie.Set( ChromeHiddenCookie, _chromeHidden );
		ApplyChromeVisibility();
		Log.Info( $"{LogPrefix} Chrome {(_chromeHidden ? "hidden (real view)" : "shown (scaffold)")}" );
	}

	private void OnGridShowToggle()
	{
		SetGrid( _gridShowOption.Checked, _gridSnap, _gridStep );
	}

	private void OnGridSnapToggle()
	{
		SetGrid( _gridShow, _gridSnapOption.Checked, _gridStep );
	}

	private void OnGridStepCommitted()
	{
		if ( _gridStepEdit is null ) return;
		if ( !int.TryParse( _gridStepEdit.Text, out var step ) || step < 1 || step > 999 )
		{
			// Invalid input — bounce the field back to the last good value.
			_gridStepEdit.Text = ((int)_gridStep).ToString();
			return;
		}
		SetGrid( _gridShow, _gridSnap, step );
	}

	private void SetGrid( bool show, bool snap, float step )
	{
		_gridShow = show;
		_gridSnap = snap;
		_gridStep = step;
		EditorCookie.Set( GridShowCookie, show );
		EditorCookie.Set( GridSnapCookie, snap );
		EditorCookie.Set( GridSizeCookie, step );
		if ( _viewportFrame is not null )
			_viewportFrame.SetGrid( new CanvasViewportFrame.GridSettings( show, snap, step ) );
		Log.Info( $"{LogPrefix} Grid set: show={show} snap={snap} step={(int)step}px" );
	}

	private void RestoreGridFromCookies()
	{
		_gridShow = EditorCookie.Get<bool>( GridShowCookie, true );
		_gridSnap = EditorCookie.Get<bool>( GridSnapCookie, true );
		var rawStep = EditorCookie.Get<float>( GridSizeCookie, DefaultGridStep );
		_gridStep = (rawStep >= 1f && rawStep <= 999f) ? rawStep : DefaultGridStep;
		if ( _gridStepEdit is not null )
			_gridStepEdit.Text = ((int)_gridStep).ToString();
		if ( _gridShowOption is not null )
			_gridShowOption.Checked = _gridShow;
		if ( _gridSnapOption is not null )
			_gridSnapOption.Checked = _gridSnap;
		if ( _viewportFrame is not null )
			_viewportFrame.SetGrid( new CanvasViewportFrame.GridSettings( _gridShow, _gridSnap, _gridStep ) );
		Log.Info( $"{LogPrefix} Grid restored: show={_gridShow} snap={_gridSnap} step={(int)_gridStep}px" );
	}

	// ── Artboard fill ──

	private void OnArtboardButton()
	{
		var menu = new Editor.Menu( this );
		AddArtboardOption( menu, ArtboardFillMode.Dark );
		AddArtboardOption( menu, ArtboardFillMode.Mid );
		AddArtboardOption( menu, ArtboardFillMode.Light );
		AddArtboardOption( menu, ArtboardFillMode.Checker );
		menu.AddSeparator();
		menu.AddOption( new Editor.Option
		{
			Text = "Custom…",
			Icon = _artboardFill == ArtboardFillMode.Custom ? "check" : "palette",
			Triggered = OpenArtboardCustomPicker,
		} );
		menu.OpenAtCursor();
	}

	private void AddArtboardOption( Editor.Menu menu, ArtboardFillMode mode )
	{
		var current = _artboardFill == mode;
		menu.AddOption( new Editor.Option
		{
			Text = mode.DisplayLabel(),
			Icon = current ? "check" : "",
			Checkable = true,
			Checked = current,
			Triggered = () => SetArtboardFill( mode, _artboardCustom ),
		} );
	}

	private void OpenArtboardCustomPicker()
	{
		Editor.ColorPicker.OpenColorPopup( _artboardCustom, color =>
		{
			SetArtboardFill( ArtboardFillMode.Custom, color );
		} );
	}

	private void SetArtboardFill( ArtboardFillMode mode, Color custom )
	{
		_artboardFill = mode;
		_artboardCustom = custom;
		EditorCookie.Set( ArtboardFillCookie, mode.ToString() );
		EditorCookie.Set( ArtboardCustomCookie, custom.Hex );
		PushArtboardFillToScene();
		UpdateArtboardOptionTooltip();
		Log.Info( $"{LogPrefix} Artboard fill set: {mode.DisplayLabel()}{(mode == ArtboardFillMode.Custom ? $" ({custom.Hex})" : "")}" );
	}

	private void PushArtboardFillToScene()
	{
		if ( Canvas?.DesignerScene is not { } scene ) return;
		scene.ArtboardFill = _artboardFill;
		scene.ArtboardCustomColor = _artboardCustom;
	}

	private void RestoreArtboardFillFromCookies()
	{
		var rawMode = EditorCookie.Get<string>( ArtboardFillCookie, null );
		if ( !string.IsNullOrEmpty( rawMode ) && System.Enum.TryParse<ArtboardFillMode>( rawMode, ignoreCase: true, out var parsed ) )
			_artboardFill = parsed;
		else
			_artboardFill = ArtboardFillMode.Dark;

		var rawCustom = EditorCookie.Get<string>( ArtboardCustomCookie, null );
		if ( !string.IsNullOrEmpty( rawCustom ) )
		{
			var c = Color.Parse( rawCustom );
			if ( c.HasValue ) _artboardCustom = c.Value;
		}

		PushArtboardFillToScene();
		Log.Info( $"{LogPrefix} Artboard fill restored: {_artboardFill.DisplayLabel()}" );
	}

	private void UpdateArtboardOptionTooltip()
	{
		if ( _artboardOption is null ) return;
		_artboardOption.ToolTip = _artboardFill == ArtboardFillMode.Custom
			? $"Artboard: Custom ({_artboardCustom.Hex})  (click to change)"
			: $"Artboard: {_artboardFill.DisplayLabel()}  (click to change)";
	}

	private void ApplyChromeVisibility()
	{
		var root = Canvas?.DesignerScene?.Root;
		if ( root is null || !root.IsValid ) return;
		root.SetClass( LiveTreeMirror.ChromeHiddenClass, _chromeHidden );
	}

	private void SetViewport( Vector2? size )
	{
		_viewport = size;
		EditorCookie.Set( CanvasSizeCookie,
			size.HasValue ? $"{(int)size.Value.x}x{(int)size.Value.y}" : FitCookieValue );
		if ( _viewportFrame is not null )
			_viewportFrame.Viewport = size;
		if ( _fitOption is not null )
			_fitOption.Checked = !size.HasValue;
		Log.Info( $"{LogPrefix} Viewport set: {(size.HasValue ? $"{(int)size.Value.x}×{(int)size.Value.y}" : "Fit")}" );
	}

	private void RestoreViewportFromCookie()
	{
		var raw = EditorCookie.Get<string>( CanvasSizeCookie, null );

		if ( string.IsNullOrEmpty( raw ) )
		{
			_viewport = DefaultViewport;
			_lastSizedViewport = DefaultViewport;
			Log.Info( $"{LogPrefix} Viewport default (no cookie): {(int)DefaultViewport.x}×{(int)DefaultViewport.y}" );
			return;
		}
		if ( raw == FitCookieValue )
		{
			_viewport = null;
			// Keep _lastSizedViewport at its default 1920×1080 — flipping Fit off lands somewhere sane.
			Log.Info( $"{LogPrefix} Viewport restored: Fit" );
			return;
		}
		var parts = raw.Split( 'x' );
		if ( parts.Length == 2
			&& int.TryParse( parts[0], out var w )
			&& int.TryParse( parts[1], out var h )
			&& w >= 16 && h >= 16 )
		{
			_viewport = new Vector2( w, h );
			_lastSizedViewport = new Vector2( w, h );
			Log.Info( $"{LogPrefix} Viewport restored: {w}×{h}" );
		}
		else
		{
			Log.Warning( $"{LogPrefix} Saved viewport unparseable: \"{raw}\"; falling back to Fit" );
			_viewport = null;
		}
	}

	private void UpdateTargetOptionTooltip()
	{
		if ( _targetOption is null ) return;
		_targetOption.ToolTip = $"Render target: {_previewTarget.DisplayLabel()}  (click to change · Reset pan & zoom available)";
	}

	private void AddRenderTargetSection( Editor.Menu menu )
	{
		void AddTarget( PreviewTargetMode mode )
		{
			var current = _previewTarget == mode;
			menu.AddOption( new Editor.Option
			{
				Text = mode.DisplayLabel(),
				Icon = current ? "check" : "",
				Checkable = true,
				Checked = current,
				Triggered = () => SetPreviewTarget( mode ),
			} );
		}

		menu.AddHeading( "Render target" );
		AddTarget( PreviewTargetMode.None );
		AddTarget( PreviewTargetMode.ScreenPanel );
		AddTarget( PreviewTargetMode.WorldPanel );
	}

	private void SetPreviewTarget( PreviewTargetMode mode )
	{
		if ( _previewTarget == mode ) return;
		_previewTarget = mode;
		EditorCookie.Set( PreviewTargetCookie, mode.ToString() );
		if ( _viewportFrame is not null )
			_viewportFrame.PreviewTarget = mode;
		UpdateTargetOptionTooltip();
		Log.Info( $"{LogPrefix} Preview target set: {mode.DisplayLabel()}" );
	}

	private void RestorePreviewTargetFromCookie()
	{
		var raw = EditorCookie.Get<string>( PreviewTargetCookie, null );
		if ( string.IsNullOrEmpty( raw ) )
		{
			_previewTarget = PreviewTargetMode.None;
			return;
		}
		if ( System.Enum.TryParse<PreviewTargetMode>( raw, ignoreCase: true, out var mode ) )
		{
			_previewTarget = mode;
			Log.Info( $"{LogPrefix} Preview target restored: {mode.DisplayLabel()}" );
		}
		else
		{
			Log.Warning( $"{LogPrefix} Saved preview target unparseable: \"{raw}\"; defaulting to None" );
			_previewTarget = PreviewTargetMode.None;
		}
	}

	[Shortcut( "razordesigner.new", "CTRL+N", ShortcutType.Window )]
	private void ShortcutNew() => OnNew();

	[Shortcut( "razordesigner.open", "CTRL+O", ShortcutType.Window )]
	private void ShortcutOpen() => OnOpen();

	[Shortcut( "razordesigner.save", "CTRL+S", ShortcutType.Window )]
	private void ShortcutSave() => OnSave();

	private void Build()
	{
		Log.Info( $"{LogPrefix} Build: rebuilding canvas" );
		// New canvas = new RootPanel = empty StyleSheet; drop caches tied to old root.
		_lastAppliedCss = null;
		_previewSheet = null;
		_canvasHost.Clear( true );

		_viewportFrame = new CanvasViewportFrame( this );
		_viewportFrame.TrackedFilenameSource = () => TrackedFilename;
		// Plumb mirror + selection so the Zoom HUD's Fit / Sel can resolve them on demand (grd-vngf).
		_viewportFrame.Mirror = _mirror;
		_viewportFrame.Selection = _selection;
		Canvas = new DesignerCanvas( _viewportFrame );
		Canvas.CanvasClicked += _controller.OnCanvasClicked;
		Canvas.CanvasPanDragged += d => _viewportFrame.Pan( d );
		Canvas.RecordDropped += _controller.OnCanvasRecordDropped;
		Canvas.TemplateDropped += _controller.OnCanvasTemplateDropped;
		Canvas.Overlay = _overlay;
		Canvas.CanvasMoved += _controller.OnCanvasMoved;
		Canvas.CanvasHoverEnded += _controller.OnCanvasHoverEnded;
		Canvas.CanvasReleased += _controller.OnCanvasReleased;
		_viewportFrame.Canvas = Canvas;
		_viewportFrame.Viewport = _viewport;
		_viewportFrame.PreviewTarget = _previewTarget;
		_viewportFrame.SetGrid( new CanvasViewportFrame.GridSettings( _gridShow, _gridSnap, _gridStep ) );
		PushArtboardFillToScene();
		_canvasHost.Add( _viewportFrame );

		_controller.RepopulateMirrorAndRefresh();
	}

	private static bool _stylesheetParseProbeRan;
	private static void RunStylesheetParseProbe()
	{
		if ( _stylesheetParseProbeRan ) return;
		_stylesheetParseProbeRan = true;
		try
		{
			var p = new Panel();
			p.StyleSheet.Parse( ".x { width: 100px; }" );
			p.StyleSheet.Parse( ".x { width: 200px; }" );
			var sheets = System.Linq.Enumerable.ToList( p.AllStyleSheets );
			Log.Info( $"[stylesheet-parse-probe] sheets={sheets.Count} (expect 1 if fixed, 2 if leak)" );
			foreach ( var s in sheets )
				Log.Info( $"[stylesheet-parse-probe]   FileName=\"{s.FileName ?? "<null>"}\"" );
		}
		catch ( System.Exception ex )
		{
			Log.Warning( $"[stylesheet-parse-probe] failed: {ex.Message}" );
		}
	}

	private void ApplyPreviewStylesheet()
	{
		RunStylesheetParseProbe();

		var canvas = Canvas;
		if ( canvas is null ) return;
		var root = canvas.DesignerScene?.Root;
		if ( root is null || !root.IsValid ) return;

		var css = DocumentSerializer.GeneratePreviewStylesheet( _document, _theme );
		if ( css == _lastAppliedCss )
		{
			Log.Info( $"{LogPrefix} ApplyPreviewStylesheet ({css.Length} chars): unchanged, skipped" );
			return;
		}
		Log.Info( $"{LogPrefix} ApplyPreviewStylesheet ({css.Length} chars)" );

		if ( _previewSheet is not null )
		{
			root.StyleSheet.Remove( _previewSheet );
		}
		try
		{
			_previewSheet = StyleSheet.FromString( css, "preview" );
		}
		catch ( System.Exception ex )
		{
			Log.Warning( ex, $"{LogPrefix} StyleSheet.FromString failed for theme \"{_theme.Name}\" ({_theme.Source}): {ex.Message}" );
			Log.Warning( $"{LogPrefix} CSS first 400 chars: {css.Substring( 0, System.Math.Min( 400, css.Length ) )}" );
			if ( _previewSheet is not null )
				root.StyleSheet.Add( _previewSheet );
			return;
		}
		root.StyleSheet.Add( _previewSheet );

		ForceFullRestyle( root );

		_lastAppliedCss = css;
	}

	public void PinPreviewState( ControlRecord target, PseudoKind? state )
	{
		if ( target is null )
		{
			Log.Info( $"{LogPrefix} PinPreviewState: target=null, no-op" );
			return;
		}

		// Always clear all three before setting the chosen one — the picker is single-select.
		_mirror.SetPseudoClassForRecord( target, PseudoClass.Hover,  false );
		_mirror.SetPseudoClassForRecord( target, PseudoClass.Active, false );
		_mirror.SetPseudoClassForRecord( target, PseudoClass.Focus,  false );

		if ( state == PseudoKind.Hover )  _mirror.SetPseudoClassForRecord( target, PseudoClass.Hover,  true );
		if ( state == PseudoKind.Active ) _mirror.SetPseudoClassForRecord( target, PseudoClass.Active, true );
		if ( state == PseudoKind.Focus )  _mirror.SetPseudoClassForRecord( target, PseudoClass.Focus,  true );

		_overlay?.SetPreviewActive( state.HasValue );

		Log.Info( $"{LogPrefix} PinPreviewState: target={target.ClassName} state={state}" );
	}

	private void OpenStateStylingForTarget( ControlRecord target )
	{
		if ( _stateStyling is null )
		{
			_stateStyling = new StateStylingWindow(
				_document,
				_selection,
				onValueChanged: () => { /* wired in Phase F */ ApplyPreviewStylesheet(); _inspector?.NotifyValueChanged(); },
				onPinChanged: PinPreviewState );
			_stateStyling.Closed += () => { _stateStyling = null; };
			Log.Info( $"{LogPrefix} StateStylingWindow created" );
		}

		_stateStyling.Show();
		Log.Info( $"{LogPrefix} StateStylingWindow activated for target={target?.ClassName}" );
	}

	// Resolved once: Sandbox.UI.Panel.DirtyStylesRecursive() — internal, no public equivalent.
	private System.Reflection.MethodInfo _dirtyStylesRecursive;
	private bool _dirtyStylesRecursiveResolved;

	private void ForceFullRestyle( Panel root )
	{
		if ( root is null || !root.IsValid ) return;

		if ( !_dirtyStylesRecursiveResolved )
		{
			_dirtyStylesRecursiveResolved = true;
			for ( var t = root.GetType(); t is not null && _dirtyStylesRecursive is null; t = t.BaseType )
			{
				_dirtyStylesRecursive = t.GetMethod( "DirtyStylesRecursive",
					System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public,
					binder: null, types: System.Type.EmptyTypes, modifiers: null );
			}
			Log.Info( $"{LogPrefix} ForceFullRestyle: DirtyStylesRecursive resolved={_dirtyStylesRecursive is not null}" );
		}

		if ( _dirtyStylesRecursive is not null )
		{
			try { _dirtyStylesRecursive.Invoke( root, null ); }
			catch ( System.Exception ex ) { Log.Warning( $"{LogPrefix} ForceFullRestyle: DirtyStylesRecursive threw: {ex.GetType().Name}: {ex.Message}" ); }
			return;
		}

		root.AddClass( "__rd_force_restyle__" );
		root.RemoveClass( "__rd_force_restyle__" );
	}

	protected override void OnKeyPress( KeyEvent e )
	{
		base.OnKeyPress( e );
		if ( e.Key == KeyCode.Delete )
		{
			var multi = _hierarchy?.SelectedRecords;
			if ( multi is { Count: > 0 } )
			{
				Log.Info( $"{LogPrefix} OnKeyPress Delete: {multi.Count} record(s) from hierarchy" );
				foreach ( var r in multi )
				{
					if ( r is null || r == _document.RootRecord ) continue;
					_controller.DeleteRecordCascade( r );
				}
				e.Accepted = true;
			}
			else if ( _selection?.Selected is not null )
			{
				_controller.DeleteRecordCascade( _selection.Selected );
				e.Accepted = true;
			}
		}

		// Arrow keys: nudge/resize the canvas selection. Alt = resize, otherwise move; Shift = ×10 step.
		{
			int sx = 0, sy = 0;
			switch ( e.Key )
			{
				case KeyCode.Left:  sx = -1; break;
				case KeyCode.Right: sx = +1; break;
				case KeyCode.Up:    sy = -1; break;
				case KeyCode.Down:  sy = +1; break;
			}
			if ( sx != 0 || sy != 0 )
			{
				bool shift = e.HasShift;
				bool alt   = e.HasAlt;
				float step = shift ? 10f : 1f;
				// NudgeSelected params are (dx, dy, dw, dh): sx (horizontal) → dw on resize / dx on move; sy (vertical) → dh / dy.
				bool handled = alt
					? _controller.NudgeSelected( 0f, 0f, sx * step, sy * step )
					: _controller.NudgeSelected( sx * step, sy * step, 0f, 0f );
				if ( handled ) e.Accepted = true;
			}
		}
	}

	private void UpdateFileLabel()
	{
		if ( _fileLabel is null ) return;
		var name = string.IsNullOrEmpty( _trackedPath )
			? "Untitled"
			: System.IO.Path.GetFileName( _trackedPath );
		_fileLabel.Text = name;
		_fileLabel.ToolTip = string.IsNullOrEmpty( _trackedPath ) ? null : _trackedPath;
	}

	private void OnNew()
	{
		Log.Info( $"{LogPrefix} New" );
		_selection?.Deselect();
		_inspector?.SetTarget( null );
		_document.Clear();
		_trackedPath = null;
		ApplyPreviewStylesheet();
		_controller.RefreshHierarchy();
		UpdateFileLabel();
	}

	private void OnOpen()
	{
		var dialog = new FileDialog( null )
		{
			Title = "Open Razor Designer file",
			DefaultSuffix = ".razor",
		};
		dialog.SetFindFile();
		dialog.SetModeOpen();
		dialog.SetNameFilter( "Razor Designer (*.razor *.razor.json)" );

		if ( !dialog.Execute() )
		{
			Log.Info( $"{LogPrefix} Open cancelled" );
			return;
		}

		var result = DocumentIO.Load( dialog.SelectedFile );

		if ( result.DriftDetected )
		{
			Log.Info( $"{LogPrefix} Open: drift detected — showing 3-button modal" );
			new DriftDialog( this ).Show(
				result.LegacyRazorPath,
				result.ResolvedPath,
				choice =>
				{
					switch ( choice )
					{
						case DriftDialog.Choice.ReImport:
							Log.Info( $"{LogPrefix} Open drift: Re-import chosen" );
							var (impDoc, impDiags) = LegacyRazorImporter.Import( result.LegacyRazorPath );
							if ( impDoc == null )
							{
								var errMsg = impDiags.Count > 0 ? impDiags[impDiags.Count - 1].Message : "unknown";
								Log.Warning( $"{LogPrefix} Open drift: re-import failed — {errMsg}" );
								return;
							}
							ApplyOpenResult( impDoc, result.LegacyRazorPath, impDiags );
							break;

						case DriftDialog.Choice.Discard:
							Log.Info( $"{LogPrefix} Open drift: Discard chosen — using .razor.json doc" );
							ApplyOpenResult( result.Document, result.ResolvedPath,
								System.Array.Empty<ValidationDiagnostic>() );
							break;

						case DriftDialog.Choice.Cancel:
							Log.Info( $"{LogPrefix} Open drift: Cancel — open aborted, current document unchanged" );
							break;
					}
				} );
			return;
		}

		if ( !result.Success )
		{
			Log.Warning( $"{LogPrefix} Open failed" );
			foreach ( var w in result.Warnings )
				Log.Warning( $"{LogPrefix} Open error: {w}" );
			return;
		}

		// Stamp mismatch (non-blocking): load proceeds with the JSON doc, banner surfaced in inspector.
		if ( result.StampMismatch )
		{
			Log.Info( $"{LogPrefix} Open: stamp-mismatch banner — .razor does not match IR" );
			var mismatchDiag = new ValidationDiagnostic(
				NodeId:   null,
				Severity: DiagnosticSeverity.Warn,
				Code:     "stamp-mismatch",
				Message:  ".razor file does not match IR. Save to refresh the .razor, or use 'Re-import from .razor' to lift hand-edits." );
			_stampMismatchDiags = new[] { mismatchDiag };
		}
		else
		{
			_stampMismatchDiags = null;
		}

		ApplyOpenResult( result.Document, result.ResolvedPath, System.Array.Empty<ValidationDiagnostic>() );

		// After the document is applied, surface stamp-mismatch banner if present.
		if ( _stampMismatchDiags is not null )
		{
			_inspector?.SetDiagnostics( _stampMismatchDiags );
			Log.Warning( $"{LogPrefix} Open: stamp-mismatch banner active. Ctrl+S to refresh .razor, or use Re-import button." );
		}

		// Surface migrated-from-legacy diagnostic in inspector.
		if ( result.MigratedFromLegacy )
		{
			Log.Info( $"{LogPrefix} Open: migrated from legacy .razor — surfacing diagnostic" );
			var migDiag = new ValidationDiagnostic(
				NodeId:   null,
				Severity: DiagnosticSeverity.Warn,
				Code:     "legacy-migrated",
				Message:  $"Migrated from legacy .razor. A new .razor.json has been created. Save to update the .razor with the IR hash stamp." );
			_inspector?.SetDiagnostics( new[] { migDiag } );
		}

		Log.Info( $"{LogPrefix} Open: loaded {_document.RootRecord.Children.Count} root child(ren); " +
		          $"{result.Warnings.Count} warning(s); _trackedPath -> {_trackedPath}" );
		foreach ( var w in result.Warnings )
			Log.Info( $"{LogPrefix} Open warning: {w}" );
	}

	private void ApplyOpenResult(
		DesignerDocument doc,
		string resolvedPath,
		IReadOnlyList<ValidationDiagnostic> diags )
	{
		if ( doc == null ) return;

		_selection?.Deselect();
		_inspector?.SetTarget( null );
		_document.Clear();
		foreach ( var child in doc.RootRecord.Children )
			_document.RootRecord.Children.Add( child );

		_trackedPath = resolvedPath;
		if ( _trackedPath is not null &&
		     _trackedPath.EndsWith( ".razor.json", System.StringComparison.OrdinalIgnoreCase ) )
			_trackedPath = _trackedPath[..^5];   // strip ".json" → "…/Foo.razor"

		_controller.RepopulateMirrorAndRefresh();

		// Surface any diagnostics from this load (e.g. re-import warnings).
		if ( diags is { Count: > 0 } )
			_inspector?.SetDiagnostics( diags );

		// Clear a prior stamp-mismatch banner on clean loads.
		if ( _stampMismatchDiags is not null && diags is { Count: 0 } )
		{
			_stampMismatchDiags = null;
		}

		Log.Info( $"{LogPrefix} ApplyOpenResult: doc applied ({_document.RootRecord.Children.Count} root children); _trackedPath={_trackedPath}" );
		UpdateFileLabel();
	}

	private void OnCodeButton()
	{
		var current = _document?.Wiring ?? Grains.RazorDesigner.Wiring.WiringEnvelope.Empty;
		Log.Info( $"{LogPrefix} OnCodeButton: opening dialog with {current.Symbols.Count} symbol(s)" );
		new Grains.RazorDesigner.CodeDialog.CodeDialog( this, current ).Show(
			onConfirm: env => _controller.ApplyWiringChange( env ),
			onCancel:  () => Log.Info( $"{LogPrefix} OnCodeButton: cancelled" ) );
	}

	private void OnReimportFromRazor()
	{
		// Compute the .razor stem from the tracked path (which might be the .razor.json path).
		var stem = _trackedPath;
		if ( stem is null )
		{
			Log.Warning( $"{LogPrefix} Re-import: no tracked path" );
			return;
		}
		if ( stem.EndsWith( ".razor.json", System.StringComparison.OrdinalIgnoreCase ) )
			stem = stem[..^5];

		if ( !System.IO.File.Exists( stem ) )
		{
			Log.Warning( $"{LogPrefix} Re-import: .razor file not found: {stem}" );
			return;
		}

		Log.Info( $"{LogPrefix} Re-import: running LegacyRazorImporter on {stem}" );
		var (impDoc, impDiags) = LegacyRazorImporter.Import( stem );

		if ( impDoc == null )
		{
			// Import failed — show an error in the inspector.
			var errMsg = impDiags.Count > 0 ? impDiags[impDiags.Count - 1].Message : "unknown error";
			Log.Warning( $"{LogPrefix} Re-import: failed — {errMsg}" );
			_inspector?.SetDiagnostics( impDiags );
			return;
		}

		// Compute loss between the imported doc and the current IR document.
		var loss = LegacyRazorImporter.DescribeLoss( impDoc, _document );
		Log.Info( $"{LogPrefix} Re-import: DescribeLoss returned {loss.Count} line(s)" );

		if ( loss.Count > 0 )
		{
			// Show the loss-preview modal.
			new LossPreviewDialog( this ).Show(
				loss,
				stem,
				onConfirm: () =>
				{
					Log.Info( $"{LogPrefix} Re-import: loss-preview confirmed — grafting imported doc" );
					ApplyOpenResult( impDoc, _trackedPath, impDiags );
				},
				onCancel: () =>
				{
					Log.Info( $"{LogPrefix} Re-import: loss-preview cancelled" );
				} );
		}
		else
		{
			// No detectable loss — apply directly.
			Log.Info( $"{LogPrefix} Re-import: no loss detected — applying directly" );
			ApplyOpenResult( impDoc, _trackedPath, impDiags );
		}
	}

	private void OnSave()
	{
		if ( string.IsNullOrEmpty( _trackedPath ) )
		{
			var dialog = new FileDialog( null )
			{
				Title = "Save Razor Designer Output",
				DefaultSuffix = ".razor",
			};
			dialog.SetFindFile();
			dialog.SetModeSave();
			dialog.SetNameFilter( "Razor (*.razor)" );
			// SelectedFile is read-only; SelectFile prefills the name field.
			dialog.SelectFile( "MyMenu.razor" );

			if ( !dialog.Execute() )
			{
				Log.Info( $"{LogPrefix} Save cancelled" );
				return;
			}

			_trackedPath = dialog.SelectedFile;
			if ( !_trackedPath.EndsWith( ".razor", System.StringComparison.OrdinalIgnoreCase ) )
				_trackedPath += ".razor";
			UpdateFileLabel();
		}

		var result = DocumentIO.Save( _document, _trackedPath, _theme );
		ApplySaveResult( result );
	}

	private void ApplySaveResult( SaveResult result )
	{
		if ( !result.RazorExported )
		{
			var errorCount = result.Diagnostics?.Count( d => d.Severity == DiagnosticSeverity.Error ) ?? 0;
			_razorExportFailedSuffix = $" ⚠ razor export failed ({errorCount} error(s))";
			// Title is the dock window title. Widget.WindowTitle sets the caption on the dock.
			WindowTitle = $"Razor Designer{_razorExportFailedSuffix}";
			Log.Warning( $"{LogPrefix} Save: .razor not exported — {errorCount} error(s). See inspector for details. .razor.json was saved." );
		}
		else
		{
			_razorExportFailedSuffix = null;
			WindowTitle = "Razor Designer";
			Log.Info( $"{LogPrefix} Save: .razor exported successfully." );
		}
	}
}