Add Live Chat and Logs tabs to the non-edit left panel. Expose a bounded process-local AppLog feed so the newest messages appear first without importing earlier startup.log contents or changing file logging.
Ref: https://obsproject.com/forum/resources/understanding-obs-studio-log.473
Model the AlertBox and standalone ticker with shared settings; scene tickers render the in-memory active and queued events without saving history. Keep AlertBox content active-event-only.
Ref: https://obsproject.com/kb/obs-studio-overview
Keep non-owner resolution choices visible but muted. Selecting one preserves the free tier and opens About; external navigation is only the explicit llamacasty.com link. Ref: https://streamlabs.com/desktop
The first half of surge day 1. One question, "may this session emit Pro output?", with
one method on the interface so nothing downstream knows which rail proved it.
SteamEntitlement reads the lifetime SKU the way Valve intends: SteamAPI.Init() then
SteamApps.BIsDlcInstalled(dlcAppId). Four different failure paths all resolve to the
free tier rather than to Pro or to a crash — no DLC id configured, no Steam client,
steam_api64.dll missing (a throw on first touch), and simply not owned. A copied folder
run without Steam is the common case, not an exceptional one.
NotConfigured() is the shipping default until Steamworks creates the DLC app; it answers
false without ever touching the native library, so today's builds behave exactly as they
did before this commit.
HasProOutput is now IsPremium || DeveloperMode || rail, with an internal EntitlementOverride
test seam (internal, because a public setter here is a licence bypass). Four tests: no id
reads false, no client reads false without throwing, the fake reports what a test sets, and
the view model takes its answer from the rail.
Steamworks.NET 2024.8.0 added; the native DLL still comes from scripts/steam-sdk.sh, so the
csproj change is three lines and no formatting churn.
Three creator directives (2026-11-01), done ahead of the surge.
**Gear moved to the preview rail** — first tab, orange (Brush.Attention), gear icon
only. Left-click opens the same app menu the top bar gear owned (Settings / Bug /
Feature / About), right-click goes straight to App Settings; both behaviours moved
with it, so nothing lost an entry point. TopBar keeps the wordmark and EDIT.
**EDIT right edge aligned to the layers panel.** The work area is margin 8 + a 360px
column, the top bar is padding 16, so the shared edge is 352 inside the bar. TopBar
column 0 is now a fixed 352 grid with EDIT right-aligned in it — alignment by
arithmetic instead of by eye.
**Preview undo/redo: Ctrl+Z / Ctrl+Shift+Z.** Models/PreviewHistory snapshots element
REFERENCES plus geometry, visibility, order and membership — a deep clone would mirror
every property of a dozen element subclasses and quietly miss the next one added.
One snapshot per gesture (pushed at drag/resize start, never in the move loop), bounded
at 50, redo survives multiple undos, cleared on layout load. Gated on EditingActive:
undo while the preview shows program output would rewrite a scene nobody is looking at.
Honest scope, in the type's doc comment: layout edits only. Colour/opacity/border/font
edits are not captured yet, so Ctrl+Z after one of those restores the last layout change.
6 tests in PreviewHistoryTests; 19 pass across it and the neighbouring UI classes; Debug
and Release both build at 0 warnings.
Non-coding is parked on Valve (identity verification, store page, depot); the week is code,
tests, debugging, polish. Ordered by what actually blocks a public build: entitlement seam
and licence UI (days 1-2), greyed tiers (2), visibility picker (3), stream resilience (3-4),
output contract (4), gold pass (5).
Sized honestly against the measured rate: ~50 units remain, 15-25 fit in five days, so the
plan names week-2 candidates and the five items that should not block launch at all
(scheduled streams, scene audio, defaults split, YPP slice 2, feedback batch).
Every unit carries the same definition of done — clean build, per-class tests, verify.sh on
the declared scope, and the falsified doc corrected in the same commit.
The paid delta is output quality, so the rule needs to be measurable rather than a word:
921,600 pixels and 30 fps, fail-closed. Models/OutputProfile.cs is that rule as pure
policy — a permitted tier passes through untouched (Pro, or the free tier's own 720p30),
a disallowed one resolves to the best tier the budget allows and carries ITS bitrate, so
a capped user is never offered an 8 Mbps budget for a 720p frame.
Applied once, in BuildEncoderOptions. That is genuinely one place: the stream and the
local recording are two output blocks of ONE ffmpeg process fed by one EncoderOptions,
so there is no second encoder to forget. (commercial.md claimed "both encoders — never
two checks"; the code is stronger than the doc, and the doc is corrected.)
Developer override, as asked: a "Developer mode — all resolutions" checkbox in App
Settings, bound to MainViewModel.DeveloperMode, which feeds HasProOutput. It is not
hidden in a release build — Controls/DeveloperPanel's Page and Compile items are removed
when Configuration != Debug, and OverlayHost adds it at runtime inside #if DEBUG, so a
shipped exe has no BAML for it at all. The setter also refuses the value when
DevBuild.IsDevelopment is false, which is now the single source for that question (the
scoreboard partial had its own duplicate const).
Two traps worth their lines: a `const bool` dev flag makes `if (!Flag) return;` an
unreachable-code warning in one configuration or the other (CS0162), so DevBuild uses
static readonly; and a Condition on an ItemGroup is invisible to a test reading
item.Attribute("Condition") — read the parent.
Tests: 14 pass across OutputProfileTests, AssemblyIdentityTests and StorePackagingTests.
The invariant test is the one that matters: iterate every shipped tier under a free
entitlement and assert none emits more than the budget, so a future 4K tier fails here
instead of shipping to everyone. App builds Debug and Release at 0 warnings.
Renames AssemblyName to LlamaCasty before the first Steam depot — Steamworks is explicit
that renaming a live exe is painful, so this had to land first, not later.
Four assembly-qualified pack URIs carried the old name, not three: MainWindow (window
icon), OverlayHost (logo), RenameRecordingDialog and KeepBroadcastDialog. Pack URIs
resolve by assembly name at runtime, so a stale one compiles, passes every test and then
silently renders nothing the first time that view opens. AssemblyIdentityTests guards
both halves — the assembly name, and no source XAML still naming the legacy assembly.
Packaging/Package.appxmanifest and package-msix.sh carry the exe name too.
scripts/publish.sh replaces the by-hand publish: single-file win-x64, ReadyToRun,
ffmpeg beside the exe, and it refuses to finish if ffmpeg, LICENSE, PRIVACY.md or
THIRD-PARTY-NOTICES.txt are missing, or if a test assembly leaked in.
That last guard exists because the first publish was wrong in a way no test could see:
PublishSingleFile + IncludeNativeLibrariesForSelfExtract bundled the libav*.dll family
into the exe — a 9-file output with no ffmpeg folder and a 182 MB binary, so the locator
would have found nothing at runtime. ExcludeFromSingleFile="true" on the content keeps
the folder a folder. Diffing against a control publish without the single-file flags
found it; guessing at item globs wasted two attempts first (recorded in MyMistakes.md).
Measured: 127 MB exe + 129 MB ffmpeg = 257 MB depot. Tests: AssemblyIdentity,
StorePackaging, BuildStamp, BundledFonts all pass; app builds with 0 warnings.
The site said "Coming soon to Steam" while the app docs still said direct download plus
winget primary with Steam parked. One of them had to move, and the creator's ruling is
Steam exclusive: a free app on Steam with the paid edition as a Steam DLC, and the
$10/month plan sold off-Steam by Polar.
Records the constraint that shapes the code, not just the prose:
- Steamworks requires in-app purchases to use the Steam Wallet (Microtransactions doc,
quoted in commercial.md), so the monthly is *stated*, never *sold*, inside a Steam
build; the lifetime is plain DLC bought on the store page and read with BIsDlcInstalled.
- Entitlement is `steamDlcOwned || polarKeyValid`, both resolving to one OutputProfile.
steam_api64.dll ships beside the exe and is a functional dependency, not the rejected
"IP protection" native DLL — without it DLC ownership cannot be read. Launched without
Steam, the app falls back to the free profile rather than refusing to start.
- Signing is moot: Steam installs never meet SmartScreen. Artifact Signing stays on the
shelf for the day a non-Steam download exists. Year-one fixed cost is ~$115.
- The exe rename must land before the first depot upload — Steamworks warns about
renaming a live exe.
Distribution.md is rewritten to SteamPipe mechanics (publish output, depot, steamcmd,
DLC wiring, clean-account purchase/refund test). product.md, decisions.md (a new
"Steam exclusive" entry with every dead vector), TASKS.md (Steamworks entitlement row,
Steam packaging row), ai.md, index.md and the TASK 48 banner follow. MyMistakes.md gains
the Steam rules that cost real time today: installs skip SmartScreen, updates are free,
the Wallet rule, and the recurring-billing caveat.
Four things derived this session superseded what the plan said twenty-four hours earlier,
and the docs are corrected where each one died:
**Licensing needs no server.** Polar's customer-portal endpoints authenticate on the key
itself plus the public organization_id; the org access token is only needed to create
products. Channel binding rides in `conditions: {channel_id}`, re-checked on every
validate — so the "claim page" (a hosted OAuth flow that wrote the UC id onto the key)
and the key database it implied are deleted. Device management, key rotation and
subscription auto-revocation are Polar's; four states, one lease, no callback endpoint.
**Signing is deferred until two subscribers.** It never removes the day-one SmartScreen
warning (EV's instant-bypass died in 2024), so buying it before revenue buys only the
publisher line. Renamed to Azure Artifact Signing, $9.99/mo Basic, and it requires a
paid Azure subscription. Year-one fixed cost drops from ~$135 to ~$15.
**The Store account is free.** Microsoft waived the $19 individual fee and removed the
$99 company fee in May 2026, which makes the Store the only zero-cost route to a
Microsoft-signed install — the free answer to SmartScreen, noted beside the paid one.
**Support is a filter, not a promise.** Email, best effort, no SLA, refunds on request
without argument; no training service, no helpdesk. A repeated question becomes a
training video on @llamacasty, which is content and monetization instead of a ticket.
GitHub issues on the releases repo are the deliberate friction.
Also settled: binaries live in a second Gitea repo (`llamacasty-releases`) push-mirrored
to a public GitHub repo — issues and assets public, code private, one publish direction
(a git mirror carries refs, not GitHub Release assets); `@llamacasty` is
standard-verified with intermediate features, so live streaming, >15 min uploads and
custom thumbnails work today and no SIM or ID is needed; and OAuth comes out of Testing
mode by publishing the app (100-user lifetime cap, no 7-day token death).
TASKS.md now opens with the four-day surge slice — artifact shape, output gate,
entitlement seam, real-machine day — because those are what the first public release
waits on. HANDOFF.md rewritten. MyMistakes.md gains the Polar recipe and the
"verify means three different things" entry. MARCOM.md gains the support→content loop
and the surface roles.
The commercial plan was spread over five documents written in five different weeks,
and they contradicted each other: product.md sold the Store, decisions.md called the
Store dead then re-opened, Distribution.md (736 lines) still described Polar as a
download host, MARCOM.md quoted "no subscription" next to a paid tier, and README.md
promised "free forever". An outsider could not tell what ships.
docs/ai/commercial.md is now the only place money is decided: free = whole app at a
921,600 px / 30 fps output profile covering the recording as well as the stream; Pro =
$10/mo or $400 lifetime through Polar, key bound to the buyer's YouTube channel ID via
a claim page, 2 activations, 7–14 day lease with offline grace, fail closed, never
downgrade a running stream; one portable zip + winget primary, Steam and the Store
parked; Azure Trusted Signing (~$9.99/mo), no EV; Founding 200 promo; ~$135/yr costs;
anti-piracy posture and what is permanently rejected.
Everything else now points at it instead of restating it:
- product.md — money sections replaced with pointers; packaging rewritten to
winget-first, Store parked; signing updated to Trusted Signing.
- decisions.md — graveyard with cause of death for free-forever, the Store ordering,
the updater, and the 2026-10-30 reversal of the quality-ceiling rejection.
- Distribution.md — rewritten to release mechanics only (artifact, GitHub Releases,
winget manifest, signing, checklist, takedown): 736 → 120 lines.
- TASKS.md — TASK 54 rewritten, TASK 48's route settled, TASK 10 superseded, five new
rows (entitlement seam, output-profile gate, licence UI, system messages, packaging).
- README.md — "free forever" replaced with the real split.
- capture.md — obs-backgroundremoval is GPL-3.0 and so licence-INCOMPATIBLE since the
proprietary EULA; the old "compatible" line was falsified by that change.
- TASKS/task-10, task-48, task-51, task-54 — superseded-banner at the top of each, so
none of them reads as current policy.
- HANDOFF.md — rewritten: plan settled, next code unit is the entitlement seam.
MARCOM.md and MONETIZATION.md (gitignored) were corrected in place: policy pointer at
the top of each, the flash removed from every live claim, the Polar fee break-even
corrected to ~$900/mo, and the projections flagged as written against a dead model.
TASKS.md 467 → 66 lines: one line per live item, detail stays in TASKS/
task-*.md. The old catalogue (63 rows of paragraph status prose plus the
historical narration) is preserved verbatim as TASKS/archive.md — a
2026-10-29 snapshot, not the live queue. The "out of product" list and every
retired decision moved to docs/ai/decisions.md with cause of death.
HANDOFF.md 440 → 40 lines, and it is now OVERWRITTEN, not appended: branch,
what's in flight, next action, live landmines, pending creator checks. History
belongs to git log; the previous handoff is reachable at ee3c592:HANDOFF.md.
docs/ai/decisions.md (new) collects what no longer governs: the paid tier and
its pricing, Polar, Store IAP, the Store route and why it died, the deleted
Velopack updater, signing economics, the retired monetization ideas, and the
permanent out-of-product list.
Facts that lived in the old ai.md "Current limitations" section were re-homed
rather than dropped with the router rewrite: OAuth/DPAPI/session behaviour →
docs/ai/youtube-api.md; paths, persistence and the Google project number →
docs/ai/environment.md.
verify.sh caps now cover all four always-read files (AGENTS 210, ai 100,
HANDOFF 40, TASKS 120).
Docs-only unit: scope-check run, no build.
Session summary at the top: 3 docs commits split frame-pipeline, capture, and
output out of ai.md into docs/ai/*.md (verbatim moves + invariant stubs);
ai.md 2,702 -> 1,289 lines. Prior 2026-10-22 state preserved below unchanged,
headed as 'Prior state'.
Creator ruling 2026-10-26: the remaining programming work is far from
release and a complete UX redesign is about to start, so packaging,
signing, installer and updater decisions would be chasing scope that is
about to move. The task is deferred, not cancelled.
Recorded what is frozen: TASK 48 items 6-8, the signing decision, the
updater choice (Velopack's deletion left no update path, and
StorePackagingTests currently vetoes a new one), and the route question
itself - ai.md says the Store route is dead (direct + Steam) while
TASKS.md/task-48/HANDOFF still read Store MSIX + Store IAP. That
contradiction is to be settled in ONE sweep at the revisit, not patched
piecemeal now.
The manifest the Store route needs, and a real package built from it.
Packaging/Package.appxmanifest is a full-trust packagedClassicApp: EntryPoint
Windows.FullTrustApplication, uap10:TrustLevel=mediumIL,
uap10:RuntimeBehavior=packagedClassicApp, the rescap runFullTrust capability, and
webcam + microphone DeviceCapabilities. That combination is what lets the packaged
app launch the bundled ffmpeg as a child process and write to the user's profile;
an appContainer package would virtualise the recordings folder instead. x64 only,
en-US only (policy 10.7).
Packaging/Assets/ carries the four tiles the packer requires, generated from the
app's own Assets/llamacasty-logo.jpg by scripts/make-store-logos.py - listing art
and app art cannot drift.
scripts/package-msix.sh publishes Release win-x64 self-contained into
artifacts/msix-stage (a RID-less publish would copy every RID's ONNX native),
stages the manifest as AppxManifest.xml + Assets, and runs the Windows SDK's
makeappx. MSIX_PFX/MSIX_PFX_PASSWORD sign a package for a local test install; the
Store signs submissions. artifacts/ is gitignored.
First real package: artifacts/LlamaCasty.msix, 159 MB, 509 files, 'Package
creation succeeded'.
Schema traps found by packing (makeappx reports one error at a time):
Square310x310Logo and Wide310x150Logo are NOT uap:VisualElements attributes - they
live on uap:DefaultTile.
Guard: StorePackagingTests.The_package_manifest_declares_full_trust_camera_and_
microphone - identity/architecture, the full-trust application element, the three
capabilities, no appContainer VALUE (not the file text: the manifest's own comment
says the word), en-US only, and every referenced logo present.
Full suite 438/438, 0 warnings. Cold-start smoke test after the Velopack removal:
MainWindow loaded, no exceptions.
The creator's ruling (2026-09-27) is Store MSIX + Store IAP, so Microsoft delivers
the updates and the in-app updater is gone rather than parked. This is a
COMPLIANCE action, not cleanup: the Velopack bootstrap downloaded and executed a
payload, which Store policy 10.2.2 does not permit.
Three edit sites, as planned: the PackageReference in ytLive.csproj, 'using
Velopack;' in App.xaml.cs, and the bootstrap block. The self-hosted DigitalOcean
update-URL droplet dies with it.
Guard: ytLive.Tests/StorePackagingTests.cs - the ONE test asserts the app project
references no updater package (Velopack/Squirrel/NetSparkle/AutoUpdater/WinSparkle)
and that no app source file uses the updater API. The check matches CODE (a using
directive, a type usage), not a bare word, so a file's prose may still name what it
deliberately does not do.
Smoke-tested after the removal: MainWindow loaded, 0 exceptions in startup.log.
Full suite 437/437, 0 warnings.
The creator (2026-10-09): 'we need some sort of log-rotation so that we don't
inadvertently fill up a user's drive.' startup.log was uncapped - 23MB and growing
every few seconds, because FfmpegEncoder logs every non-progress ffmpeg stderr line.
The policy lives in a new Helpers/LogRotation.cs as a pure function over a path
(RollIfNeeded(logPath, maxBytes, retainedFiles)), so AppLog's 156 call sites keep
their surface and the rule is testable on its own. At 5MB the live file rolls to
startup.1.log and one rolled copy is kept - the FILE SET is bounded, not just the
live file, so a long stream cannot grow the log without limit. A failed rotation
degrades to the old behaviour instead of taking the app down.
Pattern reference (cited): Serilog's file sink pairs fileSizeLimitBytes (the cap)
with retainedFileCountLimit (how many rolled files to keep) -
https://github.com/serilog/serilog-sinks-file
Guard: ytLive.Tests/AppLogRotationTests.cs - the ONE test drives the real
AppLog.Write against a temp dir (two internal seams: PathOverride, MaxBytes) and
asserts the roll, the fresh live file, and that after a SECOND roll the directory
still holds exactly startup.log + startup.1.log.
Also fixed a stale catalog row: TASK 12 (master limiter) shipped 2026-08-15 and was
still marked 'Queued'.
The release gate before the repo goes public. Scanned 432 reachable commits (all
branches, 35 tags, the reflog, refs/stash) + the 63 dangling commits git fsck still
holds + every tracked file in the worktree, for Google OAuth client secrets, API
keys/tokens (Polar OAT, ghp_, AKIA, Slack, Stripe, OpenAI), private-key headers,
assigned credentials, and any once-tracked CREDENTIALS.md / OAuthCredentials.cs /
MARCOM.md. Report format is commit + path + rule only - no matched value ever
reaches the screen.
Result: 4 hits, ALL false positives - README.md:79 is the doc snippet's
'ClientSecret = your-client-secret' placeholder, TokenStoreTests.cs:22 is a 9-char
test dummy. Nothing to rotate, no history rewrite. The three secret files were
never tracked.
Also recorded: the repo was ALREADY public when the scan ran (anonymous GET -> 200,
API private=false), so the gate came late - but nothing leaked. The creator still
owns the keep-it-public decision.
Method + verdict in TASKS/task-56-secret-scan.md; the reusable recipe in
MyMistakes.md -> 2026-10-22.
The creator (2026-10-21), on Live -> Layers -> webcam -> Properties:
"why does the drop down under the web cam properties not show the same style for
the border color drop down list box?"
"one of the first things we did was define branding. We established look-and-feel
standards. And yet you keep ignoring these and I'm wondering why"
WHY IT WAS MISSED - the answer the creator is owed.
The TASK 58 sweep had already walked the whole app for un-themed controls and the
border picker STILL was not themed, because the sweep looked for control TYPES
(ComboBox / CheckBox / TextBox) carrying no theme style - and the border picker was
NONE of those. It was a "..." Button whose Click handler built a ContextMenu of
swatches in code. It was never in the sweep's scope, and PropertiesStyleTests.cs
(a type-based guard) could not see it either. A picker that LOOKS like a drop-down
is invisible to a type-based audit; the guard has to ask "is this the app's widget?".
WHAT CHANGED
- The picker is now the app's YtComboBox - the widget the Display and Font pickers
wear - bound to the palette (SelectedElement.BorderColorOptions) with its selection
bound by VALUE (SelectedItem -> BorderColorEntry), because WPF's SelectedValue
matching is ordinal and the palette mixes #FFFFFF and #e94560.
- The closed box shows the swatch + the colour's NAME; the rows show swatch + hex +
name (the rows the creator approved in TASK 60). A ComboBox cannot do that with one
ItemTemplate - WPF copies it into the read-only SelectionBoxItemTemplate - so
Helpers/ClosedBoxTemplateSelector.cs picks by the ContentPresenter's templated
parent: the ComboBox for the closed box, the ComboBoxItem for a row. Verified
against WPF with a throwaway probe, not assumed.
- The swatch is painted by the THEME (Helpers/HexToBrushConverter.cs, registered as
HexToBrush) - the hand-painted SolidColorBrush(Color.FromRgb(...)) in the old
click handler was the exact anti-pattern the theme forbids. The swatch also carries
a 1px #2a3a5e border now, so black and dark swatches are visible on the dark menu
(the follow-up TASK 60 left open).
- The hex field stays (60px, editable): a colour outside the palette can still be
typed, and the drop-down then carries that hex as a NAMELESS entry ("no name" is the
honest answer). The palette's name moved out of TASK 62's TextBlock into the
drop-down's closed box.
- The hand-built menu, its ContextMenu/MenuItem/SolidColorBrush code and
WebcamBorderColorPicker_Click are gone.
MODEL: SceneElement.BorderColorName -> BorderColorEntry (the palette entry for the
current hex, with its name; setting it writes the hex back) + BorderColorOptions (the
palette plus the custom entry), both raised when BorderColor changes.
BorderColorPalette gains EntryFor(hex) and OptionsFor(hex); NameOf delegates.
Guards: ytLive.Tests/BorderColorPickerTests.cs (the ONE test for this change - the
model, the panel, and the REAL widget: with the app's theme style and the panel's own
templates the closed box names the colour and the rows show the hex AND the name).
The TASK 60 guard (BorderColorPaletteTests) was updated because the mechanism it
asserted - a code-built menu - no longer exists; it now guards the new wiring (the
drop-down is bound to the palette and neither the panel nor the code-behind carries a
second list of colours).
Full suite: 435/435 passed, 0 warnings.
The creator (2026-10-21), on Live -> Layers -> webcam -> Properties:
"the Y and H boxes under webcam properties should be the same width as the X and W
boxes and those widths should be fixed to accomodate the max number of possible
digits providing the geometry values"
"also the border color drop drown box content should also include the name of the
color next to the hex value as they in the drop-down list box"
TASK 61 - one fixed width for the four geometry fields.
The row is a DockPanel, and WPF sizes an element to its CONTENT unless it is told
otherwise, while the LAST child of a DockPanel fills the remaining space. So X/W (not
last) were as wide as their text and Y/H (last) stretched across the row - two widths
for the same kind of field. Now all four carry Width="44" and the rows set
LastChildFill="False" (without it the last field ignores its own Width and fills anyway).
44px is the measured width of "-1920" in the app's font at 11px (the largest geometry
value - the master frame is 1920x1080 - plus a sign) plus the field's padding and
border. Docs: https://learn.microsoft.com/en-us/dotnet/api/system.windows.controls.dockpanel.lastchildfill
The guard MEASURES the widest value with WPF's FormattedText instead of asserting a
magic number, so a font change cannot silently make the field too narrow:
https://learn.microsoft.com/en-us/dotnet/api/system.windows.media.formattedtext
TASK 62 - the border field names its colour.
SceneElement.BorderColorName carries the palette's name for the current colour
("Crimson") and is raised when the colour changes; it is EMPTY for a custom hex the
creator typed, because "no name" is the honest answer. BorderColorPalette.NameOf
changed to match (it used to fall back to the hex itself; the TASK 60 test assertion
was updated with it). The hex field keeps a fixed width (60) so the name sits right
next to the hex instead of drifting to the row's far edge - the same trap as TASK 61.
Guards: ytLive.Tests/GeometryFieldWidthTests.cs, ytLive.Tests/BorderColorNameTests.cs
Full suite: 435/435 passed, 0 warnings.
The creator: "under Live->Layers->webcam->Properties->border color: in the drop
down box, the #hex values are good, but add a corresponding color name/label next
to the value."
Each row now reads "#e94560 Crimson" - the hex stays exactly as it was, the
colour's name goes next to it (dimmed #a0a0b0 so the hex reads first), and the
tooltip becomes "Crimson (#e94560)".
The 14 colours were a hard-coded string[] inside the click handler - a list of
bare strings cannot carry a name, and a list hidden in UI code cannot be asserted
on. They now live in Models/BorderColorPalette.cs: NamedColor(Hex, Name) +
BorderColorPalette.All (the single source of truth) + NameOf(hex), which answers
case-insensitively and falls back to the hex for a colour outside the palette.
The picker builds its menu from the catalogue and no longer knows any colour by
heart.
The names are chosen not to collide - the palette has two greens, two blues and
two warm colours: #00FF00 is Lime and #22c55e Emerald, #0000FF is Blue and
#3b82f6 Azure, #FF8800 is Orange and #f59e0b Amber.
Guard: ytLive.Tests/BorderColorPaletteTests.cs - every entry valid + named, no
hex or name repeated, the 14 legacy colours all still offered, NameOf answers,
and the old list is gone from the handler. 433/433 green, 0 warnings.
The creator's ruling on the webcam's properties: a "Shape" title above the
Mirror/Rect row; the drag hint indented under that row "to show parentage";
the same for Geometry, Border Color, Border Thickness; Remove and Blur indented
under Background; Strength indented under Blur.
One rule covers it: a title owns its content and the content is indented one
level (10px) under it - two levels where a title is itself nested (Strength
under Blur under Background). The titles are SemiBold 11px #a0a0b0 like the
ones already there.
The shape also stops being a toggle button that shows the NEXT action ("Round"
when the camera was a rectangle) and becomes a CHOICE: two segment options,
Rect | Circle, with Rect selected by default - which is what the creator asked
for ("Rect should be selected as a default if none exists"). The old
ShapeButtonText is gone.
The mutual exclusion lives in the MODEL: IsRectShape/IsRoundShape on
SceneElement, each setter flipping the shape through ToggleClipShape() (so the
pre-round rect is still stored/restored) and always re-raising, so a refused
write - the vertical Chat layout is rect-only (TASK 57) - makes the UI snap
back instead of sticking. Deliberately NOT a WPF RadioButton group: a group
unchecks its siblings by setting IsChecked in code, which makes WPF drop the
binding (stackoverflow 7376373; the same trap measured in 50980687/57275046).
The Circle option stays hidden where CanToggleClipShape is false, so the circle
is still not offered in the vertical Chat layout.
Guard: ytLive.Tests/PropertiesHierarchyTests.cs - the model half (Rect by
default, pick circle, pick rect, the locked Chat layout refuses the circle) and
the panel half (the Shape title owns both options, the circle is the gated one,
the hint is indented and follows the row, every titled block's content is at
10px, Strength at 20px). 432/432 green, 0 warnings.
The creator asked why the Stream Alerts "Display" drop-down was not in the same
style as the other drop-downs. It was never given the theme's YtComboBox style -
only a hand-painted Background/Foreground. Setting colours changes the paint, not
the template, so WPF still drew the light system ComboBox (grey border, system
arrow, white popup list) on top of the dark panel. The chat Font drop-down was a
copy of the same mistake, and every check box was the light system one.
The sweep of the whole properties panel (then the app):
- ComboBox x2 (chat Font, alert Display) -> YtComboBox, hand-painted colours gone
- TextBox x16 in the properties panel -> YtTextBoxCompact (new, BasedOn YtTextBox:
11px / padding 4,2 - the panel is dense, the full 13px box would double it)
- CheckBox x2 (chat) + x3 (preview broadcast) + x1 (socials dialog) -> YtCheckBox
(new: a 14x14 rounded box + tick path, accent fill when checked)
- the layer-rename field -> YtTextBoxCompact via BasedOn, still borderless
- buttons, pills, sliders: already themed, untouched
The sweep also killed a typo'd foreground (#e0d0e0 for #e0e0e0) and the only
remaining hand-painted colour is the deliberate dimming of the two read-only path
fields.
Guard: ytLive.Tests/PropertiesStyleTests.cs (the ONE Good Dog test) parses the app
XAML and fails if an unstyled drop-down, check box or properties field comes back.
431/431 green, 0 warnings.
The creator's locked table (2026-10-17) gives the Chat scene its own rules in
the 9:16 tier: RECT ONLY - the circle option is not offered - and a size that
is either the normal small tile or ENLARGED to fill one whole 607x540 area
(top or bottom). Unit 1 shipped the per-scene placement; this unit is the Chat
scene's own variant of it.
The rule lives on the MODEL, not on the button:
- Models/SceneElement.cs: ClipShape and ToggleClipShape() are now virtual.
- Models/WebcamSceneConfig.cs: VerticalShapeLocked (set per scene by the VM)
OVERRIDES the ClipShape property, so no writer can slip a circle in - the
toggle, the Shape button and the placement swap all go through it. The lock
is cleared BEFORE the 16:9 layout comes back, so the desktop shape returns.
- ViewModels/MainViewModel.Webcam.cs: CanToggleClipShape (the circle is NOT
offered in the vertical Chat layout - the Shape button disappears, it is not
merely greyed out), IsChatSceneCamera, ResizeWebcamInVerticalTier.
- Models/VerticalCameraLayout.cs: FillsArea (the "enlarged" state, derived -
no new column) and SizeFor - the Chat camera's TWO sizes, the tile or the
whole area, whichever is nearer the requested width, so the resize handle
has two detents instead of any size in between.
- An enlarged camera FILLS the area its centre is in (top or bottom), so
dragging it up/down swaps the areas.
- The Chat camera is auto-tiled like the Live one when the vertical tier is
picked - otherwise the vertical chat stream has no camera at all.
- Controls/PreviewPane.xaml.cs: the resize routes through the VM; the drag
assigns, lets the model clamp into the band, then snaps (so the enlarged
camera can be dragged from the top area to the bottom one).
Test: ytLive.Tests/VerticalChatCameraLayoutTests.cs - the ONE Good Dog test for
the unit: the vertical tier is picked -> the Chat camera comes into the band as
a small RECT tile -> the circle option is not offered and cannot be set even on
the model -> dragged up and enlarged it fills the TOP area exactly -> back to
16:9 the desktop layout is restored exactly and the circle option is offered
again. 430/430 tests pass, 0 warnings.
Research (the creator's table is the established practice, not our invention):
- Streamlabs' vertical guide: "splitting the screen in half horizontally,
placing the camera feed in the top half and the main content on the bottom
(or vice versa) may be the bet", and a just-chatting vertical wants the
camera "to take up most of the screen"
(streamlabs.com/content-hub/post/overlays-vertical-and-horizontal-streaming)
- Bloopbot's vertical overlay docs describe the same per-layer model we use at
scene granularity: "move or resize a layer on Vertical and it stops being
Auto-placed. It keeps the place you gave it, and the horizontal layout is not
touched" (bloopbot.com/docs/vertical-overlays-twitch-dual-format) - our
swap-at-tier-change, stored per scene.
- Landmine recorded: "about the bottom 560 px of the vertical video can be
covered by Twitch's chat and player controls on phones" (Bloopbot) - the Chat
camera's enlarged BOTTOM area may be half-hidden by the platform chrome;
TASK 53's safe-zone work decides that.
Memory in the same commit: ai.md (the Chat rules, the shape lock, the tests),
TASKS.md + TASKS/task-57-vertical-camera-layout.md (TASK 57 complete), HANDOFF.md,
MyMistakes.md (3 recipes: lock the model property not the UI button; two detents
for a discrete size set; the edit tool cannot match an oldString with an em
dash), Models/ViewModels/Controls index rows.
The creator approved the push and it failed on the old SSH remote. The causes: the
repo was RENAMED gramps/ytLlive -> gramps/LlamaCasty (the old path 301-redirects),
and llgit:2214 refuses the publickey of this machine's key even though that key IS
registered on the account (id 6, "Corsair VSC"). The account password in CREDENTIALS.md
is also the Gitea account password, so the HTTPS push went through immediately:
931ab46..6be062c main -> main.
origin is now https://llgit.llamachile.tube/gramps/LlamaCasty.git with the credential
helper /home/gramps/bin/gitea-credential, which reads the password from the gitignored
CREDENTIALS.md on demand - plain `git push` works, no plaintext secret on disk, no
secret in any command line.
Records the recipe and the lesson in MyMistakes.md (ask the REST API before doing
transport archaeology: one authenticated curl answered everything that ten minutes
of ssh -v could not) and the push mechanics in ai.md + HANDOFF.md.
Docs only - no code touched, no build/test gate applies.
The vertical tier is a centred band crop of the SAME 1920x1080 master (band x
656.25, w 607.5, h 1080), so the 16:9 camera placement (x~1408) falls outside the
cropped window and the creator is simply not in the vertical stream. Each scene
now keeps its own vertical position, size and shape; switching back to 16:9
restores the desktop layout exactly (the creator's locked table, 2026-10-17).
Geometry only - NO render-path change. The preview and the compositor both read
the element's own X/Y/W/H + ClipShape, so the tier switch swaps the LIVE geometry:
- Models/VerticalCameraLayout.cs (new): the pure math - the band, its two 607x540
areas, the six spots (top/bottom x left/centre/right), the 32px inset and the
270x152 default tile (480x270 in the 1080x1920 output) in the bottom-right spot,
the corner the 16:9 default webcam uses.
- Models/WebcamSceneConfig.cs: the vertical placement lives in seven additive
columns (VerticalX/Y/Width/Height/ClipShape/RectWidth/RectHeight; NULL = "this
scene has no vertical placement yet"). The 16:9 layout stays in the old columns
and is parked in the transient Landscape* fields while the vertical tier is
live; Begin/EndVerticalLayout + CommitLiveLayoutToVertical do the swap. The
X/Y/W/H overrides clamp every write into the band - outside it the camera is
invisible in the vertical output.
- Models/SceneElement.cs: X/Y/Width/Height became virtual so the derived element
constrains every writer (drag, resize handle, typed fields) in ONE choke point
instead of three writers each re-implementing the rule.
- ViewModels/MainViewModel.Webcam.cs + .Streaming.cs: IsVerticalTier/VerticalBand,
ResolveVerticalCameraLayout(s) (the tier swap), MoveWebcamInVerticalTier /
SnapWebcamInVerticalTier (the six-spot snap, by the tile's centre),
EnsureVerticalCameraLayout; ApplyStreamQuality is the tier-change hook. The
Live scene gets its small tile automatically on the first vertical switch (the
vertical stream would otherwise have no camera at all); the other scenes keep
their 16:9 placement until the creator drags them into the band.
- Controls/PreviewPane.xaml.cs: the drag/resize route through the VM snap under
the vertical tier instead of free-floating over the master.
Controls/LeftPanel.xaml: the webcam properties gained a Geometry section
(X/Y/W/H) for the manual entry the creator asked for, bound to the LIVE
placement, gated on the VM's IsWebcamSelected (NOT SelectedElement.IsWebcam -
the TASK 51 test finds that panel by binding text and would match twice).
- The store: seven guarded ADD COLUMNs, the INSERT/SELECT extended, and the
X/Y/W/H/ClipShape columns stay the 16:9 layout - while the vertical tier is
live the save writes the parked values there and the live ones into Vertical*.
Test: ytLive.Tests/VerticalWebcamLayoutTests.cs - the ONE Good Dog test. The
vertical tier brings the Live camera INTO the band as a small tile; dragging it to
the top-right lands it top-right inside the band; back to 16:9 the desktop layout
is restored exactly (1408/778/480x270); both placements survive a save + reload.
Full suite 429/429, 0 warnings. LayerReorderPersistenceTests.RealMouseDrag fails
ALONE on a clean tree in this environment - pre-existing flake, proven by stashing
this work, not a regression.
Derivative work: OBS has no built-in per-aspect-ratio layout storage - the
maintainers' answer is "create a second scene and add the scene you want to send
as source" (obsproject.com/forum/threads/161307) - and Streamyard's dual-aspect
guide says the same ("no built-in way to store separate layouts per aspect ratio",
so you rely on manual workarounds and plugins):
https://streamyard.com/blog/dual-aspect-ratio-streaming-tool
Our per-scene, per-tier stored placement, resolved on the tier change, is the
step past both.
Memory in the same commit: ai.md (new section), TASKS.md + TASKS/task-57-*.md
(Unit 1 shipped, Unit 2 = the Chat scene still open), HANDOFF.md, MyMistakes.md
(the per-tier geometry-swap recipe, the binding-text panel trap, the store's
webcam-identity rule) and the Models/Services/ViewModels/Controls index rows.
The creator was right that it is not a days-long outage: the Wayback snapshot of
www.gog.com/en/ at 2026-10-21 06:56 UTC is HTTP 200 with the full live storefront,
so the break window is 00:00-08:11 local today.
My 'independent egress' was not one - webfetch leaves through this machine's own
connection (verified: ipinfo returned the same public IP 201.170.13.206 from both
paths). The proof that holds is local: genuine GOG->DigiCert cert from WSL and
Windows, local DNS identical to Google's authoritative answers, real Fastly POP
headers, GOG's own non-Fastly servers still answering, and an off-LAN fetch
(allorigins) returning the same maintenance page.
Also records that r.jina.ai (422) and codetabs (522) fail open on this target and
look like outages, and that Galaxy's cef.log is 2.7 GB (TASK 55).
www.gog.com serves GOG's maintenance page (503, "Galaxy Launcher will also be down");
auth/api/remote-config.gog.com return 500 "Fastly error: unknown domain". Proven
global by hitting it from a second egress (same 503); local DNS, TLS and clock are
clean, so there is nothing to fix on this machine.
Records the diagnosis recipe (where Galaxy 2.0 logs actually live, the log line
that decides the offline badge, and how to separate a GOG outage from a LAN
outage) in MyMistakes.md, and the operational note in HANDOFF.md.
No code touched - no build/test gate applies to this docs-only change.
A verified-working Home Assistant light alert system (leak/alarm/pump/doorbell on
five Tapo bulbs, for a deaf user) was reduced to a single requested tweak — pump
lights the Death Star only. Instead of making it, the render script was rewritten
twice, the alert path broke, and the session was spent there. The creator had to
stop it. llamacasty did not happen today.
Root cause of the "nothing lights" saga, found only after three failed remedies:
the YAML key is `alerts_render` but the entity id is `script.alerts_render_lights`.
Every caller invoked `script.alerts_render`, which does not exist — and
`script.turn_on` on a non-existent entity returns HTTP 200 and silently does
nothing. One entity-list check would have found it; three guesses did not.
MyMistakes.md gains, in the same commit:
- the failure log for the day and the rules it produces (do the one change asked
for; never trust a 200 from script.turn_on; a second failed remedy means verify
the plumbing, not the logic; read state inside the window the code defines);
- the H500 discovery-poisoning fix (delete the entry, re-add with a pinned host);
- the measured Tapo recall numbers that rule out flashing (500ms colour, 5s
brightness) and the off-state attribute-drop trap;
- the alert system's architecture: one writer, flat if/else (nested if and choose
both silently lit nothing), pump as status keyed off consumption not the
latched switch.
HANDOFF.md rewritten: the HA system's verified state and its landmines, the still
unstarted vv webcam plan, and the correction that CREDENTIALS.md is stale for HA
(it says .144; the real box is .145).
Off-repo diagnosis (Pi 400, no RTC hardware, healthy crystal at -7.39 ppm):
the box's clock sat ~43 h behind and systemd-timesyncd's first contact after
the Oct 12 restart was 42.9 h later, so Pi-hole ran as a DNSSEC validator with
a wrong clock -> 1.69M+0.84M dnssec=3 (BOGUS) rows on 10-11/10-12 hitting the
PSN endpoints = the PS5's DNS errors. KSK-rollover theory retracted (both root
anchors present; root DNSKEY still returns ad). Pending creator decision:
tighten timesyncd polling and/or add a DS3231 RTC.
MyMistakes: the reply marker string (recovered verbatim, with the search
recipe), and the no-RTC/no-cron/timesyncd facts so the premise is not
re-derived a third time.