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:
2026-10-30 08:47:50 -07:00
parent 9528128ca7
commit c82636e1a9
9 changed files with 329 additions and 16 deletions
+72 -4
View File
@@ -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;
}
}