diff --git a/src/AdaptiveRemote.App/AdaptiveRemote.App.csproj b/src/AdaptiveRemote.App/AdaptiveRemote.App.csproj
index e104217b..f5ea8c04 100644
--- a/src/AdaptiveRemote.App/AdaptiveRemote.App.csproj
+++ b/src/AdaptiveRemote.App/AdaptiveRemote.App.csproj
@@ -31,6 +31,10 @@
+
+
+
+
PreserveNewest
diff --git a/src/AdaptiveRemote.App/Components/Remote.razor b/src/AdaptiveRemote.App/Components/Remote.razor
index df6c23c0..3b123202 100644
--- a/src/AdaptiveRemote.App/Components/Remote.razor
+++ b/src/AdaptiveRemote.App/Components/Remote.razor
@@ -1,5 +1,10 @@
@inject Services.IRemoteDefinitionService RemoteDefinitions
+@inject Services.Layout.IDynamicStylesheetProvider Stylesheet
+@if (Stylesheet.GetCss() is { } css)
+{
+
+}
diff --git a/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs b/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs
index f5b7f17b..69b742e3 100644
--- a/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs
+++ b/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs
@@ -30,6 +30,7 @@ internal static IServiceCollection AddRemoteServices(this IServiceCollection ser
.AddCloudAssetServices()
.AddScopedLifecycleService()
.AddScoped()
+ .AddScoped()
.AddSingleton()
.Configure(configuration.GetSection(SettingsKeys.ProgrammaticSettings))
.Configure(configuration.GetSection("CloudSettings"));
diff --git a/src/AdaptiveRemote.App/Services/Layout/IDynamicStylesheetProvider.cs b/src/AdaptiveRemote.App/Services/Layout/IDynamicStylesheetProvider.cs
new file mode 100644
index 00000000..e644f696
--- /dev/null
+++ b/src/AdaptiveRemote.App/Services/Layout/IDynamicStylesheetProvider.cs
@@ -0,0 +1,9 @@
+namespace AdaptiveRemote.Services.Layout;
+
+///
+/// Scoped. Returns the CSS for the active layout in this scope.
+///
+public interface IDynamicStylesheetProvider
+{
+ string? GetCss();
+}
diff --git a/src/AdaptiveRemote.App/Services/Layout/LayoutStylesheetProvider.cs b/src/AdaptiveRemote.App/Services/Layout/LayoutStylesheetProvider.cs
new file mode 100644
index 00000000..6571394b
--- /dev/null
+++ b/src/AdaptiveRemote.App/Services/Layout/LayoutStylesheetProvider.cs
@@ -0,0 +1,19 @@
+using System.Reflection;
+
+namespace AdaptiveRemote.Services.Layout;
+
+internal sealed class LayoutStylesheetProvider : IDynamicStylesheetProvider
+{
+ private static readonly string _css = LoadCss();
+
+ public string? GetCss() => _css;
+
+ private static string LoadCss()
+ {
+ Assembly assembly = typeof(LayoutStylesheetProvider).Assembly;
+ using Stream stream = assembly.GetManifestResourceStream(
+ "AdaptiveRemote.Services.Layout.layout-grid.css")!;
+ using StreamReader reader = new(stream);
+ return reader.ReadToEnd();
+ }
+}
diff --git a/src/AdaptiveRemote.App/Services/Layout/layout-grid.css b/src/AdaptiveRemote.App/Services/Layout/layout-grid.css
new file mode 100644
index 00000000..707174d2
--- /dev/null
+++ b/src/AdaptiveRemote.App/Services/Layout/layout-grid.css
@@ -0,0 +1,190 @@
+#ROOT {
+ display: grid;
+ grid-template-rows: 6fr 3fr 1fr;
+ grid-template-columns: 3fr 2fr;
+ grid-gap: 20px;
+ width: 98vw;
+ height: 96vh;
+ padding: 2vh 1vw;
+}
+#ROOT #DPAD {
+ grid-row: 1;
+ grid-column: 1;
+ display: grid;
+ grid-template-rows: repeat(3, 1fr);
+ grid-template-columns: repeat(3, 1fr);
+ grid-gap: 10px;
+}
+#ROOT #DPAD #UP {
+ grid-row: 1;
+ grid-column: 2;
+}
+#ROOT #DPAD #DOWN {
+ grid-row: 3;
+ grid-column: 2;
+}
+#ROOT #DPAD #LEFT {
+ grid-row: 2;
+ grid-column: 1;
+}
+#ROOT #DPAD #RIGHT {
+ grid-row: 2;
+ grid-column: 3;
+}
+#ROOT #DPAD #SELECT {
+ grid-row: 2;
+ grid-column: 2;
+}
+#ROOT #DPAD #POWER {
+ grid-row: 1;
+ grid-column: 1;
+ margin: 20px;
+ background-color: #aa2525;
+ border-color: #561313;
+ border-width: 2px;
+ color: black;
+}
+#ROOT #DPAD #POWER:hover {
+ background-color: #bf2a2a;
+}
+#ROOT #DPAD #POWER:active,
+#ROOT #DPAD #POWER.btn-active {
+ background-color: #d74545;
+}
+#ROOT #DPAD #POWER.btn-not-programmed {
+ color: #aa2525;
+ border-color: #aa2525;
+ background-color: #222;
+ border-width: 5px;
+}
+#ROOT #DPAD #POWER.btn-not-programmed:hover {
+ background-color: #2f2f2f;
+}
+#ROOT #DPAD #POWER.btn-not-programmed:active,
+#ROOT #DPAD #POWER.btn-not-programmed.btn-active {
+ background-color: #484848;
+}
+#ROOT #DPAD #POWER.btn-disabled {
+ background-color: #888888;
+ color: #3c3c3c;
+ border-color: #555555;
+}
+#ROOT #DPAD #POWERON {
+ display: none;
+}
+#ROOT #DPAD #POWEROFF {
+ display: none;
+}
+#ROOT #DPAD #BACK {
+ grid-row: 3;
+ grid-column: 1;
+ margin: 20px;
+}
+#ROOT #WELL {
+ grid-row: 1;
+ grid-column: 2;
+ margin: -0.5%;
+}
+#ROOT #WELL > button {
+ width: 49%;
+ height: 19%;
+ margin: 0.5%;
+}
+#ROOT #PLAYBACK {
+ grid-row: 2;
+ grid-column: 1;
+ display: grid;
+ grid-template-rows: repeat(3, 1fr);
+ grid-template-columns: repeat(5, 1fr);
+ grid-gap: 10px;
+}
+#ROOT #PLAYBACK #REPLAY {
+ grid-row: 1;
+ grid-column: 1;
+}
+#ROOT #PLAYBACK #PLAY {
+ grid-row: 1;
+ grid-column: 2;
+}
+#ROOT #PLAYBACK #PAUSE {
+ grid-row: 1;
+ grid-column: 3;
+}
+#ROOT #PLAYBACK #RECORD {
+ grid-row: 1;
+ grid-column: 4;
+}
+#ROOT #PLAYBACK #SKIP {
+ grid-row: 1;
+ grid-column: 5;
+}
+#ROOT #CHANNELANDVOLUME {
+ grid-row: 2;
+ grid-column: 2;
+ display: grid;
+ grid-template-rows: 36pt 1fr 1fr;
+ grid-template-columns: 2fr 1fr 2fr;
+ grid-gap: 10px;
+}
+#ROOT #CHANNELANDVOLUME:before {
+ grid-row: 1;
+ grid-column: 1;
+ font-size: 36pt;
+ font-weight: bold;
+ color: #62b0ff;
+ content: "Channel";
+ text-align: center;
+}
+#ROOT #CHANNELANDVOLUME:after {
+ grid-row: 1;
+ grid-column: 3;
+ font-size: 36pt;
+ font-weight: bold;
+ color: #62b0ff;
+ content: "Volume";
+ text-align: center;
+}
+#ROOT #CHANNELANDVOLUME #CHANNELUP {
+ grid-row: 2;
+ grid-column: 1;
+}
+#ROOT #CHANNELANDVOLUME #CHANNELDOWN {
+ grid-row: 3;
+ grid-column: 1;
+}
+#ROOT #CHANNELANDVOLUME #VOLUMEUP {
+ grid-row: 2;
+ grid-column: 3;
+}
+#ROOT #CHANNELANDVOLUME #VOLUMEDOWN {
+ grid-row: 3;
+ grid-column: 3;
+}
+#ROOT #CHANNELANDVOLUME #MUTE {
+ grid-row: 2;
+ grid-column: 2;
+ grid-row-end: span 2;
+}
+#ROOT #GUTTER {
+ grid-row: 3;
+ grid-column-start: 1;
+ grid-column-end: span 2;
+ grid-gap: 20px;
+ display: grid;
+ grid-template-rows: 1fr;
+ grid-template-columns: 6fr 1fr 1fr;
+ grid-gap: 10px;
+}
+#ROOT #GUTTER #EXIT {
+ grid-row: 1;
+ grid-column: 3;
+}
+#ROOT #GUTTER #LEARN {
+ grid-row: 1;
+ grid-column: 2;
+ margin: 10px;
+}
+#ROOT #GUTTER #LISTENING {
+ grid-row: 1;
+ grid-column: 1;
+}
diff --git a/src/AdaptiveRemote.App/Services/Testing/IUITestService.cs b/src/AdaptiveRemote.App/Services/Testing/IUITestService.cs
index 855f6163..107c805e 100644
--- a/src/AdaptiveRemote.App/Services/Testing/IUITestService.cs
+++ b/src/AdaptiveRemote.App/Services/Testing/IUITestService.cs
@@ -53,4 +53,13 @@ public partial interface IUITestService : IDisposable
/// Cancellation token for the operation.
/// The inner HTML of the first matching element, or null if not found or not visible.
Task GetInnerHtmlFromElementWithCssClassAsync(string cssClass, CancellationToken cancellationToken);
+
+ ///
+ /// Gets a CSS property value from the first stylesheet rule that matches the provided selector.
+ ///
+ /// The exact CSS selector text to match.
+ /// The CSS property name to read (for example, display).
+ /// Cancellation token for the operation.
+ /// The matching property value, or null if no matching rule/property exists.
+ Task GetStylesheetRulePropertyValueAsync(string selector, string propertyName, CancellationToken cancellationToken);
}
diff --git a/src/AdaptiveRemote.App/wwwroot/css/app.css b/src/AdaptiveRemote.App/wwwroot/css/app.css
index 83ee5b09..6f45616e 100644
--- a/src/AdaptiveRemote.App/wwwroot/css/app.css
+++ b/src/AdaptiveRemote.App/wwwroot/css/app.css
@@ -84,197 +84,6 @@ div.conversation-speaking-message {
left: 50%;
transform: translate(-50%, -50%);
}
-#ROOT {
- display: grid;
- grid-template-rows: 6fr 3fr 1fr;
- grid-template-columns: 3fr 2fr;
- grid-gap: 10px;
- grid-gap: 20px;
- width: 98vw;
- height: 96vh;
- padding: 2vh 1vw;
-}
-#ROOT #DPAD {
- grid-row: 1;
- grid-column: 1;
- display: grid;
- grid-template-rows: repeat(3, 1fr);
- grid-template-columns: repeat(3, 1fr);
- grid-gap: 10px;
-}
-#ROOT #DPAD #UP {
- grid-row: 1;
- grid-column: 2;
-}
-#ROOT #DPAD #DOWN {
- grid-row: 3;
- grid-column: 2;
-}
-#ROOT #DPAD #LEFT {
- grid-row: 2;
- grid-column: 1;
-}
-#ROOT #DPAD #RIGHT {
- grid-row: 2;
- grid-column: 3;
-}
-#ROOT #DPAD #SELECT {
- grid-row: 2;
- grid-column: 2;
-}
-#ROOT #DPAD #POWER {
- grid-row: 1;
- grid-column: 1;
- margin: 20px;
- background-color: #aa2525;
- border-color: #561313;
- border-width: 2px;
- color: black;
-}
-#ROOT #DPAD #POWER:hover {
- background-color: #bf2a2a;
-}
-#ROOT #DPAD #POWER:active,
-#ROOT #DPAD #POWER.btn-active {
- background-color: #d74545;
-}
-#ROOT #DPAD #POWER.btn-not-programmed {
- color: #aa2525;
- border-color: #aa2525;
- background-color: #222;
- border-width: 5px;
-}
-#ROOT #DPAD #POWER.btn-not-programmed:hover {
- background-color: #2f2f2f;
-}
-#ROOT #DPAD #POWER.btn-not-programmed:active,
-#ROOT #DPAD #POWER.btn-not-programmed.btn-active {
- background-color: #484848;
-}
-#ROOT #DPAD #POWER.btn-disabled {
- background-color: #888888;
- color: #3c3c3c;
- border-color: #555555;
-}
-#ROOT #DPAD #POWERON {
- display: none;
-}
-#ROOT #DPAD #POWEROFF {
- display: none;
-}
-#ROOT #DPAD #BACK {
- grid-row: 3;
- grid-column: 1;
- margin: 20px;
-}
-#ROOT #WELL {
- grid-row: 1;
- grid-column: 2;
- margin: -0.5%;
-}
-#ROOT #WELL > button {
- width: 49%;
- height: 19%;
- margin: 0.5%;
-}
-#ROOT #PLAYBACK {
- grid-row: 2;
- grid-column: 1;
- display: grid;
- grid-template-rows: repeat(3, 1fr);
- grid-template-columns: repeat(5, 1fr);
- grid-gap: 10px;
-}
-#ROOT #PLAYBACK #REPLAY {
- grid-row: 1;
- grid-column: 1;
-}
-#ROOT #PLAYBACK #PLAY {
- grid-row: 1;
- grid-column: 2;
-}
-#ROOT #PLAYBACK #PAUSE {
- grid-row: 1;
- grid-column: 3;
-}
-#ROOT #PLAYBACK #RECORD {
- grid-row: 1;
- grid-column: 4;
-}
-#ROOT #PLAYBACK #SKIP {
- grid-row: 1;
- grid-column: 5;
-}
-#ROOT #CHANNELANDVOLUME {
- grid-row: 2;
- grid-column: 2;
- display: grid;
- grid-template-rows: 36pt 1fr 1fr;
- grid-template-columns: 2fr 1fr 2fr;
- grid-gap: 10px;
-}
-#ROOT #CHANNELANDVOLUME:before {
- grid-row: 1;
- grid-column: 1;
- font-size: 36pt;
- font-weight: bold;
- color: #62b0ff;
- content: "Channel";
- text-align: center;
-}
-#ROOT #CHANNELANDVOLUME:after {
- grid-row: 1;
- grid-column: 3;
- font-size: 36pt;
- font-weight: bold;
- color: #62b0ff;
- content: "Volume";
- text-align: center;
-}
-#ROOT #CHANNELANDVOLUME #CHANNELUP {
- grid-row: 2;
- grid-column: 1;
-}
-#ROOT #CHANNELANDVOLUME #CHANNELDOWN {
- grid-row: 3;
- grid-column: 1;
-}
-#ROOT #CHANNELANDVOLUME #VOLUMEUP {
- grid-row: 2;
- grid-column: 3;
-}
-#ROOT #CHANNELANDVOLUME #VOLUMEDOWN {
- grid-row: 3;
- grid-column: 3;
-}
-#ROOT #CHANNELANDVOLUME #MUTE {
- grid-row: 2;
- grid-column: 2;
- grid-row-end: span 2;
-}
-#ROOT #GUTTER {
- grid-row: 3;
- grid-column-start: 1;
- grid-column-end: span 2;
- grid-gap: 20px;
- display: grid;
- grid-template-rows: 1fr;
- grid-template-columns: 6fr 1fr 1fr;
- grid-gap: 10px;
-}
-#ROOT #GUTTER #EXIT {
- grid-row: 1;
- grid-column: 3;
-}
-#ROOT #GUTTER #LEARN {
- grid-row: 1;
- grid-column: 2;
- margin: 10px;
-}
-#ROOT #GUTTER #LISTENING {
- grid-row: 1;
- grid-column: 1;
-}
#LifecycleTitle {
height: 50vh;
width: 100%;
diff --git a/src/AdaptiveRemote.App/wwwroot/css/app.less b/src/AdaptiveRemote.App/wwwroot/css/app.less
index fbd3bbec..f69f7b7b 100644
--- a/src/AdaptiveRemote.App/wwwroot/css/app.less
+++ b/src/AdaptiveRemote.App/wwwroot/css/app.less
@@ -6,7 +6,6 @@ html, body {
@import "button_ui.less";
@import "conversation_ui.less";
-@import "layout.less";
@import "loading_screen.less";
#blazor-error-ui {
diff --git a/src/AdaptiveRemote.App/wwwroot/css/app.min.css b/src/AdaptiveRemote.App/wwwroot/css/app.min.css
index 97e45add..ce39b9e6 100644
--- a/src/AdaptiveRemote.App/wwwroot/css/app.min.css
+++ b/src/AdaptiveRemote.App/wwwroot/css/app.min.css
@@ -1 +1 @@
-html,body{font-family:'Segoe UI',Helvetica,Arial,sans-serif;font-size:48px;font-weight:bold;background-color:#222;}html,body{margin:0;}.btn-primary{color:#222;border-radius:15px;font-size:36pt;font-weight:bold;cursor:pointer;background-color:#62b0ff;border-color:#007dfb;border-width:2px;}.btn-primary:hover{background-color:#7cbdff;}.btn-primary:active,.btn-primary.btn-active{background-color:#afd6ff;}.btn-primary.btn-not-programmed{color:#62b0ff;border-color:#62b0ff;background-color:#222;border-width:5px;}.btn-primary.btn-not-programmed:hover{background-color:#2f2f2f;}.btn-primary.btn-not-programmed:active,.btn-primary.btn-not-programmed.btn-active{background-color:#484848;}.btn-primary.btn-disabled{background-color:#888;color:#3c3c3c;border-color:#555;}div.conversation-border{pointer-events:none;position:fixed;top:-20px;left:-20px;border-color:#ffea00;border-style:solid;border-width:30px;border-radius:45px;}div.conversation-border div{width:100vw;height:100vh;margin:-10px;}div.conversation-status-message{color:#ffea00;text-align:center;padding:20px;border-radius:15px;}div.conversation-status-message.clickable{cursor:pointer;}div.conversation-status-message.clickable:hover{background-color:#484848;}div.conversation-speaking-message{font-size:24px;background-color:#222;border-radius:15px;border:5px solid #ffea00;margin:-5px;color:#fff;padding:0 48px;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);}#ROOT{display:grid;grid-template-rows:6fr 3fr 1fr;grid-template-columns:3fr 2fr;grid-gap:10px;grid-gap:20px;width:98vw;height:96vh;padding:2vh 1vw;}#ROOT #DPAD{grid-row:1;grid-column:1;display:grid;grid-template-rows:repeat(3,1fr);grid-template-columns:repeat(3,1fr);grid-gap:10px;}#ROOT #DPAD #UP{grid-row:1;grid-column:2;}#ROOT #DPAD #DOWN{grid-row:3;grid-column:2;}#ROOT #DPAD #LEFT{grid-row:2;grid-column:1;}#ROOT #DPAD #RIGHT{grid-row:2;grid-column:3;}#ROOT #DPAD #SELECT{grid-row:2;grid-column:2;}#ROOT #DPAD #POWER{grid-row:1;grid-column:1;margin:20px;background-color:#aa2525;border-color:#561313;border-width:2px;color:#000;}#ROOT #DPAD #POWER:hover{background-color:#bf2a2a;}#ROOT #DPAD #POWER:active,#ROOT #DPAD #POWER.btn-active{background-color:#d74545;}#ROOT #DPAD #POWER.btn-not-programmed{color:#aa2525;border-color:#aa2525;background-color:#222;border-width:5px;}#ROOT #DPAD #POWER.btn-not-programmed:hover{background-color:#2f2f2f;}#ROOT #DPAD #POWER.btn-not-programmed:active,#ROOT #DPAD #POWER.btn-not-programmed.btn-active{background-color:#484848;}#ROOT #DPAD #POWER.btn-disabled{background-color:#888;color:#3c3c3c;border-color:#555;}#ROOT #DPAD #POWERON{display:none;}#ROOT #DPAD #POWEROFF{display:none;}#ROOT #DPAD #BACK{grid-row:3;grid-column:1;margin:20px;}#ROOT #WELL{grid-row:1;grid-column:2;margin:-.5%;}#ROOT #WELL>button{width:49%;height:19%;margin:.5%;}#ROOT #PLAYBACK{grid-row:2;grid-column:1;display:grid;grid-template-rows:repeat(3,1fr);grid-template-columns:repeat(5,1fr);grid-gap:10px;}#ROOT #PLAYBACK #REPLAY{grid-row:1;grid-column:1;}#ROOT #PLAYBACK #PLAY{grid-row:1;grid-column:2;}#ROOT #PLAYBACK #PAUSE{grid-row:1;grid-column:3;}#ROOT #PLAYBACK #RECORD{grid-row:1;grid-column:4;}#ROOT #PLAYBACK #SKIP{grid-row:1;grid-column:5;}#ROOT #CHANNELANDVOLUME{grid-row:2;grid-column:2;display:grid;grid-template-rows:36pt 1fr 1fr;grid-template-columns:2fr 1fr 2fr;grid-gap:10px;}#ROOT #CHANNELANDVOLUME:before{grid-row:1;grid-column:1;font-size:36pt;font-weight:bold;color:#62b0ff;content:"Channel";text-align:center;}#ROOT #CHANNELANDVOLUME:after{grid-row:1;grid-column:3;font-size:36pt;font-weight:bold;color:#62b0ff;content:"Volume";text-align:center;}#ROOT #CHANNELANDVOLUME #CHANNELUP{grid-row:2;grid-column:1;}#ROOT #CHANNELANDVOLUME #CHANNELDOWN{grid-row:3;grid-column:1;}#ROOT #CHANNELANDVOLUME #VOLUMEUP{grid-row:2;grid-column:3;}#ROOT #CHANNELANDVOLUME #VOLUMEDOWN{grid-row:3;grid-column:3;}#ROOT #CHANNELANDVOLUME #MUTE{grid-row:2;grid-column:2;grid-row-end:span 2;}#ROOT #GUTTER{grid-row:3;grid-column-start:1;grid-column-end:span 2;grid-gap:20px;display:grid;grid-template-rows:1fr;grid-template-columns:6fr 1fr 1fr;grid-gap:10px;}#ROOT #GUTTER #EXIT{grid-row:1;grid-column:3;}#ROOT #GUTTER #LEARN{grid-row:1;grid-column:2;margin:10px;}#ROOT #GUTTER #LISTENING{grid-row:1;grid-column:1;}#LifecycleTitle{height:50vh;width:100%;color:#fff;position:relative;}#LifecycleTitle>div{text-align:center;position:absolute;width:100%;bottom:0;}#LifecycleTaskDescription{text-align:center;color:#fff;font-size:36px;}#blazor-error-ui{background:#ffffe0;bottom:0;box-shadow:0 -1px 2px rgba(0,0,0,.2);display:none;left:0;padding:.6rem 1.25rem .7rem 1.25rem;position:fixed;width:100%;z-index:1000;}#blazor-error-ui .dismiss{cursor:pointer;position:absolute;right:.75rem;top:.5rem;}
\ No newline at end of file
+html,body{font-family:'Segoe UI',Helvetica,Arial,sans-serif;font-size:48px;font-weight:bold;background-color:#222;}html,body{margin:0;}.btn-primary{color:#222;border-radius:15px;font-size:36pt;font-weight:bold;cursor:pointer;background-color:#62b0ff;border-color:#007dfb;border-width:2px;}.btn-primary:hover{background-color:#7cbdff;}.btn-primary:active,.btn-primary.btn-active{background-color:#afd6ff;}.btn-primary.btn-not-programmed{color:#62b0ff;border-color:#62b0ff;background-color:#222;border-width:5px;}.btn-primary.btn-not-programmed:hover{background-color:#2f2f2f;}.btn-primary.btn-not-programmed:active,.btn-primary.btn-not-programmed.btn-active{background-color:#484848;}.btn-primary.btn-disabled{background-color:#888;color:#3c3c3c;border-color:#555;}div.conversation-border{pointer-events:none;position:fixed;top:-20px;left:-20px;border-color:#ffea00;border-style:solid;border-width:30px;border-radius:45px;}div.conversation-border div{width:100vw;height:100vh;margin:-10px;}div.conversation-status-message{color:#ffea00;text-align:center;padding:20px;border-radius:15px;}div.conversation-status-message.clickable{cursor:pointer;}div.conversation-status-message.clickable:hover{background-color:#484848;}div.conversation-speaking-message{font-size:24px;background-color:#222;border-radius:15px;border:5px solid #ffea00;margin:-5px;color:#fff;padding:0 48px;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);}#LifecycleTitle{height:50vh;width:100%;color:#fff;position:relative;}#LifecycleTitle>div{text-align:center;position:absolute;width:100%;bottom:0;}#LifecycleTaskDescription{text-align:center;color:#fff;font-size:36px;}#blazor-error-ui{background:#ffffe0;bottom:0;box-shadow:0 -1px 2px rgba(0,0,0,.2);display:none;left:0;padding:.6rem 1.25rem .7rem 1.25rem;position:fixed;width:100%;z-index:1000;}#blazor-error-ui .dismiss{cursor:pointer;position:absolute;right:.75rem;top:.5rem;}
\ No newline at end of file
diff --git a/test/AdaptiveRemote.App.Tests/Services/Layout/LayoutStylesheetProviderTests.cs b/test/AdaptiveRemote.App.Tests/Services/Layout/LayoutStylesheetProviderTests.cs
new file mode 100644
index 00000000..490179af
--- /dev/null
+++ b/test/AdaptiveRemote.App.Tests/Services/Layout/LayoutStylesheetProviderTests.cs
@@ -0,0 +1,20 @@
+using FluentAssertions;
+
+namespace AdaptiveRemote.Services.Layout;
+
+[TestClass]
+public class LayoutStylesheetProviderTests
+{
+ [TestMethod]
+ public void LayoutStylesheetProvider_GetCss_ReturnsNonNullContent()
+ {
+ // Arrange
+ LayoutStylesheetProvider sut = new();
+
+ // Act
+ string? css = sut.GetCss();
+
+ // Assert
+ css.Should().NotBeNullOrEmpty();
+ }
+}
diff --git a/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/LayoutButtons.feature b/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/LayoutButtons.feature
index aa4488e6..4b35f786 100644
--- a/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/LayoutButtons.feature
+++ b/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/LayoutButtons.feature
@@ -3,6 +3,16 @@ Feature: Layout button verification
I want all expected buttons from the layout to be present and accessible
So that I can control my TV and AV equipment
+Scenario: Layout CSS rules are present
+ Given the application is in the Ready phase
+ Then the stylesheet selector '#ROOT' should define 'display' as 'grid'
+ And the stylesheet selector '#ROOT' should define 'grid-template-rows' as '6fr 3fr 1fr'
+ And the stylesheet selector '#ROOT' should define 'grid-template-columns' as '3fr 2fr'
+ And the stylesheet selector '#ROOT' should define 'grid-gap' as '20px'
+ When I click on the 'Exit' button
+ And I wait for the application to shut down
+ Then I should not see any error messages in the logs
+
Scenario: All expected buttons from layout are present
Given the application is not running
When I start the application
diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/UISteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/UISteps.cs
index f94acc3b..37ad1222 100644
--- a/test/AdaptiveRemote.EndToEndTests.Steps/UISteps.cs
+++ b/test/AdaptiveRemote.EndToEndTests.Steps/UISteps.cs
@@ -60,4 +60,18 @@ public void ThenIShouldNotSeeAModalMessage()
{
Host.UI.WaitForNoModalMessage();
}
+
+ [Then(@"the stylesheet selector {string} should define {string} as {string}")]
+ public void ThenTheStylesheetSelectorShouldDefineAs(string selector, string propertyName, string expectedValue)
+ {
+ string? actualValue = Host.UI.GetStylesheetRulePropertyValue(selector, propertyName);
+ Assert.AreEqual(
+ expectedValue,
+ actualValue,
+ "Expected selector '{0}' to define '{1}' as '{2}', but was '{3}'.",
+ selector,
+ propertyName,
+ expectedValue,
+ actualValue ?? "");
+ }
}
diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/IUITestServiceExtensions.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/IUITestServiceExtensions.cs
index e3565d4d..502ddba8 100644
--- a/test/AdaptiveRemote.EndtoEndTests.TestServices/IUITestServiceExtensions.cs
+++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/IUITestServiceExtensions.cs
@@ -270,6 +270,28 @@ public static bool WaitForButtonProgrammed(this IUITestService service, string l
return WaitHelpers.WaitForState(button.IsProgrammedAsync, programmed, timeout);
}
+ ///
+ /// Gets a stylesheet rule property value using default timeout (synchronous wrapper; blocks the calling thread).
+ ///
+ /// The UI test service.
+ /// The exact CSS selector text to match.
+ /// The CSS property name to read.
+ /// Optional timeout for the operation.
+ /// The property value if found; otherwise null.
+ public static string? GetStylesheetRulePropertyValue(this IUITestService service, string selector, string propertyName, int timeoutInSeconds = DefaultUITimeoutInSeconds)
+ => service.GetStylesheetRulePropertyValue(selector, propertyName, TimeSpan.FromSeconds(timeoutInSeconds));
+
+ ///
+ /// Gets a stylesheet rule property value using explicit timeout (synchronous wrapper; blocks the calling thread).
+ ///
+ /// The UI test service.
+ /// The exact CSS selector text to match.
+ /// The CSS property name to read.
+ /// Timeout for the operation.
+ /// The property value if found; otherwise null.
+ public static string? GetStylesheetRulePropertyValue(this IUITestService service, string selector, string propertyName, TimeSpan timeout)
+ => WaitHelpers.WaitForAsyncTask(ct => service.GetStylesheetRulePropertyValueAsync(selector, propertyName, ct), timeout);
+
private static IUIButtonTestObject GetButtonByLabel(this IUITestService service, string label, TimeSpan timeout)
{
IUIButtonTestObject? button = null;
diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/PlaywrightUITestService.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/PlaywrightUITestService.cs
index 2283ae6e..ce80d0c5 100644
--- a/test/AdaptiveRemote.EndtoEndTests.TestServices/PlaywrightUITestService.cs
+++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/PlaywrightUITestService.cs
@@ -171,6 +171,38 @@ private ILocator GetTextLocator(string text)
}
}
+ public async Task GetStylesheetRulePropertyValueAsync(
+ string selector,
+ string propertyName,
+ CancellationToken cancellationToken = default)
+ {
+ return await CurrentPage.EvaluateAsync(
+ @"({ selector, propertyName }) => {
+ for (const stylesheet of Array.from(document.styleSheets)) {
+ let rules;
+ try {
+ rules = stylesheet.cssRules;
+ } catch {
+ continue;
+ }
+
+ for (const rule of Array.from(rules)) {
+ if (rule.type !== CSSRule.STYLE_RULE) {
+ continue;
+ }
+
+ if (rule.selectorText === selector) {
+ const value = rule.style.getPropertyValue(propertyName);
+ return value ? value.trim() : null;
+ }
+ }
+ }
+
+ return null;
+ }",
+ new { selector, propertyName });
+ }
+
public void Dispose()
{
GC.SuppressFinalize(this);