feat(socials): bar appearance — text colour, background plate, opacity, text shadow
In the Socials drawer, under the show/hide switch: text colour, an optional background plate with its own colour and opacity, and a text drop shadow. The bar had been hard-wired white-on-transparent since it shipped. - `SocialsConfig` gains five additive properties (BarTextColor, BarBackgroundEnabled, BarBackgroundColor, BarBackgroundOpacity, BarTextShadow). - Persistence: five new Socials columns, added by the per-column idempotent migration pattern (a half-migrated DB heals instead of failing), written in Save and read back in Load with defaults for pre-existing rows. - `SocialBarRenderer.Render(entries, SocialBarStyle?)` — the look is a separate value type so the renderer stays a pure "entries + look" function and the preview can never read a half-applied config mid-edit. A malformed hex falls back to white/black instead of throwing inside a render tick. - The plate hugs the entries (rounded 20px, padding) so it reads as a caption bar, not a band across the frame. Off by default, because transparent is what creators have today. - The colour pickers reuse `BorderColorPalette` — one catalogue of the app's colours, per the TASK 60/63 rule. Guard: SocialBarAppearanceTests paints the real strip and inspects pixels — default is white with no plate, a red text colour reaches the glyphs, a 55% blue plate yields half-transparent blue pixels, and the shadow adds dark pixels that were not there before.
This commit is contained in:
@@ -33,8 +33,11 @@ public static class SocialBarRenderer
|
||||
public const int Width = 1920;
|
||||
|
||||
/// <summary>Returns the strip frame, or null when there is nothing to render.</summary>
|
||||
public static VideoFrame? Render(IEnumerable<SocialEntry>? entries)
|
||||
public static VideoFrame? Render(IEnumerable<SocialEntry>? entries, SocialBarStyle? style = null)
|
||||
{
|
||||
var look = style ?? SocialBarStyle.Default;
|
||||
var textBrush = look.TextBrush;
|
||||
var glyphEffect = look.TextShadow ? ShadowEffect() : null;
|
||||
var list = entries?.Where(e => e != null).ToList();
|
||||
if (list == null || list.Count == 0) return null;
|
||||
|
||||
@@ -51,7 +54,8 @@ public static class SocialBarRenderer
|
||||
item.Children.Add(new Path
|
||||
{
|
||||
Data = Geometry.Parse(entry.LogoData),
|
||||
Fill = Brushes.White,
|
||||
Fill = textBrush,
|
||||
Effect = glyphEffect,
|
||||
Width = 24,
|
||||
Height = 24,
|
||||
Stretch = Stretch.Uniform,
|
||||
@@ -60,7 +64,8 @@ public static class SocialBarRenderer
|
||||
item.Children.Add(new TextBlock
|
||||
{
|
||||
Text = entry.Handle,
|
||||
Foreground = Brushes.White,
|
||||
Foreground = textBrush,
|
||||
Effect = glyphEffect,
|
||||
FontSize = 14,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(8, 0, 0, 0),
|
||||
@@ -77,7 +82,22 @@ public static class SocialBarRenderer
|
||||
Effect = new DropShadowEffect { Color = Color.FromRgb(0x2e, 0xcc, 0x71), BlurRadius = 18, ShadowDepth = 0, Opacity = 0.9 },
|
||||
};
|
||||
var centered = new Grid { HorizontalAlignment = HorizontalAlignment.Center, Margin = new Thickness(0, GlowPad, 0, 0) };
|
||||
centered.Children.Add(row);
|
||||
if (look.BackgroundEnabled)
|
||||
{
|
||||
// The plate hugs the entries, so it reads as a caption bar rather than a band across
|
||||
// the frame; 20px radius matches the pill language used elsewhere.
|
||||
centered.Children.Add(new Border
|
||||
{
|
||||
Background = look.BackgroundBrush,
|
||||
CornerRadius = new CornerRadius(20),
|
||||
Padding = new Thickness(18, 6, 18, 6),
|
||||
Child = row,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
centered.Children.Add(row);
|
||||
}
|
||||
container.Children.Add(centered);
|
||||
|
||||
container.Measure(new Size(Width, height));
|
||||
@@ -102,4 +122,52 @@ public static class SocialBarRenderer
|
||||
|
||||
return new VideoFrame(Width, height, pixels);
|
||||
}
|
||||
|
||||
private static DropShadowEffect ShadowEffect() => new()
|
||||
{
|
||||
Color = Colors.Black,
|
||||
BlurRadius = 4,
|
||||
ShadowDepth = 2,
|
||||
Direction = 315,
|
||||
Opacity = 0.85,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>How the social bar is painted (creator request 2026-10-30: text colour, an optional
|
||||
/// plate with its own colour and opacity, and a text drop shadow).
|
||||
///
|
||||
/// <para>It is a separate value type, not <see cref="SocialsConfig"/>, so the renderer stays a
|
||||
/// pure function of "entries + look" and the preview/output can never read a half-applied
|
||||
/// config mid-edit.</para></summary>
|
||||
public readonly record struct SocialBarStyle(
|
||||
string TextColor,
|
||||
bool BackgroundEnabled,
|
||||
string BackgroundColor,
|
||||
double BackgroundOpacity,
|
||||
bool TextShadow)
|
||||
{
|
||||
public static SocialBarStyle Default => new("#FFFFFF", false, "#000000", 0.55, false);
|
||||
|
||||
public static SocialBarStyle From(SocialsConfig? config) => config is null
|
||||
? Default
|
||||
: new(config.BarTextColor, config.BarBackgroundEnabled, config.BarBackgroundColor,
|
||||
config.BarBackgroundOpacity, config.BarTextShadow);
|
||||
|
||||
/// <summary>Glyph colour. A malformed hex falls back to white rather than throwing inside a
|
||||
/// render tick — a bad colour must never kill the output.</summary>
|
||||
public SolidColorBrush TextBrush => Solid(TextColor, 1.0, Colors.White);
|
||||
|
||||
/// <summary>Plate colour carrying the configured opacity in its alpha.</summary>
|
||||
public SolidColorBrush BackgroundBrush
|
||||
=> Solid(BackgroundColor, Math.Clamp(BackgroundOpacity, 0, 1), Colors.Black);
|
||||
|
||||
private static SolidColorBrush Solid(string hex, double opacity, Color fallback)
|
||||
{
|
||||
Color colour;
|
||||
try { colour = (Color)ColorConverter.ConvertFromString(hex); }
|
||||
catch (Exception) { colour = fallback; }
|
||||
var brush = new SolidColorBrush(Color.FromArgb((byte)Math.Round(opacity * 255), colour.R, colour.G, colour.B));
|
||||
brush.Freeze();
|
||||
return brush;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,11 @@ public partial class LayoutStore : IDisposable
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT Id, BarPosition, BarEnabled FROM Socials LIMIT 1;";
|
||||
cmd.CommandText = """
|
||||
SELECT Id, BarPosition, BarEnabled, TextColor, BackgroundEnabled,
|
||||
BackgroundColor, BackgroundOpacity, TextShadow
|
||||
FROM Socials LIMIT 1;
|
||||
""";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
if (reader.Read())
|
||||
{
|
||||
@@ -58,6 +62,11 @@ public partial class LayoutStore : IDisposable
|
||||
{
|
||||
BarPosition = Enum.TryParse<SocialBarPosition>(reader.GetString(1), out var pos) ? pos : SocialBarPosition.Bottom,
|
||||
BarEnabled = reader.FieldCount > 2 && !reader.IsDBNull(2) && reader.GetInt32(2) != 0,
|
||||
BarTextColor = reader.FieldCount > 3 && !reader.IsDBNull(3) ? reader.GetString(3) : "#FFFFFF",
|
||||
BarBackgroundEnabled = reader.FieldCount > 4 && !reader.IsDBNull(4) && reader.GetInt32(4) != 0,
|
||||
BarBackgroundColor = reader.FieldCount > 5 && !reader.IsDBNull(5) ? reader.GetString(5) : "#000000",
|
||||
BarBackgroundOpacity = reader.FieldCount > 6 && !reader.IsDBNull(6) ? reader.GetDouble(6) : 0.55,
|
||||
BarTextShadow = reader.FieldCount > 7 && !reader.IsDBNull(7) && reader.GetInt32(7) != 0,
|
||||
};
|
||||
var socialsId = reader.GetString(0);
|
||||
|
||||
|
||||
@@ -591,11 +591,23 @@ public partial class LayoutStore : IDisposable
|
||||
columns.Add(reader.GetString(1));
|
||||
}
|
||||
|
||||
if (columns.Contains("BarEnabled")) return;
|
||||
// Each column is added on its own, guarded by presence — the same idempotent pattern the
|
||||
// webcam table uses, so a half-migrated DB heals instead of failing.
|
||||
AddColumnIfMissing("BarEnabled", "INTEGER NOT NULL DEFAULT 1");
|
||||
AddColumnIfMissing("TextColor", "TEXT NOT NULL DEFAULT '#FFFFFF'");
|
||||
AddColumnIfMissing("BackgroundEnabled", "INTEGER NOT NULL DEFAULT 0");
|
||||
AddColumnIfMissing("BackgroundColor", "TEXT NOT NULL DEFAULT '#000000'");
|
||||
AddColumnIfMissing("BackgroundOpacity", "REAL NOT NULL DEFAULT 0.55");
|
||||
AddColumnIfMissing("TextShadow", "INTEGER NOT NULL DEFAULT 0");
|
||||
|
||||
using var alter = _connection.CreateCommand();
|
||||
alter.CommandText = "ALTER TABLE Socials ADD COLUMN BarEnabled INTEGER NOT NULL DEFAULT 1;";
|
||||
alter.ExecuteNonQuery();
|
||||
void AddColumnIfMissing(string name, string spec)
|
||||
{
|
||||
if (columns.Contains(name)) return;
|
||||
using var alter = _connection.CreateCommand();
|
||||
alter.CommandText = $"ALTER TABLE Socials ADD COLUMN {name} {spec};";
|
||||
alter.ExecuteNonQuery();
|
||||
columns.Add(name);
|
||||
}
|
||||
}
|
||||
|
||||
// v8 → v9: SocialEntry gains Software (the fediverse instance's nodeinfo
|
||||
|
||||
@@ -314,11 +314,20 @@ public partial class LayoutStore : IDisposable
|
||||
var socialsId = Guid.NewGuid().ToString();
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "INSERT INTO Socials (Id, BarPosition, BarEnabled) VALUES ($id, $pos, $enabled);";
|
||||
cmd.CommandText = """
|
||||
INSERT INTO Socials (Id, BarPosition, BarEnabled, TextColor, BackgroundEnabled,
|
||||
BackgroundColor, BackgroundOpacity, TextShadow)
|
||||
VALUES ($id, $pos, $enabled, $text, $bgen, $bg, $bgopacity, $shadow);
|
||||
""";
|
||||
cmd.Transaction = tx;
|
||||
cmd.Parameters.AddWithValue("$id", socialsId);
|
||||
cmd.Parameters.AddWithValue("$pos", socials.BarPosition.ToString());
|
||||
cmd.Parameters.AddWithValue("$enabled", socials.BarEnabled ? 1 : 0);
|
||||
cmd.Parameters.AddWithValue("$text", socials.BarTextColor);
|
||||
cmd.Parameters.AddWithValue("$bgen", socials.BarBackgroundEnabled ? 1 : 0);
|
||||
cmd.Parameters.AddWithValue("$bg", socials.BarBackgroundColor);
|
||||
cmd.Parameters.AddWithValue("$bgopacity", socials.BarBackgroundOpacity);
|
||||
cmd.Parameters.AddWithValue("$shadow", socials.BarTextShadow ? 1 : 0);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user