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
+84 -35
View File
@@ -15,7 +15,7 @@
</Button.Template>
</Button>
<Border Background="{StaticResource Brush.Background}" CornerRadius="8" Width="650" MaxHeight="700"
<Border Background="{StaticResource Brush.Background}" CornerRadius="8" Width="820" MaxHeight="860"
VerticalAlignment="Center" HorizontalAlignment="Center"
Padding="28" BorderBrush="#333" BorderThickness="1">
<Grid>
@@ -76,42 +76,91 @@
Background="{StaticResource Brush.SurfaceContainerLow}"
Visibility="{Binding IsAboutOpen, Converter={StaticResource BoolToVis}}">
<!-- Hub view -->
<StackPanel Visibility="{Binding IsHubVisible, Converter={StaticResource BoolToVis}}">
<Image Source="/LlamaCasty;component/Assets/llamacasty-logo.jpg" Height="125" Stretch="Uniform"
HorizontalAlignment="Center" Margin="0,0,0,12"/>
<TextBlock Text="{Binding AppVersionLabel}" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12"
HorizontalAlignment="Center" Margin="0,4,0,0"/>
<TextBlock Text="Live streaming for YouTube — simple enough that even the most right-brained person can intuit it."
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="13" TextWrapping="Wrap" MaxWidth="380"
TextAlignment="Center" Margin="0,16,0,0"/>
<TextBlock x:Name="UpgradeCopy"
Text="Free at 720p30. The paid version unlocks higher stream and recording quality. One-time purchase; no subscription. Launch price $79, then $99 on the announced date."
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12"
TextWrapping="Wrap" MaxWidth="380" TextAlignment="Center" Margin="0,12,0,0"/>
<Button x:Name="UpgradeLinkButton" Content="Learn more at llamacasty.com"
Command="{Binding OpenPremiumCommand}" Style="{StaticResource YtButtonSecondary}"
HorizontalAlignment="Center" Margin="0,12,0,0"
ToolTip="Opens llamacasty.com in your browser when clicked"/>
<TextBlock Text="Made by gramps · llama chile shop" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12"
HorizontalAlignment="Center" Margin="0,16,0,8"/>
<!-- Hub view — a conversion surface, not a credits page (creator, 2026-11-05):
large logo, one tagline, the offer in three short blocks, one primary CTA,
everything else demoted. A paying customer never sees the price. -->
<ScrollViewer VerticalScrollBarVisibility="Auto"
Visibility="{Binding IsHubVisible, Converter={StaticResource BoolToVis}}">
<StackPanel>
<Image Source="/LlamaCasty;component/Assets/llamacasty-logo.jpg"
Width="520" Stretch="Uniform" HorizontalAlignment="Center" Margin="0,0,0,10"/>
<!-- Creator hub links -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,0,0,8">
<Button Content="llama chile shop on YouTube" Command="{Binding OpenChannelCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
<Button Content="Mastodon" Command="{Binding OpenMastodonCommand}"
Style="{StaticResource YtButtonSecondary}"/>
</StackPanel>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<Button Content="Buy me a coffee" Command="{Binding OpenCoffeeCommand}"
Style="{StaticResource YtButtonSecondary}"/>
</StackPanel>
<Button x:Name="ReleaseBadgeButton" Content="{Binding AppReleaseLabel}"
Style="{StaticResource PillBadgeButton}"
Command="{Binding OpenPremiumCommand}"
HorizontalAlignment="Center" Margin="0,0,0,10"
ToolTip="Pre-release — opens llamacasty.com when clicked"/>
<Button Content="Licenses &amp; legal" Command="{Binding OpenLicensesCommand}"
Style="{StaticResource YtButtonSecondary}" HorizontalAlignment="Center"
Margin="0,16,0,0"/>
</StackPanel>
<TextBlock x:Name="AboutTagline"
Text="1-2-3, and go live to the world on YouTube!"
Foreground="{StaticResource Brush.OnSurface}" FontSize="17" FontWeight="SemiBold"
TextWrapping="Wrap" MaxWidth="640" TextAlignment="Center"
HorizontalAlignment="Center" Margin="0,4,0,18"/>
<Grid MaxWidth="640" HorizontalAlignment="Center" Margin="0,0,0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Free" Margin="0,0,14,4"
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="13" FontWeight="SemiBold"/>
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding FreeOfferText}" Margin="0,0,0,4"
Foreground="{StaticResource Brush.OnSurface}" FontSize="13" TextWrapping="Wrap"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Paid" Margin="0,0,14,4"
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="13" FontWeight="SemiBold"/>
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding PaidOfferText}" Margin="0,0,0,4"
Foreground="{StaticResource Brush.OnSurface}" FontSize="13" TextWrapping="Wrap"/>
<TextBlock Grid.Row="2" Grid.ColumnSpan="2" Text="{Binding TrustLine}"
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12"
TextWrapping="Wrap" Margin="0,6,0,0"/>
</Grid>
<!-- Free: the offer. Owned: thanks, no price. -->
<StackPanel x:Name="UpgradeBlock"
Visibility="{Binding IsAppOwned, Converter={StaticResource InverseBoolToVis}}"
HorizontalAlignment="Center" Margin="0,18,0,0">
<TextBlock x:Name="AboutPriceLine" Text="{Binding PriceLine}"
Foreground="{StaticResource Brush.OnSurface}" FontSize="16" FontWeight="Bold"
HorizontalAlignment="Center"/>
<TextBlock Text="{Binding PriceNote}" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12"
TextAlignment="Center" TextWrapping="Wrap" MaxWidth="520" Margin="0,2,0,10"/>
<Button x:Name="UpgradeCtaButton" Content="{Binding UpgradeCtaLabel}"
Command="{Binding OpenPremiumCommand}"
Style="{StaticResource YtButton}"
HorizontalAlignment="Center" Padding="22,10" FontSize="14"
ToolTip="Opens llamacasty.com in your browser when clicked"/>
</StackPanel>
<TextBlock x:Name="OwnedStateText" Text="{Binding LicenceStateLabel}"
Foreground="{StaticResource Brush.Positive}" FontSize="13" FontWeight="SemiBold"
HorizontalAlignment="Center" Margin="0,18,0,0"
Visibility="{Binding IsAppOwned, Converter={StaticResource BoolToVis}}"/>
<!-- Secondary destinations, one row, deliberately quieter than the CTA. -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,22,0,0">
<Button Content="llama chile shop on YouTube" Command="{Binding OpenChannelCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0" FontSize="11" Padding="10,5"/>
<Button Content="Mastodon" Command="{Binding OpenMastodonCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0" FontSize="11" Padding="10,5"/>
<Button Content="Buy me a coffee" Command="{Binding OpenCoffeeCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0" FontSize="11" Padding="10,5"/>
<Button Content="Licenses &amp; legal" Command="{Binding OpenLicensesCommand}"
Style="{StaticResource YtButtonSecondary}" FontSize="11" Padding="10,5"/>
</StackPanel>
<TextBlock x:Name="AboutCopyright" Text="© Llama Chile Shop"
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"
HorizontalAlignment="Center" Margin="0,18,0,4"/>
</StackPanel>
</ScrollViewer>
<!-- Licensing/legal panel -->
<StackPanel Visibility="{Binding IsLicensesOpen, Converter={StaticResource BoolToVis}}">
+58 -1
View File
@@ -48,8 +48,65 @@
</Setter>
</Style>
<!-- Secondary buttons used to be YtButton with the SAME tone as the panel behind them, so they
read as plain text (creator, 2026-11-05: "you can tell they're buttons b/c our styling hasn't
been applied"). They now sit on a higher surface with a hairline border, and show a focus ring
so a keyboard user can find the one that matters. -->
<Style x:Key="YtButtonSecondary" TargetType="Button" BasedOn="{StaticResource YtButton}">
<Setter Property="Background" Value="{StaticResource Brush.SurfaceContainer}"/>
<Setter Property="Background" Value="{StaticResource Brush.SurfaceContainerHigh}"/>
<Setter Property="Foreground" Value="{StaticResource Brush.OnSurface}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Grid>
<Border x:Name="Bd" Background="{TemplateBinding Background}"
BorderBrush="{StaticResource Brush.Outline}" BorderThickness="1"
CornerRadius="{StaticResource Radius.Sm}"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<Border x:Name="Glow" Background="White" CornerRadius="{StaticResource Radius.Sm}" Opacity="0"/>
<Border x:Name="Focus" BorderBrush="{StaticResource Brush.Primary}" BorderThickness="2"
CornerRadius="{StaticResource Radius.Sm}" Opacity="0" Margin="-2"/>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Glow" Property="Opacity" Value="0.08"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Glow" Property="Opacity" Value="0.16"/>
</Trigger>
<Trigger Property="IsKeyboardFocused" Value="True">
<Setter TargetName="Focus" Property="Opacity" Value="1"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.45"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- The release badge: a pill that is itself a button (it links to the product site until the
Steam store page exists). -->
<Style x:Key="PillBadgeButton" TargetType="Button">
<Setter Property="Foreground" Value="{StaticResource Brush.OnSurfaceVariant}"/>
<Setter Property="FontSize" Value="11"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Padding" Value="10,3"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{StaticResource Brush.SurfaceContainerHigh}"
BorderBrush="{StaticResource Brush.Outline}" BorderThickness="1"
CornerRadius="999" Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="IconButton" TargetType="Button">
@@ -25,6 +25,7 @@ public partial class MainViewModel
EntitlementErrorMessage = _entitlement.ErrorMessage;
if (!changed) return;
OnPropertyChanged(nameof(HasProOutput));
OnPropertyChanged(nameof(LicenceStateLabel));
RefreshSteamLicenceAlert();
if (!HasProOutput && _selectedQuality is { RequiresPro: true } && CanChangeQuality)
SelectedQuality = QualityOptions.Single(option => option.Display == OutputProfile.FreeFallbackName);
@@ -78,6 +79,7 @@ public partial class MainViewModel
if (!DevBuild.IsDevelopment) return;
if (!SetProperty(ref _developerMode, value)) return;
OnPropertyChanged(nameof(HasProOutput));
OnPropertyChanged(nameof(LicenceStateLabel));
RefreshSteamLicenceAlert();
}
}
+21
View File
@@ -29,6 +29,27 @@ public partial class MainViewModel : ViewModelBase
public const string CoffeeUrl = "https://buymeacoffee.com/llamachiley";
public static string AppVersionLabel => $"Version {typeof(MainViewModel).Assembly.GetName().Version?.ToString(3)}";
// ── About box copy (creator ruling, 2026-11-05). The price and its end date live HERE, once, so
// the app, the site and docs/commercial.md cannot drift apart. ──
public const string LaunchPrice = "$79";
public const string StandardPrice = "$99";
public const string LaunchPriceEndsLabel = "April 1, 2027";
/// <summary>The release state shown as a badge. Not the assembly version — that number is for
/// log lines, not for the creator.</summary>
public static string AppReleaseLabel => "Pre-Release";
public string UpgradeCtaLabel => $"Unlock 60 fps — {LaunchPrice}";
public string PriceLine => $"Launch price {LaunchPrice}";
public string PriceNote => $"Until {LaunchPriceEndsLabel} — after that {StandardPrice}, locked for life.";
public string FreeOfferText => "Livestream and record at 30 fps.";
public string PaidOfferText => "Unlocks 60 fps for streaming and recording.";
public string TrustLine => "One-time purchase · lifetime licence · no account, no subscription, no tracking.";
/// <summary>Shown instead of the price and CTA once ownership is verified: a paying customer is
/// never sold to.</summary>
public string LicenceStateLabel => IsAppOwned ? "Licence: owned via Steam — thank you." : string.Empty;
/// <summary>The About hub's optional product-information link.</summary>
public bool IsPremiumAvailable => PremiumUrl.Length > 0;
+9
View File
@@ -1,3 +1,12 @@
## Price (creator ruling 2026-11-05)
- **Launch price $79** — one-time purchase, lifetime licence, no subscription, no account, no tracking.
- **Promotion ends April 1, 2027**; after that the price is **$99, locked for life**.
- Free tier: livestream and record at **30 fps**. Paid unlocks **60 fps** for both.
- The numbers live in `ViewModels/MainViewModel.Overlays.cs` (`LaunchPrice`, `StandardPrice`,
`LaunchPriceEndsLabel`) so the About box, the site copy and this file cannot drift apart. Change them
there first, then here.
# Commercial: pricing, entitlement, channels, support
The single source of truth for what LlamaCasty charges, what a licence entitles, how it ships and
+14
View File
@@ -1,3 +1,17 @@
## About box (2026-11-05)
A conversion surface, not a credits page: **820×860 modal**, ~520 px logo, a `Pre-Release` pill that is
itself a button (links to llamacasty.com until a Steam store page exists), the tagline
"1-2-3, and go live to the world on YouTube!", the offer as three short blocks (free 30 fps · paid
60 fps · one-time/lifetime/no-tracking), then the launch price and **one primary CTA**
("Unlock 60 fps — $79"). Secondary destinations are one quiet row; the footer is `© Llama Chile Shop`.
**A paying customer is never sold to:** with ownership verified the price/CTA block collapses and is
replaced by `Licence: owned via Steam — thank you.` The old tagline ("simple enough that even the most
right-brained person can intuit it") and the "Made by gramps" line are gone for good.
`YtButtonSecondary` carries a higher surface, a hairline border and a keyboard focus ring — before this
the secondary buttons were the same tone as the panel behind them and read as plain text.
## Left panel alert stack (2026-11-05)
A stack of red rows at the top of the left panel, **above both mode grids** (so it shows while editing
+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++)