diff --git a/Models/ScoreboardLayout.cs b/Models/ScoreboardLayout.cs
new file mode 100644
index 0000000..368eefa
--- /dev/null
+++ b/Models/ScoreboardLayout.cs
@@ -0,0 +1,89 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace ytLive.Models;
+
+/// Which edge a full-width bar occupies.
+public enum BarEdge
+{
+ Top,
+ Bottom,
+}
+
+/// The scoreboard's placement and line-fitting arithmetic, kept pure so both are testable
+/// without a renderer.
+///
+/// Two rules from the creator (2026-10-30):
+/// the strip and the social bar are a COUPLED pair that own opposite edges, resolved at render time
+/// from what is actually visible; and a strip that does not fit SHRINKS to a floor and then wraps to
+/// a second line — it never scrolls, because a creator reading the screen cannot wait for the fact
+/// to come round.
+public static class ScoreboardLayout
+{
+ public const double FloorFontSize = 14;
+ public const double MaxFontSize = 24;
+
+ /// Result of fitting the enabled tiles into one strip.
+ public readonly record struct Fit(double FontSize, int LineCount, bool Shrunk)
+ {
+ public bool Wrapped => LineCount > 1;
+ }
+
+ /// Resolves the edge pair. When both bars are visible they take opposite edges; the
+ /// social bar keeps its own choice and the scoreboard is pushed to the other one. When either is
+ /// hidden the survivor keeps its preferred edge — so hiding the scoreboard frees the social bar
+ /// to sit anywhere, exactly as the creator asked.
+ public static (BarEdge Social, BarEdge Scoreboard) ResolveEdges(
+ bool socialVisible, BarEdge socialPreferred,
+ bool scoreboardVisible, BarEdge scoreboardPreferred)
+ {
+ if (socialVisible && scoreboardVisible)
+ return (socialPreferred, Opposite(socialPreferred));
+
+ return (socialPreferred, scoreboardPreferred);
+ }
+
+ public static BarEdge Opposite(BarEdge edge) => edge == BarEdge.Top ? BarEdge.Bottom : BarEdge.Top;
+
+ /// Fits measured tile widths (at ) into the available
+ /// width: full size if they fit, otherwise shrink, and only if the shrink would drop below the
+ /// legibility floor does it wrap. Widths are passed in because the renderer MEASURES them —
+ /// guessing a character width would make the fit lie for a wide glyph or a long handle.
+ public static Fit FitLine(IReadOnlyList tileWidths, double separatorWidth,
+ double availableWidth, double requestedSize = MaxFontSize, double floorSize = FloorFontSize)
+ {
+ var count = tileWidths?.Count ?? 0;
+ if (count == 0 || availableWidth <= 0)
+ return new Fit(requestedSize, 1, false);
+
+ var total = tileWidths!.Sum() + separatorWidth * (count - 1);
+ if (total <= availableWidth)
+ return new Fit(requestedSize, 1, false);
+
+ var scaled = requestedSize * (availableWidth / total);
+ if (scaled >= floorSize)
+ return new Fit(scaled, 1, true);
+
+ // Below the floor: keep the floor and wrap. Greedy packing, measured widths scaled to the
+ // floor size so the line breaks match what is actually drawn.
+ var factor = floorSize / requestedSize;
+ var lines = 1;
+ var lineWidth = 0.0;
+ foreach (var width in tileWidths)
+ {
+ var w = width * factor;
+ var needed = lineWidth == 0 ? w : lineWidth + separatorWidth + w;
+ if (needed > availableWidth && lineWidth > 0)
+ {
+ lines++;
+ lineWidth = w;
+ }
+ else
+ {
+ lineWidth = needed;
+ }
+ }
+ return new Fit(floorSize, lines, true);
+ }
+}
diff --git a/ytLive.Tests/ScoreboardLayoutTests.cs b/ytLive.Tests/ScoreboardLayoutTests.cs
new file mode 100644
index 0000000..8e99e2a
--- /dev/null
+++ b/ytLive.Tests/ScoreboardLayoutTests.cs
@@ -0,0 +1,50 @@
+using Xunit;
+using ytLive.Models;
+
+namespace ytLive.Tests;
+
+/// Two creator rulings live here: the strip and the social bar own OPPOSITE edges (coupled,
+/// resolved from what is visible), and a strip that does not fit shrinks to a legibility floor and
+/// then wraps — it never scrolls.
+public class ScoreboardLayoutTests
+{
+ [Fact]
+ public void Bars_never_share_an_edge_and_an_overlong_strip_shrinks_then_wraps()
+ {
+ // Both visible: opposite edges, whatever the scoreboard asked for.
+ Assert.Equal((BarEdge.Top, BarEdge.Bottom),
+ ScoreboardLayout.ResolveEdges(true, BarEdge.Top, true, BarEdge.Top));
+ Assert.Equal((BarEdge.Bottom, BarEdge.Top),
+ ScoreboardLayout.ResolveEdges(true, BarEdge.Bottom, true, BarEdge.Bottom));
+
+ // Social bar hidden: the scoreboard keeps its own preference.
+ Assert.Equal((BarEdge.Top, BarEdge.Bottom),
+ ScoreboardLayout.ResolveEdges(false, BarEdge.Top, true, BarEdge.Bottom));
+
+ // Scoreboard hidden: the social bar is free.
+ Assert.Equal((BarEdge.Bottom, BarEdge.Top),
+ ScoreboardLayout.ResolveEdges(true, BarEdge.Bottom, false, BarEdge.Top));
+
+ // Fits at full size.
+ var fit = ScoreboardLayout.FitLine(new[] { 200.0, 200.0, 200.0 }, 40, 1920);
+ Assert.Equal(ScoreboardLayout.MaxFontSize, fit.FontSize);
+ Assert.Equal(1, fit.LineCount);
+ Assert.False(fit.Shrunk);
+
+ // Too wide for full size, but a shrink keeps it on one line.
+ fit = ScoreboardLayout.FitLine(new[] { 400.0, 400.0, 400.0 }, 40, 900);
+ Assert.True(fit.FontSize < ScoreboardLayout.MaxFontSize);
+ Assert.True(fit.FontSize >= ScoreboardLayout.FloorFontSize);
+ Assert.Equal(1, fit.LineCount);
+ Assert.True(fit.Shrunk);
+
+ // Far too wide even at the floor: two lines, never a scroll.
+ fit = ScoreboardLayout.FitLine(new[] { 600.0, 600.0, 600.0, 600.0 }, 40, 700);
+ Assert.Equal(ScoreboardLayout.FloorFontSize, fit.FontSize);
+ Assert.True(fit.LineCount > 1);
+ Assert.True(fit.Wrapped);
+
+ // Nothing enabled: no crash, no lines invented.
+ Assert.Equal(1, ScoreboardLayout.FitLine(System.Array.Empty(), 40, 1920).LineCount);
+ }
+}