Editor/Serialization/PreviewTheme.cs
using System;
using System.IO;
using System.Linq;
using Editor;
using Sandbox;
namespace Grains.RazorDesigner.Serialization;
public sealed class PreviewTheme
{
private const string LogPrefix = "[Grains.RazorDesigner]";
private const string DefaultThemeFilename = "preview-default.scss";
private static readonly (string Ident, string Subpath)[] DefaultThemeRoots = new[]
{
( "grains_razordesigner", "Assets/RazorDesigner" ),
( "grains_razordesigner", "Libraries/xaz.razordesigner/Assets/RazorDesigner" ),
( "razordesigner", "Assets/RazorDesigner" ),
};
public string Name { get; }
public string Source { get; } // "default" or absolute file path
public string Css { get; }
private PreviewTheme( string name, string source, string css )
{
Name = name;
Source = source;
Css = css;
}
public bool IsDefault => Source == "default";
private static readonly Lazy<PreviewTheme> _default = new( BuildDefault );
public static PreviewTheme Default => _default.Value;
private static PreviewTheme BuildDefault()
{
var path = ResolveDefaultThemePath();
if ( !string.IsNullOrEmpty( path ) )
{
try
{
var css = File.ReadAllText( path );
Log.Info( $"{LogPrefix} PreviewTheme.Default loaded from {path} ({css.Length} chars)" );
return new PreviewTheme( "Default", "default", css );
}
catch ( Exception ex )
{
Log.Warning( $"{LogPrefix} PreviewTheme.Default failed to read '{path}': {ex.Message}; using baked fallback" );
}
}
else
{
Log.Info( $"{LogPrefix} PreviewTheme.Default: no '{DefaultThemeFilename}' asset found in any DefaultThemeRoots; using baked fallback" );
}
return new PreviewTheme( "Default", "default", BakedFallbackCss );
}
private static string ResolveDefaultThemePath()
{
foreach ( var (ident, subpath) in DefaultThemeRoots )
{
var root = EditorUtility.Projects.GetAll()
.FirstOrDefault( p => string.Equals( p.Config?.Ident, ident, StringComparison.OrdinalIgnoreCase ) )
?.GetRootPath();
if ( string.IsNullOrEmpty( root ) ) continue;
var candidate = Path.Combine( root, subpath, DefaultThemeFilename );
if ( File.Exists( candidate ) ) return candidate;
}
return null;
}
// On read failure returns Default and logs. Caller handles missing-file gracefully on cookie restore.
public static PreviewTheme FromFile( string path )
{
try
{
var css = File.ReadAllText( path );
Log.Info( $"{LogPrefix} PreviewTheme.FromFile: {path} ({css.Length} chars)" );
return new PreviewTheme( Path.GetFileName( path ), path, css );
}
catch ( Exception ex )
{
Log.Warning( $"{LogPrefix} PreviewTheme.FromFile failed for '{path}': {ex.Message}; falling back to Default" );
return Default;
}
}
private const string BakedFallbackCss =
".root { color: #fafaff; font-family: Inter; }\n" +
".button { " +
"background-image: linear-gradient(to bottom, #37425D 0%, #283043 100%); " +
"border: 1px solid rgba(194, 201, 219, 0.1); " +
"border-radius: 8px; " +
"padding: 6px 16px; " +
"color: #fafaff; " +
"align-items: center; " +
"justify-content: center; " +
"min-height: 28px; " +
"font-weight: 500; " +
// sbox CSS box-shadow: single layer only, 'inset' is a trailing keyword.
"box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4); " +
"}\n" +
".button:hover { " +
"background-image: linear-gradient(to bottom, #465477 0%, #37425D 100%); " +
"border-color: rgba(63, 169, 245, 1); " +
"box-shadow: 0 2px 4px rgba(0, 0, 0, 0.5); " +
"}\n" +
// TextEntry (and NumberEntry which inherits the .textentry class) — recessed dark.
".textentry { " +
"background-color: #11141D; " +
"border: 1px solid rgba(194, 201, 219, 0.1); " +
"border-radius: 4px; " +
"padding: 4px 10px; " +
"color: #fafaff; " +
"min-height: 26px; " +
"align-items: center; " +
"box-shadow: 0 1px 2px rgba(0, 0, 0, 0.5) inset; " +
"}\n" +
".textentry .placeholder { color: #687AA6; }\n" +
// Checkbox — square checkmark + accent fill when checked.
".checkbox { " +
"flex-direction: row; " +
"align-items: center; " +
"color: #fafaff; " +
"cursor: pointer; " +
"}\n" +
".checkbox > .checkmark { " +
"width: 16px; " +
"height: 16px; " +
"border: 1px solid rgba(194, 201, 219, 0.2); " +
"border-radius: 4px; " +
"margin-right: 6px; " +
"color: transparent; " +
"align-items: center; " +
"justify-content: center; " +
"font-size: 12px; " +
"background-color: #11141D; " +
"box-shadow: 0 1px 1px rgba(0, 0, 0, 0.4) inset; " +
"}\n" +
".checkbox.is-checked > .checkmark { " +
"color: white; " +
"background-image: linear-gradient(to bottom, #5BB8F8 0%, #3FA9F5 100%); " +
"border-color: #2E8FD9; " +
"box-shadow: 0 1px 2px rgba(63, 169, 245, 0.25); " +
"}\n" +
// IconPanel — Material Icons font (the [Library('icon')] alias 'i' too).
".iconpanel, i { " +
"font-family: Material Icons; " +
"font-size: 18px; " +
"color: #fafaff; " +
"align-items: center; " +
"justify-content: center; " +
"}\n" +
".preview-panel { " +
"border: 1px solid rgba(194, 201, 219, 0.1); " +
"background-color: rgba(40, 48, 67, 0.3); " +
"min-height: 28px; " +
"}\n" +
// ButtonGroup — minimal container slot.
".preview-buttongroup { " +
"background-color: rgba(40, 48, 67, 0.4); " +
"border: 1px solid rgba(194, 201, 219, 0.1); " +
"border-radius: 8px; " +
"min-height: 32px; " +
"}\n" +
// DropDown — raised button surface + .inner caret zone (right-anchored).
".preview-dropdown { " +
"background-image: linear-gradient(to bottom, #37425D 0%, #283043 100%); " +
"background-color: rgba(0, 0, 0, 0); " +
"border: 1px solid rgba(194, 201, 219, 0.1); " +
"border-radius: 8px; " +
"color: #fafaff; " +
"min-height: 32px; " +
"position: relative; " +
"box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4); " +
"}\n" +
".preview-dropdown > .inner { " +
"position: absolute; " +
"top: 0; right: 0; bottom: 0; " +
"width: 32px; " +
"background-color: rgba(17, 20, 29, 0.4); " +
"border-left: 1px solid rgba(194, 201, 219, 0.1); " +
"align-items: center; " +
"justify-content: center; " +
"}\n" +
".form { " +
"background-image: linear-gradient(to bottom, #283043 0%, #1F2535 100%); " +
"background-color: rgba(0, 0, 0, 0); " +
"border: 1px solid rgba(194, 201, 219, 0.1); " +
"border-radius: 6px; " +
"min-height: 60px; " +
"padding: 8px; " +
"box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); " +
"}\n" +
".field { " +
"background-color: rgba(0, 0, 0, 0); " +
"border: none; " +
"border-bottom: 1px solid rgba(194, 201, 219, 0.06); " +
"min-height: 28px; " +
"padding: 4px 0; " +
"}\n" +
".field-control { " +
"background-color: #191D2A; " +
"border: 1px solid rgba(194, 201, 219, 0.08); " +
"border-radius: 4px; " +
"min-height: 28px; " +
"flex-grow: 1; " +
"box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3) inset; " +
"}\n";
}