Ask each Advanced setting once, instead of spreading it over switches - #41
Merged
Conversation
Two settings on that page were spread across several controls that could not all be true at once, so the page spent its height greying things out to explain hierarchies that were never visible in the first place. Both collapse to a dropdown, by the same arithmetic each time: count the combinations the controls allowed, count the outcomes they produced, and the difference is what the user had to keep rediscovering. Mute advertisements / Record everything Spotify plays / Include advertisements: three switches, four combinations, three outcomes. Two of the switches did nothing unless a third allowed it. It is now recording.recordSelection, with the values the booleans could actually express between them - recognisable tracks only, everything except advertisements, everything including them. Mute advertisements never muted anything, which is why this was worth looking at rather than relabelling. AudioSessions.SetMute had no callers, so the switch shipped on by default and its one real effect was to veto record-everything silently: turn that on and nothing happened, with nothing on the page to say why. The predecessor never hit this because its form unchecked mute-ads the moment record-everything was checked; Offstream had inherited the rule and not the interlock. Restoring it was considered and rejected: the predecessor's mute-ads muted every other application on the machine and held Spotify at full volume, to catch video adverts on a separate audio session, so it never silenced the advert either. A recorder reaching out to mute unrelated applications is a side effect nobody asked for. Existing-file policy plus "also tell Spotify to move on": three values and a boolean, six combinations, four outcomes. Moving on does nothing under Replace it or Save the new one alongside it, since both write the file again and leave nothing to move past. The switch was the second half of one answer, and it is now the second of four: ExistingFilePolicy.SkipAndMoveOn. Adding a member to that enum is the one dangerous edit here. Four sites compared against ExistingFilePolicy.Skip, and one of them - the check in TrackRecorder.AlreadyOnDisk - compared negated. A member missing from a comparison like that fails no build and no test; it quietly records over a file the user asked to keep. All four now ask RecordingSettings.KeepsTheExistingFile, and the enriched-template test became a Theory over both keep-it policies to pin it. Skip keeps its name and its zero value. Member names are what settings.json stores and JsonStringEnumConverter throws on a value it does not recognise, which SettingsStore.Load turns into a hard "fix or delete the file to start from defaults" - so renaming it to Keep would fail every existing install to gain a tidier identifier. Session_UnderAnOverwritePolicy_NeverAsksSpotifyToMoveOn is gone rather than converted. It existed to prove the core read the policy and the boolean together, because a hand-edited settings file could disagree with the greyed-out page. That state cannot be expressed now. What replaced it asserts the live question instead: under Overwrite the track is recorded, not kept - the negative side of KeepsTheExistingFile. Both collapses drop settings keys. recording.recordSelection replaces three, output.existingFilePolicy absorbs one, and there is no importer, so a settings.json tuned around the old behaviour comes back at the defaults for those settings. Deliberate: the keys never shipped in a release, and a permanent migration path is a poor trade for them. The rest of the page follows from having room again: Descriptions are tooltips. A line of prose under every switch cost height on a page that has none - the bottom card has now been clipped three times - and said the same thing whether or not anyone needed it. The row carries a ToolTip so hovering anywhere finds it, and the control carries the same sentence as AutomationProperties.HelpText, because a tooltip is the one thing on the page a screen reader cannot see. Text unchanged, still in Strings.resx and .fr.resx, still translated. Recorded as a standing convention in CLAUDE.md rather than a one-off. The existing-file policy and the move-on setting moved out of File names. What to do about a file already on disk is a rule about recording, not about what recordings are called. They now sit in Recording, which is what the Detection card is called: it had long since stopped being only about detection. Giving the policy dropdown its own row also fixes the switch label, which was clipped at "Also tell Spotify to mov". A test that fails the build on the page's height was attempted and cannot be kept. It measures correctly - 495 units for Advanced against the Settings page's 536, at the shell's content width of 976 - but StaticResource resolves at load time, so the dictionaries must exist before the page's constructor runs, which needs an Application. Application.Current is a process-wide singleton bound to its creating thread, and that thread ends with the test, leaving every later test in the assembly marshalling onto a dead dispatcher: nine RecordViewModelTests failed on the first run. Measuring the shell instead does not work either, since an unshown window arranges to nothing. Settings has never been reported as clipped, so Advanced now fits wherever Settings does - which is the closest thing to a proof available without a shown window. Both the measurement and the two dead ends are in the plan, with the assembly-scoped STA fixture that would actually work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two unlabelled buttons under the template box read as actions on what is in it - reset, apply - rather than as layouts to start from. They now sit behind a Presets caption, docked left rather than on a line of its own, because this card is at the top of a page that has to fit the window's minimum height and a row spent on one word is a row the bottom card loses. Three more layouts, chosen for how people actually keep music rather than for covering the token grammar. Numbered tracks puts the track number in front of each title, so players and Explorer keep album order. Albums by year leads the album folder with the year, so an artist's records sort chronologically instead of alphabetically - the layout a user was already typing by hand. A folder per day groups by recording date rather than by album, for long sessions and radio. Each button's tooltip is the path its layout produces, rendered by OutputPaths.BuildFromTemplate - the same call behind the live preview, so an example cannot promise a name the recorder would not write, and the 260-character budgeting is included rather than omitted by a hand-rolled preview. A preset is only ever picked for its outcome, and the label has room for a name but not for a path. The output root is dropped from a preset's example and kept in the live preview: the preview answers where the next recording goes, a preset answers what shape the names are, and the root is identical across all five. It is still passed to the renderer either way, so the length budgeting sees the real path. The presets are data now, not a command and a button apiece. A new layout costs a row in one table rather than a command, a resource key, a button and a tooltip, and every button goes through one UsePreset command with its own preset as the parameter. That makes a wrongly-bound parameter the failure mode worth a test, so there is one: every button applies its own template, every preset's template passes FileNameTemplate.Validate, and every example renders. A preset is a string literal in a table, and nothing else stops a typo'd token from shipping - it costs a click to discover, and the click overwrites the template the user was happy with. Presets is a get-only property rebuilt per read, notified alongside TemplatePreview when the document changes, because each example ends in the output format's extension and the Settings page can change that underneath this one. The tooltip is the example rather than a description, which is a departure from the convention CLAUDE.md now carries: what a screen reader needs on these buttons is not an account of the control but the outcome itself. So HelpText is the same sentence, and the label goes in AutomationProperties.Name. Height, which is the constraint this page keeps losing to: the row already existed, so at the window's usual width nothing changes. The WrapPanel is there so that at the narrowest width the fifth button moves down instead of being clipped silently, which costs about 36 units and puts the page near 531 against the Settings page's 536. Still under the comparison the fit rests on, but the margin is now that difference rather than forty units, and the plan's measurement note says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two settings on the Advanced page were spread across several controls that could not all be true at once, so the page spent its height greying things out to explain hierarchies that were never visible in the first place. Both collapse to a dropdown, by the same arithmetic each time.
Advertisements: three switches, four combinations, three outcomes
Mute advertisements / Record everything Spotify plays / Include advertisements. Two of the three did nothing unless a third allowed it. It is now
recording.recordSelection:Mute advertisements never muted anything, which is why this was worth looking at rather than relabelling.
AudioSessions.SetMutehad no callers, so the switch shipped on by default and its one real effect was to veto record-everything silently — turn that on and nothing happened, with nothing on the page to say why. The predecessor never hit this because its form unchecked mute-ads the moment record-everything was checked; Offstream had inherited the rule and not the interlock.Restoring muting was considered and rejected. The predecessor's version muted every other application on the machine and held Spotify at full volume, to catch video adverts on a separate audio session — so it never silenced the advert either. A recorder reaching out to mute unrelated applications is a side effect nobody asked for. It is now off the roadmap rather than owed.
Existing files: three values and a boolean, six combinations, four outcomes
"Also tell Spotify to move on" did nothing under Replace it or Save the new one alongside it — both write the file again and leave nothing to move past. It was the second half of one answer, and is now the second of four:
The dangerous edit
Adding a member to
ExistingFilePolicyis the one change here that could have broken silently. Four sites compared againstExistingFilePolicy.Skip, and one — the check inTrackRecorder.AlreadyOnDisk— compared negated. A member missing from a comparison like that fails no build and no test; it quietly records over a file the user asked to keep. All four now askRecordingSettings.KeepsTheExistingFile, and the enriched-template test became aTheoryover both keep-it policies to pin it.Skipkeeps its name and its zero value: member names are whatsettings.jsonstores,JsonStringEnumConverterthrows on a value it does not recognise, andSettingsStore.Loadturns that into a hard "fix or delete the file to start from defaults". Renaming it toKeepwould fail every existing install to gain a tidier identifier.A test removed rather than converted
Session_UnderAnOverwritePolicy_NeverAsksSpotifyToMoveOnexisted to prove the core read the policy and the boolean together, because a hand-edited settings file could disagree with the greyed-out page. That state cannot be expressed now. Its replacement asserts the live question instead: under Overwrite the track is recorded, not kept — the negative side ofKeepsTheExistingFile.Settings keys
recording.recordSelectionreplaces three keys andoutput.existingFilePolicyabsorbs one. There is no importer, so asettings.jsontuned around the old behaviour comes back at the defaults for those settings. Deliberate — the keys never shipped in a release, and a permanent migration path is a poor trade for them.The rest of the page
ToolTip; the control carries the same sentence asAutomationProperties.HelpText, because a tooltip is the one thing on the page a screen reader cannot see. Text unchanged, still translated. Recorded as a standing convention inCLAUDE.mdrather than a one-off.The height test could not be kept
A build-failing measurement test was attempted. It measures correctly — 495 units for Advanced against the Settings page's 536, at the shell's content width of 976 — but
StaticResourceresolves at load time, so the dictionaries must exist before the page's constructor runs, which needs anApplication.Application.Currentis a process-wide singleton bound to its creating thread, and that thread ends with the test, leaving every later test in the assembly marshalling onto a dead dispatcher: nineRecordViewModelTestsfailed on the first run. Measuring the shell instead does not work either, since an unshown window arranges to nothing.Settings has never been reported as clipped, so Advanced now fits wherever Settings does — the closest thing to a proof available without a shown window. Both the measurement and the two dead ends are written up in
docs/MODERNIZATION-PLAN.md, with the assembly-scoped STA fixture that would actually work.Filename presets, said out loud
Two unlabelled buttons under the template box read as actions on what is in it — reset, apply — rather than as layouts to start from. They now sit behind a Presets caption, and there are five:
{artist} - {title}{artist}\{album} ({year})\{title}{artist}\{album} ({year})\{track:00} {title}{artist}\({year}) {album}\{track:00} {title}{date:yyyy-MM-dd}\{artist} - {title}Each button's tooltip is the path its layout produces, rendered by
OutputPaths.BuildFromTemplate— the same call behind the live preview — so an example cannot promise a name the recorder would not write, and the 260-character budgeting is included rather than omitted by a hand-rolled preview. The output root is dropped from a preset's example and kept in the live preview: the preview answers where the next recording goes, a preset answers what shape the names are, and the root is identical across all five. It is still passed to the renderer either way, so the length budgeting sees the real path.The presets are data now, not a command and a button apiece. A new layout costs a row in one table rather than a command, a resource key, a button and a tooltip — and every button goes through one
UsePresetcommand with its own preset as the parameter. That makes a wrongly-bound parameter the failure mode worth testing, soPresets_AllOfferATemplateTheRendererAcceptsandUsePreset_WritesThatPresetIntoTheTemplatecover it. A preset is a string literal in a table, and nothing else stops a typo'd token from shipping: it costs a click to discover, and the click overwrites the template the user was happy with.The tooltip here is the outcome, not a description — a departure from the convention this PR writes into
CLAUDE.md. What a screen reader needs on these buttons is not an account of the control but the example itself, soHelpTextcarries the same sentence and the label goes inAutomationProperties.Name.On height: the row already existed, so at the window's usual width nothing changes. The
WrapPanelis there so the fifth button moves down at the narrowest width instead of being clipped silently — about 36 units, putting the page near 531 against the Settings page's 536. Still under the comparison the fit rests on, but the margin is now that difference rather than forty units, and the plan's measurement note says so.Testing
924
Offstream.Core.Tests+ 204Offstream.UI.Testsgreen, 0 warnings under analyzers-as-errors. The page itself was reviewed by hand — the layout claim is a measurement plus a comparison, not a test.🤖 Generated with Claude Code