feat(about): rebuild the About box as a conversion surface

Creator: the About box was "shitty" — an unreadable logo, a version number nobody asked for, copy that
insulted the creator, and buttons that looked like text. Rebuilt around one job: convert.

- **820×860 modal** (was 650×700) with a scrollable hub, and the logo at **520 px wide** instead of
  125 — the asset is 2816×1536, so it was being drawn as a thumbnail, not an icon problem.
- **Pre-Release pill** replaces "Version 1.0.0" (the assembly version stays in log lines). The pill is a
  button: it opens llamacasty.com until a Steam store page exists, so the link changes in one place.
- Tagline: "1-2-3, and go live to the world on YouTube!" The old "simple enough that even the most
  right-brained person can intuit it" is gone, and a test asserts it stays gone.
- The offer in three short blocks (free 30 fps · paid 60 fps · one-time, lifetime, no account /
  subscription / tracking), then the launch price and **one primary CTA**: "Unlock 60 fps — $79".
- Price: $79 until **April 1, 2027**, then $99 locked for life. The numbers live once, in
  `MainViewModel.Overlays`, and are recorded in `docs/ai/commercial.md`.
- **A paying customer is never sold to**: ownership collapses the price/CTA block and shows
  "Licence: owned via Steam — thank you."
- Secondary destinations (channel, Mastodon, coffee, licences) are one quiet row; footer is
  `© Llama Chile Shop`; the "Made by gramps" line is removed.
- `YtButtonSecondary` now carries a higher surface, a hairline border and a keyboard focus ring — they
  were the same tone as the panel behind them, which is why they read as plain text.

Test: `AboutHubTests.About_ShowsTheOfferToFreeUsers_AndNothingToSellToOwners` — tagline, pill, price,
CTA text, copyright, the removed lines stay removed, and the CTA block collapses when owned.
FAST gate: 540 passed, 0 failed, 0 warnings.
This commit is contained in:
2026-11-05 13:00:57 -08:00
parent 3fde7fcfe6
commit 0de953ef66
7 changed files with 275 additions and 36 deletions
+87
View File
@@ -75,6 +75,69 @@ public sealed class AboutHubTests
}
}
/// <summary>
/// The About box is a conversion surface (creator, 2026-11-05): a large logo, the 1-2-3 tagline,
/// the offer, one primary CTA with the price on it — and a paying customer sees none of the price.
/// </summary>
[Fact]
public void About_ShowsTheOfferToFreeUsers_AndNothingToSellToOwners()
{
_app.Run(() =>
{
var tempDb = Path.Combine(Path.GetTempPath(), $"llamacasty-about-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = tempDb;
MainWindow? window = null;
try
{
window = new MainWindow();
var vm = (MainViewModel)window.DataContext;
vm.EntitlementOverride = new FakeEntitlement(false, "Steam licensing is not configured in this build.");
window.Show();
vm.OpenAboutCommand.Execute(null);
window.UpdateLayout();
var badge = FindVisualChildByName<Button>(window, "ReleaseBadgeButton");
Assert.NotNull(badge);
Assert.Equal("Pre-Release", badge!.Content);
Assert.NotNull(badge.Command); // the badge links out until the Steam page exists
var tagline = FindVisualChildByName<TextBlock>(window, "AboutTagline");
Assert.NotNull(tagline);
Assert.Equal("1-2-3, and go live to the world on YouTube!", tagline!.Text);
// The old line insulted the creator and is gone for good.
Assert.DoesNotContain("right-brained", TextOf(window));
var price = FindVisualChildByName<TextBlock>(window, "AboutPriceLine");
Assert.Equal("Launch price $79", price!.Text);
var cta = FindVisualChildByName<Button>(window, "UpgradeCtaButton");
Assert.NotNull(cta);
Assert.Equal("Unlock 60 fps — $79", cta!.Content);
var upgradeBlock = FindVisualChildByName<StackPanel>(window, "UpgradeBlock");
Assert.Equal(Visibility.Visible, upgradeBlock!.Visibility);
var copyright = FindVisualChildByName<TextBlock>(window, "AboutCopyright");
Assert.Equal("© Llama Chile Shop", copyright!.Text);
Assert.DoesNotContain("Made by gramps", TextOf(window));
// A paying customer is never sold to.
vm.EntitlementOverride = new FakeEntitlement(true);
window.UpdateLayout();
Assert.Equal(Visibility.Collapsed, FindVisualChildByName<StackPanel>(window, "UpgradeBlock")!.Visibility);
var owned = FindVisualChildByName<TextBlock>(window, "OwnedStateText");
Assert.Equal(Visibility.Visible, owned!.Visibility);
Assert.Contains("owned", owned.Text, StringComparison.OrdinalIgnoreCase);
}
finally
{
window?.Close();
MainViewModel.LayoutPathOverride = null;
SqliteConnection.ClearAllPools();
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
}
});
}
[Fact]
public void Free_user_gets_1080p30_and_cannot_select_or_change_to_60fps()
{
@@ -154,6 +217,30 @@ public sealed class AboutHubTests
});
}
private static T? FindVisualChildByName<T>(DependencyObject parent, string name) where T : DependencyObject
{
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
{
var child = VisualTreeHelper.GetChild(parent, i);
if (child is FrameworkElement element && element.Name == name && child is T typed) return typed;
if (FindVisualChildByName<T>(child, name) is { } found) return found;
}
return null;
}
/// <summary>Every TextBlock string in the tree — for asserting that removed copy is really gone.</summary>
private static string TextOf(DependencyObject parent)
{
var text = new System.Text.StringBuilder();
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
{
var child = VisualTreeHelper.GetChild(parent, i);
if (child is TextBlock block) text.Append(block.Text).Append('\n');
text.Append(TextOf(child));
}
return text.ToString();
}
private static T? FindVisualChild<T>(DependencyObject parent) where T : DependencyObject
{
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)