{
  "$comment": "User-facing AND internal changes, newest first. Every entry: version (CalVer YYYY.M.N it ships in), date, area, type, userFacing, title (one line), details (full). The welcome screen shows userFacing entries released since the version last run. See CLAUDE.md > Change log for the authoring rules. Ships with the product and is copied to the CDN on release.",
  "entries": [
    {
      "version": "2026.8.32",
      "date": "2026-08-31",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "title": "Release builds run without a memory or swap limit",
      "details": "The build containers were capped at 4g, and 8g for the web AOT, with --memory-swap set equal to --memory. Both are gone. The cap was sized for a 4 vCPU / 7 GB box whose 2 GB of swap was already fully consumed by long-idle services, where one OOM kill had already landed and the kernel picks its victim by score, so the casualty could as easily have been ezmuze.studio, the api or the proxy as the compiler. The box is 8 vCPU / 23 GB now with about 16 GB available and a swapfile holding 10 MiB of 2 GB, and four parallel heads peak well inside that. EZMUZE_BUILD_MEM still re-caps without a code change since this box has changed capacity twice already; unset means unlimited. Recorded while removing it because it would otherwise mislead whoever reinstates a cap: --memory-swap never applied on this host at all. Ubuntu does not set swapaccount=1 on the kernel cmdline, so Docker accepted the flag, dropped it, and emitted 'WARNING: No swap limit support' where nobody looks; docker inspect showed MemorySwap -1, meaning unlimited, and memory.memsw.limit_in_bytes did not exist, while --memory itself applied exactly. The comment asserting the containers could not swap was false for as long as it existed."
    },
    {
      "version": "2026.8.31",
      "date": "2026-08-31",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "title": "The welcome screen no longer spills out of its own window",
      "details": "On the first launch after an update, the demo cards could run straight out of the bottom of the welcome dialog and onto the background behind it, with the Open/New/Close buttons drawn over the top of them, and no scrollbar. It looked intermittent because it depended on timing: the dialog is built as the app starts, and the \"What's new\" list arrives a moment later when the change log finishes loading. On a warm start the log was already there and everything fitted; when it landed late, the dialog had already decided how tall it was and never reconsidered. It now grows to fit whatever arrives, and falls back to a scrollbar when the window is genuinely too short for all of it."
    },
    {
      "version": "2026.8.32",
      "date": "2026-08-31",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": false,
      "title": "VerticalStackElement re-measures children that never write their SizeTrait",
      "details": "Root cause of the welcome screen spilling out of its modal. The stack re-laid out on two signals: a child added or removed (the Update poll added 2026-08-18 for the Kill path), and a child writing its SizeTrait. A child using SizeFitsChildren does neither - ElementExtensions.GetSize measures it live and leaves CachedSizeTrait untouched for the life of the element - so it can grow silently. AboutModal wraps the whole welcome body in exactly such a container, so when the change log landed after the modal was built, the stack kept reporting its old height; ModalWindowElement sized the modal from that stale number AND read the same stale number when deciding whether it had overflowed, which is why there was no clipping and no scrollbar rather than one or the other. The Update poll now also remembers the child sizes it laid out against and recalculates when a live GetSize disagrees - per child, since two children changing by equal and opposite amounts leave the total unchanged while everything below the first is misplaced. Position and size writes are now skipped when the value has not moved, so a pass that can run every frame does not fire listeners every frame. Measured on the sequencer with a demo loaded afterwards: 142 fps, tree update 0.30ms, and the timeline rebound 21 times across 4100 update calls, so nothing churns."
    },
    {
      "version": "2026.8.31",
      "date": "2026-08-31",
      "area": "Sequencer",
      "type": "Improvement",
      "userFacing": true,
      "title": "Selecting a range of bars moved to the right mouse button",
      "details": "2026.8.30 put the sequencer's new time selection on a LEFT drag over empty timeline, which meant panning had to move out of the way to make room. It is a right drag now, and everything the left button used to do it does again: move a clip, drag its edges, pan empty timeline, click the ruler to seek and drag it to scrub. Right-drag the ruler to select across every channel. The change also fixes a hole the first version had - a left drag could not start a selection on top of a clip, because that gesture moves the clip, so a channel packed edge to edge was one you could not start a selection on at all. A right drag starts anywhere, clips included. A right CLICK that does not move is left alone, ready for the right-click menu that comes with the ripple tools. Middle-drag still pans as well, which is the one pan that works while the pointer is over a clip."
    },
    {
      "version": "2026.8.31",
      "date": "2026-08-31",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "title": "The bar selection is a soft wash that breathes, with no outline",
      "details": "The selection was drawn as a translucent blue box with a brighter blue border. The border was wrong at that scale: it turned every covered channel into an outlined object of its own, so a range across six channels read as six boxes rather than one range, and over a clip it competed with the clip's own edges. It is one flat tint now. Losing the outline cost it some definition over a busy waveform, so instead of simply making it more opaque - which would have hidden the clips it covers - it breathes: about five points of opacity, in and out over roughly two and a half seconds. Movement is what makes it findable; the opacity stays low enough to read the waveforms straight through. The breathe is deliberately not locked to the tempo, because it marks a selection rather than a musical event."
    },
    {
      "version": "2026.8.31",
      "date": "2026-08-31",
      "area": "UI",
      "type": "Internal",
      "userFacing": false,
      "title": "GustUI dispatches the right mouse button as a full drag too",
      "details": "2026.8.30 gave GustUI the middle button as a press/held/release trio with its own capture slot. The right button still had only the press edge it has always had (OnRightClickTrait), which is not enough to draw a marquee with, so it now has OnRightMousePress/HeldDown/Release and a CapturedRightElement slot of its own alongside the middle button's. Three separate slots rather than one shared one, for the reason the second one existed: the buttons drive unrelated gestures on the SAME element in the sequencer - right marquee, middle pan, left clip move - and sharing would let starting any one silently cancel another. The OnRightClickTrait dispatch is deliberately left exactly where it was, after the left button's capture early-return, so no existing consumer of it changes behaviour; an element declaring both simply hears about the press twice, which is what a context menu that also wants to know about dragging needs. The remote-control API's /mousedown takes {\"right\":true} to match its {\"middle\":true}."
    },
    {
      "version": "2026.8.30",
      "date": "2026-08-31",
      "area": "Build",
      "type": "Bug fix",
      "userFacing": false,
      "title": "The DX head builds its own service plugins instead of borrowing the GL head's",
      "details": "Ezmuze.Studio.Desktop.DX referenced only Ezmuze.Services, the shared contract, and none of the five plugin projects, while the GL and Linux heads reference all of them with ReferenceOutputAssembly=false so they build without being linked against. ServicePlugins.targets copies each plugin out of its OWN bin/<config>/<tfm> directory under Condition=Exists(...), so in the DX head's tree nothing ever built them, every copy silently no-op'd, and there was no warning either: the item list was non-empty, it was the files that were missing. It nonetheless worked for as long as the DX head has existed, because the release built the GL head first IN THE SAME WORKING TREE and left the DLLs lying there for the DX copy to find. A shared checkout was doing the work a project reference should have, so no shipped DX build was ever actually missing its services. Giving each head its own workspace, which is what building them in parallel means, removed the accident and the release failed on 'win-x64-dx has no service plugins' -- the integrity check in build-head.sh earning its place on the first run that could expose this. Fixed by giving DX the same five references, and by making the targets file fail rather than shrug: it now filters to the plugins that exist, copies those, and raises an Error if that came to nothing. The per-file Exists() filter has to stay because the Linux head legitimately omits Splice (net8.0-windows), but all-absent is never legitimate for any of the three heads that import the file, and the old Warning for it could not fire and would have been invisible in a 2000-line publish log if it had."
    },
    {
      "version": "2026.8.30",
      "date": "2026-08-31",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "title": "Drag across the sequencer to select a range of bars",
      "details": "Dragging over empty timeline now draws a selection instead of panning the view. It snaps to bar lines, and holding Shift drags it off the grid - the same thing Shift already does when you drag a clip. Drag in any direction: right-to-left and bottom-to-top end up the same selection as the opposite drag. It spans as many channels as you drag over, and skips any automation lanes you have expanded, since those have their own point selection. Dragging the bar ruler at the top selects across every channel at once; a plain click there still moves the playhead exactly as before. Pressing Delete removes what the selection covers, as ONE undo step no matter how many clips it touched - and clips are not all-or-nothing, so a clip hanging over the edge of the selection is trimmed back to it rather than wiped, and a selection sitting inside a single clip splits that clip in two. Escape drops the selection. Panning by dragging has moved to the middle mouse button; the wheel and the scrollbars are unchanged. This is the first half of the feature: copy, cut, paste, the ripple tools, the right-click menu and holding a modifier to build up several selections at once are still to come."
    },
    {
      "version": "2026.8.30",
      "date": "2026-08-31",
      "area": "UI",
      "type": "Internal",
      "userFacing": false,
      "title": "GustUI dispatches the middle mouse button, with its own capture slot",
      "details": "GustUI knew about the left button (press/held/release with pointer capture) and the right button (a press edge only). The middle button was not dispatched at all, so the sequencer's timeline pan had nowhere to move to once a left drag became the marquee. InputManager now has OnMiddleMousePress/OnMiddleMouseHeldDown/OnMiddleMouseRelease and a CapturedMiddleElement slot deliberately separate from the left button's: the two drive unrelated gestures, and sharing one slot would let starting either silently cancel the other. It is processed before the left button's capture early-return, so a middle drag keeps getting frames while something else holds the left capture. PushPointerEdge and the remote-control API's /mousedown both learned the middle button too ({\"middle\":true}), so a pan is still scriptable from outside the app."
    },
    {
      "version": "2026.8.30",
      "date": "2026-08-31",
      "area": "Core",
      "type": "Internal",
      "userFacing": false,
      "title": "CompositeEdit, TimeSelection and a deep clip copy",
      "details": "Three additions to Ezmuze.Core behind the sequencer's time selection. CompositeEdit runs several edits as one undo step, reverting them backwards - deleting a selection can remove a dozen clips and trim two more, and pushing those separately would mean a dozen presses of Ctrl+Z through a half-deleted song. TimeSelection is the selection itself, normalised on construction and stored in TICKS rather than bars so Shift-dragging off the grid is not thrown away at the door; TimeSelectionMath decides, for each clip a selection meets, whether it is deleted whole, trimmed at either end, or split. LoopClipCopy is the deep copy a split needs: QuarterTriggerFx and Automation are indexed per quarter of A BAR - a repeating pattern, not a timeline - so both halves keep exactly what the whole clip was playing, but they must not keep the same objects. Unit-tested, including that a composite apply-then-revert leaves the song byte-identical."
    },
    {
      "version": "2026.8.30",
      "date": "2026-08-31",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "title": "The four heads build in parallel",
      "details": "build.yml is now prepare -> test -> a four-way matrix (web, win-x64-dx, win-x64-gl, linux-x64-gl) -> finalize, with fail-fast so one broken head cancels the rest instead of spending twelve more minutes on an AOT for a release that cannot ship. Four self-hosted runners back it, because a runner executes one job at a time and with a single runner a matrix is strictly slower than a loop. The first Actions release measured the serial cost honestly and it was not where the old docs said: tests 31s, win-x64-gl 4m38s, win-x64-dx 5m21s, linux-x64-gl 36s, web AOT 12m13s, total 23m19s. The two Windows heads are ten minutes between them, not the three the docs claimed, which is what makes parallelising worth roughly ten minutes rather than five; the floor is tests plus the AOT, about 13 minutes. release.sh's successor build.sh is split into prepare.sh, test.sh, build-head.sh and finalize.sh with the common parts in lib.sh. Two bugs that only exist under concurrency were designed out rather than discovered: the build image is now built once in prepare, since four jobs on a cold box would otherwise race to build the same 2 GB image; and container labels carry the run and job id, because the old sweep matched every container labelled ezmuze-build and would have killed a sibling's compiler mid-build. The container memory caps were re-derived for the upgraded box (8 vCPU / 23 GB, up from 4 / 7): the three desktop heads stay at their proven 4g and only the web AOT rises, to 8g, because with four containers at once the sum is what matters and 3x4g + 8g is about all the free memory there is."
    },
    {
      "version": "2026.8.29",
      "date": "2026-08-31",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "title": "The release pipeline runs on GitHub Actions",
      "details": "A push to main now runs .github/workflows/build.yml on a self-hosted runner on the VPS; on success that triggers publish.yml, which registers the version and ships it. This replaces a systemd timer polling git ls-remote every minute and one 508-line release.sh, now split into tools/release/build.sh (compile and stage, visible to nobody) and publish.sh (register, CDN, web swap, change-log stamp-back). The move was not about the trigger — a one-minute poll was fine — but about cancellation, and it deleted four mechanisms that existed only to hand-roll it: the timer and poller, the whole-release flock with poll.sh's careful reasoning about not recording a sha while a release holds the lock, the three hand-placed superseded()/give_way() checkpoints, and the last-sha state file that stopped the change-log stamp commit triggering another release (a push authenticated with GITHUB_TOKEN does not trigger workflows, so that one needs nothing at all). Publishing is a separate workflow rather than a second job, and that is the design: build.yml is cancelled the instant a newer commit lands, publish.yml can never be cancelled, so 'a version registered with no artefacts behind it' goes from a rule living in a comment to something the structure forbids. Two things the old lock covered now need naming. A cancelled build is killed mid-docker-run, so containers are labelled and swept rather than left holding the memory cap. And version derivation can now race a publish that is already in flight and would read a tracker and a CDN that neither know about it yet — so the lock file survives, for exactly that: build.sh takes it around derivation, publish.sh holds it for its whole run. The build also now compiles the commit that triggered it, where release.sh reset to whatever origin/main had become by the time it checked out. A CI workflow was removed in 2026.8.11 because the release ran the tests anyway; this is the release itself moving, not that coming back. The lock is a stopgap: docs/release-pipeline.md sketches the reservation that should replace it, where the tracker hands out a number atomically and takes it back if the build is cancelled, which would retire both this lock and the CDN register. See docs/github-actions-runner.md for installing and operating the runner."
    },
    {
      "version": "2026.8.29",
      "date": "2026-08-31",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "title": "Changelog versions are stamped by the build server, not guessed",
      "details": "Entries are now authored as \"version\": \"NEXT\" and resolved by release.sh to the real CalVer before it compiles anything, so every artefact ships the true number; the one-line change is then pushed back to main as the release's last act. Guessing was the old rule and it lost entries outright: the number could only be guessed at authoring time, a release takes ~25 minutes and one is often already in flight, and Changelog.Since only shows entries strictly newer than the version last run — so a guess at or below what shipped was invisible for ever, while guessing high merely showed a note a release late. An unresolved NEXT is safe in the meantime because Since filters on TryParseVersion, which is now pinned by a test rather than left as an incidental property. Three details carry the risk: the substitution is anchored on the key so the word NEXT in prose is untouched and the result is re-parsed as JSON before being written; the push-back resets to the current head and re-applies, because main may have moved and the build checkout is shallow; and the release records its own commit in the poller's state file so the stamp does not itself trigger another release, but only when main had not moved, since otherwise someone else's commit is in the same range and burying it unbuilt would be worse than an extra build. A failed push-back never fails the release. This entry is itself the first NEXT."
    },
    {
      "version": "2026.8.29",
      "date": "2026-08-31",
      "area": "Build",
      "type": "Improvement",
      "userFacing": false,
      "title": "A release gives way to a newer commit instead of finishing work nobody wants",
      "details": "The poller defers rather than interrupts, so two pushes a minute apart cost about fifty minutes, of which the first twenty-five built a commit already superseded. release.sh now checks whether main has moved past the commit it is building, at three points: after the tests, before the web AOT (about fifteen of the twenty-five minutes sit after that line, so it is where giving way actually saves time) and immediately before registering the version. Cooperative abandonment at named points rather than a kill, for two reasons. Registration is what /api/versions reports and what What's new and bug filing key off, so a run killed after registering but before publishing would leave a version with no artefacts behind it — hence no checkpoint after that line. And every build step is a docker run --rm, so killing the script would leave the container running to completion anyway, still holding a memory cap this box cannot spare. Abandoning needs no bookkeeping: the poller records the sha before the attempt, so main having moved is the condition its next tick already tests. An unreachable remote answers 'not superseded' on purpose, so a network blip cannot throw away a nearly finished build."
    },
    {
      "version": "2026.8.28",
      "date": "2026-08-30",
      "area": "Sound engine",
      "type": "New feature",
      "userFacing": true,
      "title": "Changing the tempo no longer changes the key",
      "details": "Loops are fitted to the bar, so moving a song's tempo has to do something to them — and until now that something was a plain resample, which speeds a loop up the way playing a record faster does: it raises the pitch. The engine could always avoid that, by time-stretching instead, but the switch for it had never been exposed anywhere and was off by default. It is now a row in File > Song settings, directly under the tempo it governs, and it is ON for new songs: dragging a BPM should change how fast a song goes, not what key it is in. Existing songs keep whatever they were saved with, so nothing you have already made will sound different. Turn it off if you want the old record-speed behaviour, which is still the right answer for some material."
    },
    {
      "version": "2026.8.28",
      "date": "2026-08-30",
      "area": "Core",
      "type": "Internal",
      "userFacing": false,
      "title": "Song.Normalize removed; it had never been read",
      "details": "Normalize was declared on Song, imported faithfully from legacy .eez files and round-tripped by the serialiser, and consulted by absolutely nothing — not the renderer, not the master chain, not any view. In legacy ezmuze it normalised at render time; here the master chain's limiter does that job, so the field had been inert for the whole life of this codebase while still reading, in Song.cs, like a live setting. Removed rather than documented, on the principle that an option nothing reads is not a feature waiting to be wired up but a false statement about what the app does. No format version bump: the serialiser skips unknown members, so every song ever saved still loads and simply drops the value, and a test pins that rather than leaving it as an incidental property of the current options. Ezmuze.Legacy's LegacySongData keeps its own copy, since that type is a faithful record of the legacy FILE format, which does contain the field. Also flips Song.TimeStretch to default true, which is the user-facing half above; NaiveSongRendererTests.ShorterLoopPcm_IsStretchedToBarGrid now pins TimeStretch:false explicitly, because it is about the naive nearest-sample fit and was previously relying on the default to get it."
    },
    {
      "version": "2026.8.27",
      "date": "2026-08-30",
      "area": "Sequencer",
      "type": "New feature",
      "userFacing": true,
      "title": "Songs can be in 3/4, 6/8 and nine other time signatures",
      "details": "Every song used to be in 4/4, and not because that was chosen — the number four was simply written into the code in four separate places, in the audio engine, in the note timing and in both grid views, with nowhere to change it. A song now carries its own time signature, and File > Song settings is where you set it, along with the tempo. That dialog is new too: until now there was no way to change a song's BPM at all, so a new song was stuck at 120 and an imported one kept whatever it arrived with. Twelve signatures are offered, from 2/4 through 7/4 and 3/8 through 12/8. The ruler follows: its gridlines now land on beats rather than on eighths of a bar (which only lined up with the beat in 4/4), and zooming in labels positions as bar:beat instead of the decimal 1.125 it used to show. Changing the tempo or the signature rebuilds the song, since bar length changes and every loop has to be re-fitted to it. Existing songs are unaffected — they load as 4/4 and play exactly as they did."
    },
    {
      "version": "2026.8.27",
      "date": "2026-08-30",
      "area": "Core",
      "type": "Internal",
      "userFacing": false,
      "title": "Song format v22: the time signature is stored rather than assumed",
      "details": "Song gains BeatsPerBar and BeatUnit, and the four scattered literals that encoded 4/4 now read them through the new Ezmuze.Core.SongTiming: the renderer's bar-length maths, PatternSignals' note/beat domain, PatternBeats.PerBar behind the piano-roll and drum-lane grids, and the Bpm / 240.0 in both audio bridges (the 240 being 60 x 4, with the 4 being exactly the assumption in question). BPM stays quarter-notes-per-minute whatever the signature, matching the DAW convention and every song already on disk, so a 4/4 song's timing is bit-identical to the arithmetic it replaced — a test pins that against the old literals rather than deriving them, so a future refactor has to disagree with it rather than follow it. LoopClip.QuartersPerBar is deliberately left alone: that is how a legacy audio loop divides into quarters of itself, a property of the loop file, not a claim about bars. The format bump is not merely additive in effect — a v22 file read by a v21 build would play a 3/4 song at 4/4, putting every clip after the first in the wrong place, which is what the version stamp exists to refuse."
    },
    {
      "version": "2026.8.26",
      "date": "2026-08-30",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "title": "Only one submenu opens at a time",
      "details": "Menus could end up with two submenus open side by side, overlapping each other into something unreadable. It took a particular path to trigger, which is why it seemed to come and go: hover an item until its submenu appears, move sideways INTO that submenu, then come back and hover a different item. The first submenu stayed behind and the second opened alongside it. The reason is that an item only ever finds out the pointer has left it once, and it deliberately spends that one notification declining to close while you are moving into the submenu — after which nothing ever asked again. Submenus now belong to the menu level they open from rather than to the individual row, so opening one closes whichever was already open beside it, and closing a menu takes the whole tail of nested submenus with it however deep it goes."
    },
    {
      "version": "2026.8.25",
      "date": "2026-08-30",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "title": "bug-board: guess the changelog version high, and re-read it before pushing",
      "details": "The skill said to guess the version as the highest in /api/versions plus one. That is not enough, and it silently lost two entries in one session: an entry at or below what shipped counts as already released and never appears in What's new. Two ordinary things beat the guess. A release takes about 25 minutes, so one is often already in flight while the work is being done and lands before the push. And every push gets its own release, so splitting a fix and a follow-up commit across two pushes burns two version numbers rather than one — which is exactly what happened here. Now: guess plus TWO, re-read /api/versions immediately before pushing rather than trusting the number read at the start, and prefer one push per ticket. Also records why this is the safe direction to be wrong in: an over-guess just shows the note one release late, an under-guess hides it for good."
    },
    {
      "version": "2026.8.24",
      "date": "2026-08-30",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "title": "The sequencer fills its window, with one scrollbar instead of two",
      "details": "Two things were wrong along the bottom and right edges of the sequencer. A black band sat under the timeline, clipping the last channel row mid-row and wasting space that could have been more sequencer — the layout was subtracting the status bar's height a second time, from a window that had already set it aside. And there were genuinely two vertical scrollbars stacked almost exactly on top of each other in the corner, of which the outer one moved, resized and did nothing at all, because the window had wrapped the sequencer in a scroll view it never needed. Both are gone: the sequencer now runs down to the status bar, and the only scrollbar on screen is the one that scrolls it."
    },
    {
      "version": "2026.8.24",
      "date": "2026-08-30",
      "area": "UI",
      "type": "Internal",
      "userFacing": false,
      "title": "ModalWindowElement scroll promotion measures the authored body, not the stretched one",
      "details": "Two independent causes behind bug #20. (1) SequencerView.ComputeRowHeight subtracted StatusBarHeight from a size that already excluded it: every caller passes modal.GetSize(), BuildUi sets modal.BottomInset = StatusBar.Height, and TimelineTop is modal.ContentTop. Correct when this measured window.GetSize(); the 2026-08-16 free-resize change moved every caller to the modal and left the subtraction behind. Cost 24px of dead band, and the const is now deleted. (2) GustUI's RefreshScrollMode promotes a body into a VerticalScrollElement once it outgrows MaxModalHeight, but PositionContent stretches a non-fit-to-content body to the full client area every frame, so the measurement read back this class's own output from the previous frame — necessarily modal-sized, hence always over the cap. The sequencer's transparent 10x10 placeholder was wrapped in a viewport whose rail overlapped its real scrollbar by 10 of 12 pixels while scrolling nothing. Fixed by measuring the height the CALLER authored, captured once at construction (and per tab on adopt, restored on activate), for non-fit-to-content modals only; fit-to-content keeps measuring live, since its content genuinely arrives late. Deliberately not \"don't promote fill-available windows\" — a device panel is authored at max(420, 70% of window) and really does need to scroll on a short window; suppressing it there squeezed its content, which is how the first attempt at this was caught."
    },
    {
      "version": "2026.8.22",
      "date": "2026-08-30",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "title": "The sequencer keeps following the song while a device panel is open",
      "details": "Opening a device's panel from the Stack froze the sequencer while the song carried on playing: the playhead stopped moving, the view stopped scrolling to follow it, and the channel meters sat still — so the one moment you most want to watch the arrangement, tweaking a synth as it plays, was the one moment you could not. Closing the panel made it jump back to where the song had got to. Device panels are windows you work alongside the arrangement, not sheets that cover it, and the sequencer now keeps running underneath them. Editors that genuinely do fill the screen, like the piano roll, still pause it, because there is nothing to see behind those."
    },
    {
      "version": "2026.8.22",
      "date": "2026-08-30",
      "area": "UI",
      "type": "Internal",
      "userFacing": false,
      "title": "FullScreenModalElement.AnyOpen, and the sequencer update gate that asks it",
      "details": "EzmuzeStudioGame.Update dispatched per-frame view logic through an else-if chain whose final else was the only caller of sequencer.Update, so ANY open view froze the sequencer's view logic. That was right while every branch was a FullScreenModalElement — an opaque sheet with nothing visible beneath — but ModuleUiView and ModuleUiEditorView became ModalWindowElements (real, resizable, dockable windows) and the chain never learned about the conversion. The sequencer is now gated on a new GustUI static, FullScreenModalElement.AnyOpen, rather than on a hand-listed set of views: the sheet-using views are a shrinking group (KorbenView is already on its way out) and a list would silently rot at each conversion, whereas AnyOpen goes permanently false when the last sheet is converted and the gate can then be deleted outright. Note this was never about GustUI's own element traversal, which runs regardless — Element.Update recurses from the root every frame — but about the host's view OBJECTS, which are plain classes GustUI does not know about. The old comment's other justification, that pending rebuilds run when the modal closes, no longer applied either: the needsChannelRebuild/ReopenSequencer teardown it named is gone, replaced by in-place refresh."
    },
    {
      "version": "2026.8.20",
      "date": "2026-08-30",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "title": "A skill for working the bug board",
      "details": "`.claude/skills/bug-board` runs a ticket from intake to beta testing: reproduce the unconfirmed bugs before moving any of them, then — once no bugs are left — put each unconfirmed feature request to the user with an effort estimate, approved to Backlog and rejected to Rejected, since a feature request cannot be reproduced and never belongs in Confirmed. Then propose the three most important from Confirmed and Backlog and ask which to take, state the plan and wait for a yes before writing anything, fix, commit, watch the release build and hand over to beta testing. Reads the tracker over its REST API; the manager token stays outside this repo."
    },
    {
      "version": "2026.8.20",
      "date": "2026-08-30",
      "area": "Sequencer",
      "type": "Bug fix",
      "userFacing": true,
      "title": "Closing the now-playing panel no longer jams the transport",
      "details": "Stopping a song by closing the now-playing panel left the toolbar showing Pause and the spacebar doing nothing, so you had to press play twice to start again. The sequencer now checks the engine every frame instead of relying on each action to report itself, which also covers anything else that stops playback from outside the toolbar."
    },
    {
      "version": "2026.8.20",
      "date": "2026-08-30",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "title": "Text no longer shows through panels and dialogs",
      "details": "Labels from behind a dialog could paint straight through it — knob captions and clip names appearing on top of a solid panel. The renderer drew every shape first and then every piece of text, so a panel could not cover another element's label however far in front it was. It now draws strictly in order, so what is in front covers what is behind."
    },
    {
      "version": "2026.8.20",
      "date": "2026-08-30",
      "area": "UI",
      "type": "Bug fix",
      "userFacing": true,
      "title": "Notifications no longer stop the song",
      "details": "The now-playing panel was dropped once four notifications had arrived during playback, and losing it was read as you closing it — which stopped the song. It is now pinned: it holds its corner, notifications stack above it, and only closing it yourself stops playback."
    },
    {
      "version": "2026.8.20",
      "date": "2026-08-30",
      "area": "UI",
      "type": "Improvement",
      "userFacing": true,
      "title": "The now-playing panel replaces the playing bar",
      "details": "The white strip along the bottom is gone. What is playing now appears as a panel in the corner with the song title, the artist, a live visualiser and a progress bar showing how far through the whole song you are — so starting halfway through starts the bar at half. It sits at half opacity so the timeline reads through it, a full-screen button opens the visualiser, and its close button stops playback."
    },
    {
      "version": "2026.8.20",
      "date": "2026-08-30",
      "area": "UI",
      "type": "Performance",
      "userFacing": false,
      "title": "Drawing a frame costs far less setup",
      "details": "Five things forced the renderer to stop and submit mid-frame: raw triangle drawing (waveforms and MPE bend curves, once per element), every clipped container, additive glow regions, and the now-playing visualiser's offscreen pass. All now batch or run before the frame. On a maximised song a frame went from 17 forced submissions to 2, and drawing from 1.81 ms to about 1.55 ms."
    },
    {
      "version": "2026.8.20",
      "date": "2026-08-30",
      "area": "Core",
      "type": "Internal",
      "userFacing": false,
      "title": "Removed the SpriteBatch-era rendering scaffolding",
      "details": "GustUI has drawn everything through its geometry batch for a while, but the machinery built around SpriteBatch survived: NativeElement/VideoElement/DrawOOPManager, TVBlurFill and the three render-target-clone methods serving it, per-batch GraphicsDevice state that the batch sets again per segment, and the GPU scissor rect, which nothing read because clipping is per-vertex. Element.IsOverlay went too — thirteen call sites that existed only to defeat the reordering, all of which already carried the correct Depth. The frame profiler now reports segments (draw calls) instead of sprite and string counters that had been reporting zero."
    },
    {
      "version": "2026.8.14",
      "date": "2026-08-30",
      "area": "UI",
      "type": "New feature",
      "userFacing": true,
      "title": "See what's new each time you update",
      "details": "After an update, the welcome screen now lists the changes since the version you last ran — each tagged by area and kind, with the full details on hover. Nothing is shown on a fresh install or a dev build; the list appears the first time you launch a newer version."
    },
    {
      "version": "2026.8.14",
      "date": "2026-08-30",
      "area": "Core",
      "type": "Internal",
      "userFacing": false,
      "title": "Change log infrastructure",
      "details": "Added wwwroot/changelog.json plus Ezmuze.Core/Changelog.cs (parse + CalVer comparison + since-last-run filtering, unit-tested), a ChangelogService that loads it through the same HttpClient as the rest of wwwroot, and a last-run version stored in user preferences. LocalContent.targets ships the file with desktop builds and release.sh copies it to the CDN."
    },
    {
      "version": "2026.8.13",
      "date": "2026-08-30",
      "area": "Desktop",
      "type": "New feature",
      "userFacing": true,
      "title": "ezmuze studio now runs on Linux",
      "details": "A native Linux desktop build (DesktopGL) alongside the Windows ones. File dialogs use kdialog/zenity and the clipboard uses SDL2. The Splice service and the drag-hover-onto-timeline affordance are not available on Linux yet; dropping a file into the wave bank still works."
    },
    {
      "version": "2026.8.13",
      "date": "2026-08-30",
      "area": "Build",
      "type": "Internal",
      "userFacing": false,
      "title": "Release pipeline builds and publishes the Linux head",
      "details": "release.sh publishes Ezmuze.Studio.Desktop.Linux self-contained for linux-x64 and ships it as a .tar.gz under cdn.ezmuze.co.uk/ezmuzeStudio/<version>/linux-x64-gl/. DesktopGL native libs are copied into x64/ by hand (publish does not carry the package's AfterBuild copy). GitHub Actions was removed — the release runs the tests before every build already."
    }
  ]
}
