diff --git a/CLAUDE.md b/CLAUDE.md
index 20ee8ad..9e549e1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -511,6 +511,9 @@ python3 tools/ansi_frame_to_image.py frame.ansi frame.html # or .svg
wearing it, an armed tinted band over an idle tinted one, and the colour travelling with the focus.
The moved frame is also the one that shows a bar wearing a character's hue while its prompt reads
`no connection ›`, which is the composition rule stated in paint: hue says whose, not whether),
+ `tint-tabs` (the same two tinted characters with **no split**, so one pane holds both their windows as
+ tabs — the only geometry where an idle chip can be seen wearing a colour the pane behind it is not,
+ which is the whole of the per-tab chip),
`deletions`,
`compose`/`compose-literal` (the F1 composer in each of its two escaping modes — the pair exists
because ⌥L changes what is *sent* and only the header says which way it is set; the demo has no
@@ -877,6 +880,24 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/]
**Not fixed here**: the `│` between chips and the `─` filling the rest of the strip are hardcoded
`Color.Grey` in `TabControl.Rendering.cs`, unthemeable in all three header styles. That is an upstream
change.
+- **An idle chip wears its *own* character's colour, and that has to be said in the title** (`TabChip`,
+ `SharpMUTermApp.ChipFor`). `TabControl`'s four chip properties belong to the **control**, so every
+ unselected chip in a strip is one colour — and a pane can host several characters' windows as tabs while
+ painting one rectangle, so that one colour is necessarily the *front* window's. The other characters'
+ tabs were therefore drawn in a hue that named the wrong person, which is the one thing the tint exists
+ to prevent. The per-tab channel is the title, because a tag there costs no cells (the entry above), so
+ the chip goes out as `[{ink} on {plane}]` around the same span the bold and the activity tint already
+ cover. Three things hold it together. It runs the **same pipeline as the plane behind it** —
+ `SurfaceToneIn` (tint, then the focus lift if the pane holds focus) then `Recessed` — so a strip whose
+ tabs share an owner is byte-identical to what it was before this existed and only the mixed pane
+ changes; `PaneSurfaceTone` is now one line of that same function, rather than a second arithmetic that
+ would drift. The **selected** tab is never chipped: it is the strip's own colour, which is already its
+ page's plane. And unread stays a **foreground** over the chip, so "whose" and "something new" remain the
+ two channels they are everywhere else. The framework pads each title with one space either side
+ (`" {Title} "`, outside our markup), so the colour hugs the text and the pad keeps the strip's tone.
+ `PaneTintTests.ABackgroundTabWearsItsOwnCharactersColour` is the pin and the `tint-tabs` view is the
+ frame — the old code was internally consistent and the screen was still wrong, so only a painted cell
+ answers this.
- **One unread count, one spelling: `UnreadBadge`.** The sidebar and the tab strip are two views of
`WorkspaceWindow.Unread`, and they had two formatters — the rail capped at `99+`, the tab printed the
raw integer, so a busy channel read `99+` in one place and `(4127)` in the other. Cap, field width and
diff --git a/src/SharpMUTerm.Core/Workspaces/RailModel.cs b/src/SharpMUTerm.Core/Workspaces/RailModel.cs
index 14901ad..c1f128f 100644
--- a/src/SharpMUTerm.Core/Workspaces/RailModel.cs
+++ b/src/SharpMUTerm.Core/Workspaces/RailModel.cs
@@ -107,6 +107,12 @@ public sealed record RailWindow(string Title, string Id, string? Chord, int Unre
/// filter exists.
///
///
+/// Indent is one level per depth — world 0, character 1, window 2 — and the view spends two cells
+/// on each. Characters used to sit at 2, reserving a level nothing was ever drawn at and pushing every
+/// window row two cells right; the sidebar's width is its widest row and comes out of the panes, which
+/// every connected session is told over NAWS, so a skipped level is columns spent saying nothing.
+///
+///
/// Every row that names somewhere you can go also carries the a click
/// dispatches. The ids are the shell's own (), so the rail is a second door
/// onto the ⌃P surface's actions rather than a second implementation of switching.
@@ -137,7 +143,7 @@ public static IReadOnlyList Build(IReadOnlyList worlds)
if (world.Characters.Count == 0)
{
rows.Add(new RailRow(
- RailRowKind.Empty, 2, "no characters", Target: NoCharactersTarget(world.Name)));
+ RailRowKind.Empty, 1, "no characters", Target: NoCharactersTarget(world.Name)));
continue;
}
@@ -145,7 +151,7 @@ public static IReadOnlyList Build(IReadOnlyList worlds)
{
rows.Add(new RailRow(
RailRowKind.Character,
- 2,
+ 1,
character.Name,
Accent: world.Accent,
Active: character.Active,
@@ -163,7 +169,7 @@ public static IReadOnlyList Build(IReadOnlyList worlds)
{
rows.Add(new RailRow(
RailRowKind.Window,
- 3,
+ 2,
window.Title,
Accent: world.Accent,
Unsent: window.HasUnsent,
diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.cs
index a9ac5b6..203d373 100644
--- a/src/SharpMUTerm.Tui/SharpMUTermApp.cs
+++ b/src/SharpMUTerm.Tui/SharpMUTermApp.cs
@@ -1418,7 +1418,22 @@ public string RenderSnapshot(string? view = null)
SwitchToCharacter($"{tinted[1].Name}.{tinted[1].Characters[0].Name}");
}
- PaneCommands.Apply(_workspace.Layout, PaneCommand.SplitRight);
+ // `tint-tabs` is the same two characters with no split — the one geometry where a pane holds
+ // two characters' windows, which is the only place an idle chip can be seen wearing a colour
+ // the pane behind it is not. Switching back brings the first character's window to the front,
+ // so the frame carries a selected chip beside an idle one in the other character's hue.
+ if (string.Equals(view, "tint-tabs", StringComparison.OrdinalIgnoreCase))
+ {
+ if (tinted.Count > 0)
+ {
+ SwitchToCharacter($"{tinted[0].Name}.{tinted[0].Characters[0].Name}");
+ }
+ }
+ else
+ {
+ PaneCommands.Apply(_workspace.Layout, PaneCommand.SplitRight);
+ }
+
RebuildPaneArea();
if (view.StartsWith("tint-input", StringComparison.OrdinalIgnoreCase))
@@ -7841,12 +7856,43 @@ private IWindowControl OnSurface(IWindowControl content, string? paneId = null)
/// tinted pane is exactly as visibly focused as an untinted one and a focused pane still says whose
/// it is. See .
///
- private Rgb PaneSurfaceTone(string? paneId)
+ private Rgb PaneSurfaceTone(string? paneId) => SurfaceToneIn(paneId, PaneTintOf(paneId));
+
+ ///
+ /// The tone comes out as in — the character's
+ /// plane, lit if that pane holds the focus. Split out from so a tab
+ /// chip can ask the question for a character who is not the pane's occupant and get an
+ /// answer on the same two channels; a second arithmetic would drift from the plane beside it.
+ ///
+ private Rgb SurfaceToneIn(string? paneId, PaneTint tint)
{
- var plane = PanePlane(paneId);
+ var plane = WorkspacePalette.Tint(_theme, tint);
return paneId is not null && IsFocusedPane(paneId) ? WorkspacePalette.Focus(plane) : plane;
}
+ ///
+ /// The chip an unselected tab is painted on: its own character's plane, recessed the same
+ /// step every idle chip has always been recessed by.
+ ///
+ /// Per tab, because the framework cannot say it. TabControl's four colour properties
+ /// belong to the control, so every unselected chip in a strip is one colour — the front window's,
+ /// recessed — and a pane holding two characters' windows painted the other one's tab in the wrong
+ /// character's hue. The per-tab channel is the title, which is markup and costs no cells.
+ ///
+ ///
+ /// It runs the same pipeline as the plane behind it ( then
+ /// ), so a strip whose tabs share an owner comes out byte for
+ /// byte what it was before this existed, and only the mixed pane changes. A window nobody owns — the
+ /// web view — and a character who has chosen no colour both come out on the plain surface, which is
+ /// the honest answer rather than borrowing the neighbour's hue.
+ ///
+ ///
+ private TabChip ChipFor(string paneId, WorkspaceWindow window)
+ {
+ var plane = WorkspacePalette.Recessed(SurfaceToneIn(paneId, TintOf(window.SessionKey)));
+ return new TabChip(plane.ToHex(), ChromeInk.On(WorkspacePalette.IdleInk(_theme), plane));
+ }
+
///
/// A pane's unfocused plane — the tint of the character whose window is in front of it, or
/// the plain surface when that window has no owner or its character has chosen no colour.
@@ -7923,8 +7969,12 @@ private List RenderRailLines()
/// The widest it goes, so one long world or window name cannot run away with the layout.
private const int RailMaxWidth = 44;
- /// Breathing room between the widest row and the divider beside it.
- private const int RailMargin = 2;
+ ///
+ /// Breathing room between the widest row and the divider beside it. One cell, not two: the divider
+ /// column and the _railSpacer beside it (see ) already separate
+ /// the rail from the panes, so a second margin cell is a column taken off every pane to no effect.
+ ///
+ private const int RailMargin = 1;
///
/// The rail column width: the widest row's visible width plus a small margin, clamped. Collapsed, it
@@ -8101,7 +8151,8 @@ private IWindowControl BuildPaneTabs(PaneNode pane)
// terminal, where a luminance step does not.
var selected = string.Equals(pane.ActiveTab, windowId, StringComparison.Ordinal);
builder.AddTab(
- TabTitles.For(window, ActiveCharacterKey(), focused && selected, selected, _ink),
+ TabTitles.For(
+ window, ActiveCharacterKey(), focused && selected, selected, _ink, ChipFor(pane.Id, window)),
BuildTabContent(pane, windowId, window));
ids.Add(windowId);
}
@@ -9923,7 +9974,8 @@ private void RefreshTabTitles()
{
var selected = string.Equals(activeTab, id, StringComparison.Ordinal);
page.Title = TabTitles.For(
- window, focusedCharacter, IsFocusedPane(paneId) && selected, selected, _ink);
+ window, focusedCharacter, IsFocusedPane(paneId) && selected, selected, _ink,
+ ChipFor(paneId, window));
// The × follows the active tab, so keep it in step with every title refresh.
page.IsClosable = CanCloseTab(id, activeTab);
}
diff --git a/src/SharpMUTerm.Tui/TabTitles.cs b/src/SharpMUTerm.Tui/TabTitles.cs
index 3bfa1a3..1047096 100644
--- a/src/SharpMUTerm.Tui/TabTitles.cs
+++ b/src/SharpMUTerm.Tui/TabTitles.cs
@@ -17,6 +17,14 @@ namespace SharpMUTerm.Tui;
/// The close affordance is not here. A ✕ in the label is just text the hit test
/// reads as part of the title; the real button is TabPage.IsClosable.
///
+///
+/// The plane an idle tab's chip is painted on and the ink that lands on it, as #rrggbb markup
+/// tokens. It exists because TabControl's four chip colours are properties of the control
+/// — one answer for every unselected tab in a strip — so a tab that wants to say whose window it is has
+/// to say it in the only per-tab channel there is: its title, which is markup.
+///
+internal readonly record struct TabChip(string Plane, string Ink);
+
internal static class TabTitles
{
/// The window the tab stands for.
@@ -32,12 +40,18 @@ internal static class TabTitles
/// survives a flattened palette. Independent of : an unfocused pane
/// still has a tab in front of it.
///
+ ///
+ /// The plane this tab is drawn on when it is not the one its pane is showing, so a pane
+ /// holding two characters' windows says whose each background tab is. Ignored on the selected tab,
+ /// which the strip paints in its page's own plane.
+ ///
public static string For(
WorkspaceWindow window,
string? focusedCharacterKey = null,
bool focusedPane = false,
bool selected = false,
- ChromeInk? ink = null)
+ ChromeInk? ink = null,
+ TabChip? chip = null)
{
ArgumentNullException.ThrowIfNull(window);
@@ -71,6 +85,14 @@ public static string For(
(false, true) => UnreadBadge.TintFor(ink),
_ => null,
};
+
+ // The chip is the tab's own plane, which only an unselected tab carries — the selected one is
+ // painted by the strip, in the plane its page is on. Foreground first so an unread tab keeps its
+ // accent: the plane says whose window this is, the accent says it has something new.
+ if (chip is { } tile && !selected)
+ {
+ style = $"{style ?? tile.Ink} on {tile.Plane}";
+ }
var body = style is null ? named : $"[{style}]{named}[/]";
return focus + body + pen + cross;
diff --git a/tests/SharpMUTerm.Core.Tests/Workspace/RailModelTests.cs b/tests/SharpMUTerm.Core.Tests/Workspace/RailModelTests.cs
index 7586bf8..6b35626 100644
--- a/tests/SharpMUTerm.Core.Tests/Workspace/RailModelTests.cs
+++ b/tests/SharpMUTerm.Core.Tests/Workspace/RailModelTests.cs
@@ -209,6 +209,43 @@ await Assert.That(row.Target is null).IsEqualTo(expected)
}
}
+ ///
+ /// The indent ladder skips no level. Every cell of indent is width the sidebar takes out of the
+ /// panes — and the sidebar's width is announced to every connected server over NAWS — so a depth
+ /// nothing is ever drawn at is columns spent saying nothing. Characters sat at 2 under a world at 0,
+ /// which reserved a level 1 that no row has ever used and pushed every window row two cells right.
+ ///
+ [Test]
+ public async Task TheIndentLadderSkipsNoLevel()
+ {
+ var rows = RailModel.Build(new[] { TwoCharacterWorld(), new RailWorld("Empties", "h", 1, Accent, Array.Empty()) });
+
+ var previous = 0;
+ foreach (var row in rows)
+ {
+ await Assert.That(row.Indent).IsLessThanOrEqualTo(previous + 1)
+ .Because($"a {row.Kind} row ('{row.Label}') at indent {row.Indent} follows one at {previous}");
+ previous = row.Indent;
+ }
+
+ await Assert.That(rows.Max(r => r.Indent)).IsEqualTo(2); // world → character → window, and no more
+
+ // The ladder above is monotonic and could still be nonsense — an empty row at 0 satisfies it — so
+ // each kind is held to its own depth. An Empty row stands where a character would, and takes a
+ // character's indent.
+ foreach (var row in rows)
+ {
+ var depth = row.Kind switch
+ {
+ RailRowKind.Header or RailRowKind.World => 0,
+ RailRowKind.Character or RailRowKind.Empty or RailRowKind.Host => 1,
+ _ => 2,
+ };
+
+ await Assert.That(row.Indent).IsEqualTo(depth).Because($"{row.Kind} ('{row.Label}')");
+ }
+ }
+
private static RailWorld TwoCharacterWorld() => new("Aetherfall", "aetherfall.mux", 4201, Accent, new[]
{
new RailCharacter("Corvid", "Aetherfall.Corvid", Connected: true, Active: true, Unread: 3, new[]
diff --git a/tests/SharpMUTerm.Tui.Tests/PaneTintTests.cs b/tests/SharpMUTerm.Tui.Tests/PaneTintTests.cs
index 29dbe2e..a9bf1d8 100644
--- a/tests/SharpMUTerm.Tui.Tests/PaneTintTests.cs
+++ b/tests/SharpMUTerm.Tui.Tests/PaneTintTests.cs
@@ -132,6 +132,85 @@ await Assert.That(FrameGrid.CellsPaintedIn(ansi, rects[paneId], Colour(expected)
await Assert.That(app.PaneSurfaceColors.Values.Distinct().Count()).IsEqualTo(2);
}
+ ///
+ /// A background tab wears the colour of the character whose window it is, not of the pane it sits
+ /// in. A pane can host several characters' windows and paints one rectangle, so the plane behind
+ /// the strip can only ever answer for the window in front; the chips are where the rest of them get
+ /// to say whose they are.
+ ///
+ /// It has to be a frame test. TabControl's four chip colours belong to the control, so the
+ /// old code was internally consistent — every unselected chip really was the colour it had been
+ /// told to be — and the screen was still wrong. The per-tab colour rides the title's markup, which
+ /// only a painted cell can confirm arrived.
+ ///
+ ///
+ [Test]
+ public async Task ABackgroundTabWearsItsOwnCharactersColour()
+ {
+ var config = DemoScene.Build();
+ var app = App(config);
+
+ // `tint-tabs` is the one geometry that can ask this: the same two tinted characters as `tint`,
+ // with no split, so both their windows are tabs of a single pane.
+ var ansi = app.RenderSnapshot("tint-tabs");
+ var cells = FrameGrid.Cells(ansi, 120, 34);
+ var rows = FrameGrid.Decode(ansi, 120, 34);
+
+ // The pane is the focused one (it is the only one), so both chips are recessed from a lit plane.
+ var lit = (PaneTint tint) =>
+ WorkspacePalette.Recessed(WorkspacePalette.Focus(WorkspacePalette.Tint(config.Theme, tint)));
+ var other = config.Worlds[1].Characters[0].Name; // Moss, and the second world's own character
+
+ var strip = rows.Select((text, y) => (text, y)).First(r => r.text.Contains(other, StringComparison.Ordinal));
+ var mine = strip.text.IndexOf("Chat", StringComparison.Ordinal); // Corvid's spawn window, idle
+ var theirs = strip.text.IndexOf(other, StringComparison.Ordinal); // the other character's, idle
+ await Assert.That(mine).IsGreaterThan(0);
+
+ await Assert.That(cells[(strip.y, theirs)].Background).IsEqualTo(lit(PaneTint.Moss));
+ await Assert.That(cells[(strip.y, mine)].Background).IsEqualTo(lit(PaneTint.Slate));
+
+ // And they are genuinely two colours, or the frame proves nothing.
+ await Assert.That(cells[(strip.y, theirs)].Background).IsNotEqualTo(cells[(strip.y, mine)].Background);
+ }
+
+ ///
+ /// The other half of the chip's rule: a tab nobody owns stays on the plain surface rather than
+ /// borrowing the hue of whatever is in front of it. The web view is the reachable case — it belongs to
+ /// no character — and it is the one that would make the cue lie, since a colour there would name a
+ /// character whose window it is not.
+ ///
+ [Test]
+ public async Task AnUnownedBackgroundTabBorrowsNobodysColour()
+ {
+ var config = DemoScene.Build();
+ Corvid(config).Tint = PaneTint.Slate;
+ var app = App(config);
+
+ // The `web` view opens the web window into the pane and brings it forward; putting the character's
+ // own window back in front leaves the unowned one as the background tab this is about. The page is
+ // retitled first only so the tab carries a token nothing else on the frame does.
+ app.RenderSnapshot("web");
+ app.SimulateWebPageTitled("Atlas");
+ await Assert.That(app.DispatchCommand("win:main")).IsTrue();
+ var ansi = app.RenderWholeFrame(); // a second render over a populated buffer emits only a delta
+
+ var rows = FrameGrid.Decode(ansi, 120, 34);
+ var cells = FrameGrid.Cells(ansi, 120, 34);
+
+ // Past the sidebar, so the rail's own row for that window cannot be mistaken for the tab.
+ var strip = rows.Select((text, y) => (text, y))
+ .First(r => r.text.IndexOf("Atlas", StringComparison.Ordinal) > app.RailColumnWidth);
+
+ var untinted = WorkspacePalette.Recessed(WorkspacePalette.Focus(WorkspacePalette.Surface(config.Theme)));
+ var web = strip.text.IndexOf("Atlas", StringComparison.Ordinal);
+
+ await Assert.That(cells[(strip.y, web)].Background).IsEqualTo(untinted);
+
+ // And the pane behind that chip really is wearing a colour, or the frame asks nothing at all.
+ await Assert.That(app.PaneSurfaceColors[app.FocusedPaneId])
+ .IsEqualTo(Colour(WorkspacePalette.Focus(WorkspacePalette.Tint(config.Theme, PaneTint.Slate))));
+ }
+
// --- composing with focus ---------------------------------------------------------------------
///
diff --git a/tests/SharpMUTerm.Tui.Tests/RailRendererTests.cs b/tests/SharpMUTerm.Tui.Tests/RailRendererTests.cs
index d4e62cf..93d2611 100644
--- a/tests/SharpMUTerm.Tui.Tests/RailRendererTests.cs
+++ b/tests/SharpMUTerm.Tui.Tests/RailRendererTests.cs
@@ -185,7 +185,7 @@ public async Task Render_EscapesBracketsInsideTheLinkTarget()
var line = RailRenderer.Render(rows).Single(l => l.Contains("no characters"));
await Assert.That(line).Contains("[link=rail:no-characters:Od%5Dd]");
- await Assert.That(SharpMUTermApp.MarkupWidth(line)).IsEqualTo(" no characters".Length);
+ await Assert.That(SharpMUTermApp.MarkupWidth(line)).IsEqualTo(" no characters".Length);
}
// --- the width trap: nothing volatile may cost a cell ------------------------------------------
diff --git a/tests/SharpMUTerm.Tui.Tests/RailWindowRowTests.cs b/tests/SharpMUTerm.Tui.Tests/RailWindowRowTests.cs
index c870098..5e2331f 100644
--- a/tests/SharpMUTerm.Tui.Tests/RailWindowRowTests.cs
+++ b/tests/SharpMUTerm.Tui.Tests/RailWindowRowTests.cs
@@ -241,6 +241,32 @@ public async Task ALabelLongerThanTheRailIsElidedRatherThanWrapped()
}
}
+ ///
+ /// The sidebar spends at most one blank column past its widest row. The other half of the width
+ /// invariant: the rail must be wide enough for its rows (above) and no wider, because everything it
+ /// keeps is taken off the panes. One column and not two — a divider and a one-cell spacer already stand
+ /// between the rail's last cell and the first pane, so a second margin cell separates nothing from
+ /// nothing. Asserted on the demo, whose widest row is comfortably past RailMinWidth, so what is
+ /// being read is the margin rather than the floor.
+ ///
+ [Test]
+ public async Task TheRailSpendsAtMostOneBlankColumnPastItsWidestRow()
+ {
+ var app = App();
+ app.RenderSnapshot();
+
+ var widest = Rail(app).Max(SharpMUTermApp.MarkupWidth);
+ await Assert.That(widest).IsGreaterThan(16); // else the floor is what is being measured
+ await Assert.That(app.RailColumnWidth).IsEqualTo(widest + 1);
+
+ // And the layout spent exactly that. The line above is arithmetic over the rows and would still
+ // pass if nothing applied the answer, so the claim is closed against the arranged pane rectangle:
+ // a pane starts past the rail, its divider and the one-cell spacer beside it
+ // (SharpMUTermApp.BuildWorkspaceRow), which is also the geometry per-pane NAWS is derived from.
+ var rect = app.PaneOutputRects()[app.FocusedPaneId];
+ await Assert.That(rect.X).IsEqualTo(app.RailColumnWidth + 2);
+ }
+
///
/// Shortening the rows narrows the sidebar again, rather than leaving it as wide as the widest thing it
/// ever held: the columns the rail gives back are columns the panes get, and per-pane NAWS is derived
diff --git a/tests/SharpMUTerm.Tui.Tests/TabTitlesTests.cs b/tests/SharpMUTerm.Tui.Tests/TabTitlesTests.cs
index 1094998..25ca6b2 100644
--- a/tests/SharpMUTerm.Tui.Tests/TabTitlesTests.cs
+++ b/tests/SharpMUTerm.Tui.Tests/TabTitlesTests.cs
@@ -151,6 +151,47 @@ public async Task DifferentCharacter_AppendsACrossMarker()
await Assert.That(label).IsEqualTo("pages ⌁");
}
+ // ---- the idle chip -----------------------------------------------------------------------
+ //
+ // A strip's unselected chips are one colour to TabControl, so a tab that wants to name its own
+ // character has to do it in its title. These pin the shape of that markup and, more importantly,
+ // that it stays free: the title is measured by MarkupParser.StripLength everywhere the strip is
+ // laid out or hit-tested, so a tag here must move nothing.
+
+ private static readonly TabChip Chip = new("#101418", "#9aa5b1");
+
+ [Test]
+ public async Task IdleTab_WearsTheChipItIsHanded()
+ {
+ var window = new WorkspaceWindow("w1", "Chat", WindowKind.Spawn, sessionKey: "Aetherfall.Rookery");
+ await Assert.That(TabTitles.For(window, chip: Chip)).IsEqualTo("[#9aa5b1 on #101418]Chat[/]");
+ }
+
+ ///
+ /// Unread stays a foreground on a chipped tab: the plane says whose window it is and the
+ /// accent says it has something new, which are two facts on two channels. Writing the plane over the
+ /// activity tint would have made a background tab's colour mean either.
+ ///
+ [Test]
+ public async Task AnUnreadIdleTabKeepsItsActivityTintOverTheChip()
+ {
+ var chat = Background("Chat", 2);
+ await Assert.That(TabTitles.For(chat, chip: Chip))
+ .IsEqualTo($"[{UnreadBadge.TintFor(null)} on #101418]Chat (2)[/]");
+ }
+
+ ///
+ /// The invariant the strip's geometry rests on. Every width a tab is measured by is
+ /// StripLength, so a chip costs no cells and moves no click target.
+ ///
+ [Test]
+ public async Task AChipCostsNoCells()
+ {
+ var chat = Background("Chat", 3);
+ await Assert.That(MarkupParser.StripLength(TabTitles.For(chat, chip: Chip)))
+ .IsEqualTo(MarkupParser.StripLength(TabTitles.For(chat)));
+ }
+
[Test]
public async Task SameCharacter_HasNoCrossMarker()
{